DEV Community

webit
webit

Posted on

How to Persist React useReducer State with sessionStorage

Client-side React applications are fast, interactive, and offer an incredible user experience. However, they share a common pain point: a page reload resets all local state.

If you're managing complex local state—like multi-step forms, active drawer filters, or UI configurations—using React's native useReducer, a simple browser refresh wipes out everything.

To solve this without adding heavy global state management, here is a lightweight, universal custom hook: useSessionStorageReducer.

The Solution

By combining React’s useReducer with useSessionStorage (from react-use), we can automatically persist every state update to sessionStorage per browser tab.

import { useCallback, useReducer, type Dispatch } from 'react'
import { useSessionStorage } from 'react-use'

type ReducerFn<State, Action> = (state: State, action: Action) => State

export default function useSessionStorageReducer<State, Action>(
  key: string,
  reducer: ReducerFn<State, Action>,
  initialState: State
): [State, Dispatch<Action>] {
  const [sessionStorageValue, setSessionStorageValue] = useSessionStorage(
    `reducer:${key.replaceAll(/\s+/g, '_')}`,
    initialState
  )

  const reducerLocalStorage = useCallback(
    (state: State, action: Action) => {
      const newState = reducer(state, action)
      setSessionStorageValue(newState)
      return newState
    },
    [reducer, setSessionStorageValue]
  )

  return useReducer(reducerLocalStorage, sessionStorageValue)
}
Enter fullscreen mode Exit fullscreen mode

How It Works

  1. Storage Key Sanitization: It prepends reducer: and safely converts spaces to underscores so storage keys remain clean and predictable in Developer Tools.

  2. Session Storage Sync: useSessionStorage initializes the starting state from sessionStorage (falling back to initialState).

  3. Wrapped Reducer: A useCallback wrapper intercepts every action dispatched, runs your standard reducer logic, writes the updated state to sessionStorage, and returns the new state.

  4. Native API: It returns the exact same [state, dispatch] tuple you expect from standard useReducer, making it a 1:1 drop-in replacement.

Quick Example Usage

type State = { count: number }
type Action = { type: 'INCREMENT' } | { type: 'DECREMENT' }

function counterReducer(state: State, action: Action): State {
  switch (action.type) {
    case 'INCREMENT':
      return { count: state.count + 1 }
    case 'DECREMENT':
      return { count: state.count - 1 }
    default:
      return state
  }
}

export function Counter() {
  const [state, dispatch] = useSessionStorageReducer('counter_key', counterReducer, { count: 0 })

  return (
    <div>
      <p>Count: {state.count}</p>
      <button onClick={() => dispatch({ type: 'INCREMENT' })}>+</button>
      <button onClick={() => dispatch({ type: 'DECREMENT' })}>-</button>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Grab the Code

Feel free to grab or fork the full Gist here:
👉 GitHub Gist: useSessionStorageReducer.ts

How are you currently persisting complex local state across reloads in your React projects? Let me know in the comments!

Top comments (1)

Collapse
 
raknaos profile image
Raknaos

useSessionStorageValue(newState) firing from inside the wrapped reducer is the one thing I would flag: the reducer stops being pure, and StrictMode double-invokes it precisely to expose that. As long as the reducer is deterministic the write is just redundant, but the first time it touches Date.now() or a generated id, the state you persist and the state you render are two different values, not one written twice.

Moving the write to an effect that watches the returned state keeps your 1:1 drop-in shape and removes the problem. Did you run into the sharing case as well — two components on one key, each holding its own useReducer copy, last dispatch winning in storage while both keep rendering their own version?