useReducerWithLocalStorage — Persisting Reducer State to Local Storage

February 28, 2022

The built-in useReducer hook is a great tool for managing multiple pieces of component state that need to be updated together. In a recent use case, I was managing a user's preferances/settings withing an app with useReducer and needed to sync those settings with local storage.

On the component level, the state was initially managed as follows:

// reducer
const calculationParamsReducer = (state: UserCustomisableParams, action: ActionType) => {
  switch (action.type) {
    case 'updatePreference1':
      return { ...state, preferance1: action.payload };
    case 'updatePreference2':
      return { ...state, preferance2: action.payload };
  }
};

// initial state
const defaultUserPreferences = {
  preferance1: true,
  preference2: false,
};

// usage
const [userCustomisedParams, dispatch] = useReducer(calculationParamsReducer, defaultUserPreferences);

I'd been using a custom hook to sync component state to local storage with a variant of the useLocalStorage hook. Its API looks like this:

// the hook takes in a key and a default value
const [name, setName] = useLocalStorage<string>('name', 'Bob');

The goal was to compose a new custom hook from this useLocalStorage hook that worked with useReducer.

Composing useLocalStorage with useReducer

Fortunately, I stumbled upon useReducerWithLocalStorage by Mattia Richetto, which shows how to compose useLocalStorage with useReducer.

The hook was a fit for my use case except that it wasn't written in TypeScript. I modified it slightly by making the passing of arguments resemble useReducer and rewrote it in TypeScript:

import * as React from 'react';
// useLocalStorage from useHooks
import { useLocalStorage } from './useLocalStorage';

export const useReducerWithLocalStorage = <S, A>(reducer: React.Reducer<S, A>, initializerArg: S, key: string) => {
  const [localStorageState, setLocalStorageState] = useLocalStorage(key, initializerArg);

  return React.useReducer(
    (state: S, action: A) => {
      const newState = reducer(state, action);
      setLocalStorageState(newState);
      return newState;
    },
    { ...localStorageState }
  );
};

export default useReducerWithLocalStorage;

Now I had a flexible hook that provided me with the required type information.

Applying the hook to the problem

I could now take this new hook to use to manage user preferances in the app like so:

// reducer
const calculationParamsReducer = (
  state: UserCustomisableParams,
  action: ActionType,
) => {
  switch (action.type) {
    case 'updatePreference1':
      return { ...state, preferance1: action.payload }
    case 'updatePreference2':
      return { ...state, preferance2: action.payload }
  }
}

// initial state
const defaultUserPreferences = {
  preferance1: true,
  preference2: false,
}

// usage
const [userCustomisedParams, dispatch] =
  useReducerWithLocalStorage(
    calculationParamsReducer,
    defaultUserPreferences,
    'user-preferences',
  )

Here's a link to the a gist of this hook.