-
-
Save mattiaerre/8dbd2d8efca3f242c7085a9ce82ecbde to your computer and use it in GitHub Desktop.
import { useState } from 'react'; | |
// credit: https://usehooks.com/useLocalStorage/ | |
function useLocalStorage(key, initialValue) { | |
const [storedValue, setStoredValue] = useState(() => { | |
try { | |
const item = window.localStorage.getItem(key); | |
return item ? JSON.parse(item) : initialValue; | |
} catch (error) { | |
console.log(error); | |
return initialValue; | |
} | |
}); | |
function setValue(value) { | |
try { | |
const valueToStore = | |
value instanceof Function ? value(storedValue) : value; | |
setStoredValue(valueToStore); | |
window.localStorage.setItem(key, JSON.stringify(valueToStore)); | |
} catch (error) { | |
console.log(error); | |
} | |
} | |
return [storedValue, setValue]; | |
} | |
export default useLocalStorage; |
import { useReducer } from 'react'; | |
import useLocalStorage from './useLocalStorage'; | |
function useReducerWithLocalStorage({ initializerArg, key, reducer }) { | |
const [localStorageState, setLocalStorageState] = useLocalStorage( | |
key, | |
initializerArg | |
); | |
return useReducer( | |
(state, action) => { | |
const newState = reducer(state, action); | |
setLocalStorageState(newState); | |
return newState; | |
}, | |
{ ...localStorageState } | |
); | |
} | |
export default useReducerWithLocalStorage; |
import { renderHook, act } from '@testing-library/react-hooks'; | |
import useLocalStorage from './useLocalStorage'; | |
import useReducerWithLocalStorage from './useReducerWithLocalStorage'; | |
jest.mock('./useLocalStorage'); | |
const emptyState = { firstName: '', lastName: '' }; | |
const mockSetLocalStorageState = jest.fn(); | |
const mockLocalStorageState = { ...emptyState, lastName: 'Doe' }; | |
useLocalStorage.mockImplementation(() => [ | |
mockLocalStorageState, | |
mockSetLocalStorageState | |
]); | |
const REACT_APP_STATE = 'REACT_APP_STATE'; | |
const CHANGE_FIRST_NAME = 'CHANGE_FIRST_NAME'; | |
function reducer(state, action) { | |
switch (action.type) { | |
case CHANGE_FIRST_NAME: | |
return { ...state, firstName: action.value }; | |
default: | |
return state; | |
} | |
} | |
const mockReducer = jest.fn((state, action) => reducer(state, action)); | |
test('useReducerWithLocalStorage', () => { | |
const { | |
result: { | |
current: [state, dispatch] | |
} | |
} = renderHook(() => | |
useReducerWithLocalStorage({ | |
initializerArg: emptyState, | |
key: REACT_APP_STATE, | |
reducer: mockReducer | |
}) | |
); | |
expect(useLocalStorage).toBeCalledWith(REACT_APP_STATE, emptyState); | |
expect(state).toEqual(mockLocalStorageState); | |
act(() => { | |
dispatch({ type: CHANGE_FIRST_NAME, value: 'John' }); | |
}); | |
expect(mockSetLocalStorageState).toBeCalledWith({ | |
firstName: 'John', | |
lastName: 'Doe' | |
}); | |
}); |
Ah, maybe I didn't put it quite right, but I'm thinking of the case where you have local storage state AND you have temporary state initialized by the component. Take this (bad) example:
function App() {
const initialState = {
status: 'loading',
value: '',
}
const reducer = (state, action) => {
switch (action.type) {
case 'TOGGLE_ASYNC':
return {
...state,
status: state.status == 'ready' ? 'loading' : 'ready',
}
case 'ONCHANGE':
return { ...state, value: action.payload }
default:
return state
}
}
const [state, dispatch] = useReducerWithLocalStorage({
initializerArg: initialState,
key: 'myapp',
reducer: reducer,
})
useEffect(() => {
setTimeout(() => {
dispatch({ type: 'TOGGLE_ASYNC' })
}, 1000)
}, [])
return (
<div>
<div>{state.status}</div>
<div>
<input
value={state.value}
disabled={state.status === 'loading'}
onChange={(e) => {
dispatch({ type: 'ONCHANGE', payload: e.target.value })
}}
/>
</div>
<div>
<button
onClick={() => {
dispatch({ type: 'TOGGLE_ASYNC' })
setTimeout(() => {
dispatch({ type: 'TOGGLE_ASYNC' })
}, 1000)
}}
>
reset loading
</button>
</div>
</div>
)
}
In this case, when you refresh the page, you end up with status showing as ready
even though what I would want here is loading
along with the text input saved in localstorage. Maybe this is a use case you don't want to support, and I would totally respect that, but I could see it being useful.
I think you would have to introduce a new argument, something like defaultOverrides
or something, because you couldn't simply merge the states (you would end up writing over every key).
And yes, just re-reading this:
Let's say I have an app that has a form; maybe the updates against the form fields are managed by a reducer and I want to persist that object. But this is an inner component in a bigger React app that has a global state that deals w/ loading and fetching and all these kind o booleans. I would use useReducer for the top component and useReducerWithLocalStorage maybe only for the inner one that deals w/ the form.
For sure, I think I'm taking something that is clean and elegant and jamming too many features into it. It's probably better to keep them separate as you're suggesting. I guess I'm still just thinking of cases where you're using a reducer to coordinate related state and some of it is transient and some is persisted, but yeah... this makes it more complicated. Feature creep! Anyway, this was fun to take a look at. Excellent little hook here.
edit: added disabled
state to loading state to show how they might be linked.
this is awesome @mdboop thanks for that. You are absolutely right. I like very much your suggestion and
I think you would have to introduce a new argument, something like defaultOverrides or something, because you couldn't simply merge the states (you would end up writing over every key).
that or
One other option is to add an additional feature to this reducer to pass a list of properties I do not wish to store locally
in this case, the defaultOverrides
will be inferred by the initialState
.
also, I've created a repo for this component as well as published it to npm
:
-
https://github.com/Tweries/useReducerWithLocalStorage heavily WIP
-
https://www.npmjs.com/package/use-reducer-with-local-storage
-
https://github.com/Tweries/silver-tip just a playground where I use this hook
how about this?
import { useReducer } from 'react';
import remove from './remove';
import useLocalStorage from './useLocalStorage';
function useReducerWithLocalStorage({
blacklist = [],
initializerArg,
key,
reducer
}) {
const [localStorageState, setLocalStorageState] = useLocalStorage(
key,
remove({ blacklist, state: initializerArg })
);
return useReducer(
(state, action) => {
const newState = reducer(state, action);
setLocalStorageState(remove({ blacklist, state: newState }));
return newState;
},
{ ...initializerArg, ...localStorageState }
);
}
export default useReducerWithLocalStorage;
Oh yeah, good point. It's kind of hidden actually but it already supports merging the local storage state w/ an initial state. The
initializerArg
prop is actually the default if the local storage is empty and that will be used as a default for the reducer. So, for instance, say that you have a reducer initial state and you have empty local storage, you would use theinitializerArg
prop to feed the initial state for your reducer as well as adding that particular object to the local storage. Good point for theloading
properties kind of thing. I would say that I would use this hook only if I care to save the whole state. I may have an inner state managed by a reducer that I would like to persist and only that one would be enhanced by this hook. Let's say I have an app that has a form; maybe the updates against the form fields are managed by a reducer and I want to persist that object. But this is an inner component in a bigger React app that has a global state that deals w/ loading and fetching and all these kind o booleans. I would useuseReducer
for the top component anduseReducerWithLocalStorage
maybe only for the inner one that deals w/ the form. One other option is to add an additional feature to this reducer to pass a list of properties I do not wish to store locally ๐ that's maybe a better approach. Thanks so much for this feedback ๐