Fixing React useReducer State Resets That Lose Updates on Re-mount
You dispatch an action, the UI updates, and then the component unmounts for a split second β a route transition, a conditional render, a Suspense boundary β and when it comes back, the state is gone. Your reducer is fine. Your dispatch calls are fine. The problem is that React treats every new mount as a fresh start.
This article explains exactly what causes useReducer state to reset on re-mount and walks through several concrete fixes ranked by complexity.
What you'll learn
- Why useReducer state is tied to component instance lifetime, not component identity
- How to diagnose whether a reset is caused by unmounting or something else
- Four practical strategies to persist state across re-mounts
- When each strategy is the right tool for the job
- Common pitfalls that make the problem worse
Prerequisites
You should be comfortable with React hooks, understand the basics of useReducer, and have a rough mental model of how React's reconciler works. No external libraries are required for most fixes, though one section touches on Zustand as an option.
Why useReducer Resets on Re-mount
React stores hook state in a "fiber" β an internal node in the component tree. When a component unmounts, React discards that fiber along with every hook value attached to it. When the component mounts again, React creates a brand-new fiber and calls useReducer from scratch, passing your initialState or calling your init function again.
This is intentional behavior. React does not keep a cache of hook state for components that are no longer in the tree. The lifecycle is strict: mount β state lives β unmount β state dies.
The confusion arises because component identity (same function, same position in the tree) feels like it should imply continuity. It doesn't. Only an uninterrupted presence in the tree guarantees that state survives.
Diagnosing the Problem
Before reaching for a fix, confirm that unmounting is actually what's happening. There are three other reasons useReducer can appear to reset:
- Stale closure in a dispatch call β a callback captured an old version of dispatch or state.
- Key prop change β changing the
keyprop on a component forces React to unmount and remount it immediately. - Reducer returning the initial state accidentally β a missing
defaultbranch in your switch statement silently returnsundefined, which React replaces with the initial state.
Add a useEffect with an empty dependency array and log a message on both mount and cleanup. If you see mount β unmount β mount in quick succession, the component is definitely cycling.
useEffect(() => {
console.log('mounted');
return () => console.log('unmounted');
}, []);
React DevTools Profiler will also show you which components are being committed in each render pass. That's often faster than adding logs.
Fix 1: Stabilize the Key Prop
If the re-mount is caused by a changing key prop, the fix is simply to stop changing the key. This sounds obvious, but it's surprisingly common to use an index or a derived value as a key when it shouldn't be.
// Bad β index changes when list order changes, forcing remounts
{items.map((item, index) => (
<ItemEditor key={index} item={item} />
))}
// Good β stable identity
{items.map((item) => (
<ItemEditor key={item.id} item={item} />
))}
If you are intentionally using key to reset state (a legitimate pattern), then the state reset is by design. Don't fight it β instead rethink whether a key reset is the correct reset mechanism for your use case.
Fix 2: Lift State to a Parent That Stays Mounted
The cleanest architectural fix is to move the useReducer call up to a parent component that never unmounts during the scenario you care about. The child component becomes stateless from the reducer's perspective β it just receives props and dispatches actions upward.
// Parent β always mounted
function EditorContainer() {
const [state, dispatch] = useReducer(editorReducer, initialState);
return (
<Routes>
<Route path="/edit" element={<Editor state={state} dispatch={dispatch} />} />
<Route path="/preview" element={<Preview state={state} />} />
</Routes>
);
}
// Child β can unmount freely
function Editor({ state, dispatch }) {
return (
<input
value={state.title}
onChange={(e) => dispatch({ type: 'SET_TITLE', payload: e.target.value })}
/>
);
}
This pattern works well when the state is genuinely shared between routes or siblings. It breaks down if the state is truly private to the child and lifting it creates awkward prop drilling.
Fix 3: Persist State with useRef Between Mounts
When you can't lift state and you want a zero-dependency solution, store the last known state in a useRef attached to a parent component, then pass it as the initialState when the child re-mounts.
function EditorContainer() {
const savedState = useRef(initialEditorState);
return <Editor savedState={savedState} />;
}
function Editor({ savedState }) {
const [state, dispatch] = useReducer(editorReducer, savedState.current);
// Sync the latest state back to the ref on every render
useEffect(() => {
savedState.current = state;
});
return <input value={state.title} onChange={/* ... */} />;
}
The key detail: the useEffect with no dependency array runs after every render, keeping savedState.current up to date. When the child unmounts and remounts, useReducer initializes from the ref's current value instead of the hardcoded initial state.
One caveat: useRef is mutable and does not trigger re-renders, so this is a write-and-forget pattern. That's fine here because you're using it as a snapshot buffer, not reactive state.
Fix 4: Move State Outside React with a Store
For state that needs to survive across unmounts reliably and be accessible from multiple components, move it out of the component tree entirely. You can use React Context combined with useReducer at a high level, or reach for a lightweight store like Zustand.
The Context approach keeps things inside React's model:
const EditorContext = React.createContext(null);
export function EditorProvider({ children }) {
const [state, dispatch] = useReducer(editorReducer, initialState);
return (
<EditorContext.Provider value={{ state, dispatch }}>
{children}
</EditorContext.Provider>
);
}
export function useEditor() {
return React.useContext(EditorContext);
}
Place EditorProvider above any route or conditional that could cause the consuming component to unmount. The state now lives in the Provider's fiber, which stays mounted as long as the Provider does.
If even the Provider can unmount, Zustand stores state entirely outside the React tree and survives any mount/unmount cycle without extra plumbing. It's a pragmatic choice when the Context approach feels like too many moving parts.
Fix 5: Use React's Built-in Activity (Experimental)
React's experimental <Activity> component (previously discussed under the offscreen API) is designed exactly for this scenario. It lets React keep a subtree's state alive while hiding it from the screen, rather than unmounting it.
// Conceptual β API is still experimental as of recent React canary builds
import { unstable_Activity as Activity } from 'react';
<Activity mode={isVisible ? 'visible' : 'hidden'}>
<Editor />
</Activity>
When mode is 'hidden', the component stays mounted in the tree but is not painted. Its hook state, including your useReducer, is fully preserved. This is the most direct solution but it's not production-stable across all React versions yet. Keep an eye on the React changelog before adopting it in a critical path.
Common Pitfalls
Passing a new object reference as initialState every render
If you write useReducer(reducer, { count: 0 }) inside a component, JavaScript creates a new object on every render call. React only reads initialState on the first mount, so this doesn't cause repeated resets β but it does mean the initial state is re-evaluated as an expression every render, which is wasteful. Define initialState outside the component or use the lazy initializer form: useReducer(reducer, arg, initFn).
Forgetting the default case in your reducer
function reducer(state, action) {
switch (action.type) {
case 'INCREMENT':
return { ...state, count: state.count + 1 };
// Missing default β returns undefined on unknown actions
}
}
Always add default: return state;. Without it, any unknown action type silently corrupts your state, which can look identical to a reset if the initial state and undefined produce similar-looking renders.
Relying on Strict Mode double-invocation behavior
In development with React.StrictMode, React intentionally mounts, unmounts, and remounts every component to help you detect side-effect bugs. If your state loss only reproduces in development and not production, this is likely why. It's not a bug to ignore β it's a signal that your component isn't handling the mount/unmount cycle cleanly.
Losing state during Suspense fallback transitions
When a component is inside a <Suspense> boundary and the boundary falls back, children may unmount depending on the React version and how the boundary is structured. Lifting state above the Suspense boundary is the safest fix here.
Choosing the Right Fix
| Scenario | Best fix |
|---|---|
| Key prop is changing unnecessarily | Stabilize the key |
| State is shared across routes or siblings | Lift state to a parent |
| State is private, parent is available | useRef snapshot in parent |
| State needed across many components | Context + useReducer provider |
| Complex global state, multiple consumers | External store (Zustand etc.) |
| Hiding UI without unmounting (future) | Activity component |
Wrapping Up
State loss on re-mount is one of those bugs that feels mysterious until you understand React's fiber lifetime model. Once it clicks, the fix is usually straightforward.
Here are your concrete next steps:
- Add mount/unmount logs to confirm the component is actually cycling before you change any code.
- Check your
keyprops first β it's the most common cause and the easiest fix. - If the component legitimately unmounts, decide whether to lift state or use a ref-based snapshot based on whether the state is shared or private.
- Add a
default: return state;branch to every reducer you own if it doesn't already have one. - Watch the React changelog for the
ActivityAPI stabilizing β it will become the standard solution for keep-alive patterns.
π€ Share this article
Sign in to saveRelated Articles
Comments (0)
No comments yet. Be the first!