Skip to content

Instantly share code, notes, and snippets.

@mattiaerre
Created April 29, 2020 02:12
Show Gist options
  • Save mattiaerre/8dbd2d8efca3f242c7085a9ce82ecbde to your computer and use it in GitHub Desktop.
Save mattiaerre/8dbd2d8efca3f242c7085a9ce82ecbde to your computer and use it in GitHub Desktop.
React hook useReducerWithLocalStorage
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'
});
});
@mattiaerre
Copy link
Author

the useReducerWithLocalStorage React hook wraps the useReducer hook and rehydrates the state w/ what has been persisted in the window.localStorage

@mdboop
Copy link

mdboop commented Apr 29, 2020

My only feedback/question would be could you support merging the localStorage state with an initial state you pass to the reducer?
What if you have state persisted in localStorage you want to hydrate, but the component using this also has some initial, partial state, like loading? Normally I'd say don't put that in localStorage, but if you're using a reducer you probably want to tie all that together to coordinate something.

@mattiaerre
Copy link
Author

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 the initializerArg prop to feed the initial state for your reducer as well as adding that particular object to the local storage. Good point for the loading 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 use useReducer for the top component and useReducerWithLocalStorage 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 ๐Ÿ™

@mdboop
Copy link

mdboop commented Apr 30, 2020

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.

@mattiaerre
Copy link
Author

mattiaerre commented Apr 30, 2020

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:

@mattiaerre
Copy link
Author

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;

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment