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)
}
How It Works
Storage Key Sanitization: It prepends
reducer:and safely converts spaces to underscores so storage keys remain clean and predictable in Developer Tools.Session Storage Sync:
useSessionStorageinitializes the starting state fromsessionStorage(falling back toinitialState).Wrapped Reducer: A
useCallbackwrapper intercepts every action dispatched, runs your standard reducer logic, writes the updated state tosessionStorage, and returns the new state.Native API: It returns the exact same
[state, dispatch]tuple you expect from standarduseReducer, 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>
)
}
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)
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 touchesDate.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
useReducercopy, last dispatch winning in storage while both keep rendering their own version?