Overview
Context plus reducer is a React-native state management pattern that combines useReducer for predictable state transitions with context for sharing state and dispatch through a component subtree. External stores are state containers that live outside React's component tree and expose subscription APIs so components can read selected state and update when the store changes.
This topic matters because state management choices shape maintainability, performance, testing, and team workflow. A small feature may only need local state. A complex screen may benefit from reducer plus context. A large application with shared, frequently updated state may need an external store such as Redux, Zustand, Jotai, Valtio, or another library.
For interviews, this topic is important because candidates often overreach in both directions. Some reach for a global store too early. Others force context to handle high-frequency, app-wide updates and then fight rerender problems. A strong answer explains the shape of the state, update frequency, ownership boundary, debugging needs, and performance trade-offs before picking a tool.
Core Concepts
Local State First
Not all state needs a global solution.
Local state is usually best for:
- Input focus.
- Modal open/closed state.
- Hover state.
- Local tab selection.
- Temporary component UI state.
- Small form drafts.
- Component-only toggles.
Example:
function ExpandablePanel() {
const [open, setOpen] = useState(false);
return (
<section>
<button onClick={() => setOpen((value) => !value)}>
{open ? "Collapse" : "Expand"}
</button>
{open ? <PanelContent /> : null}
</section>
);
}
Putting this in context or an external store would add coordination cost without solving a real problem.
Reducers
useReducer is useful when state transitions are easier to describe as events.
type State = {
selectedIds: string[];
filter: string;
};
type Action =
| { type: "filterChanged"; filter: string }
| { type: "itemSelected"; id: string }
| { type: "selectionCleared" };
function reducer(state: State, action: Action): State {
switch (action.type) {
case "filterChanged":
return { ...state, filter: action.filter };
case "itemSelected":
return {
...state,
selectedIds: [...state.selectedIds, action.id],
};
case "selectionCleared":
return { ...state, selectedIds: [] };
default:
return state;
}
}
Reducers help when:
- Several fields update together.
- Updates have names and intent.
- State transitions need tests.
- Event handlers are getting crowded.
- Many components trigger the same state changes.
Reducers do not automatically make state global. They only organize update logic.
Context
Context lets components read a value from the nearest provider above them in the tree.
const ThemeContext = createContext<"light" | "dark">("light");
function App() {
return (
<ThemeContext.Provider value="dark">
<Toolbar />
</ThemeContext.Provider>
);
}
function Toolbar() {
const theme = useContext(ThemeContext);
return <div data-theme={theme}>Toolbar</div>;
}
Context is useful for values that are logically scoped to a subtree:
- Theme.
- Locale.
- Current user identity.
- Permission snapshot.
- Design system configuration.
- Wizard state.
- Feature-specific reducer state.
Context avoids prop drilling, but it is not automatically a high-performance state store.
Context Plus Reducer
Reducer plus context combines structured updates with easy access throughout a subtree.
type TasksState = {
tasks: Task[];
};
type TasksAction =
| { type: "added"; task: Task }
| { type: "changed"; task: Task }
| { type: "deleted"; id: string };
const TasksStateContext = createContext<TasksState | null>(null);
const TasksDispatchContext = createContext<Dispatch<TasksAction> | null>(null);
function TasksProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(tasksReducer, { tasks: [] });
return (
<TasksStateContext.Provider value={state}>
<TasksDispatchContext.Provider value={dispatch}>
{children}
</TasksDispatchContext.Provider>
</TasksStateContext.Provider>
);
}
Consumer hooks make usage safer:
function useTasksState() {
const value = useContext(TasksStateContext);
if (!value) {
throw new Error("useTasksState must be used inside TasksProvider");
}
return value;
}
function useTasksDispatch() {
const value = useContext(TasksDispatchContext);
if (!value) {
throw new Error("useTasksDispatch must be used inside TasksProvider");
}
return value;
}
This pattern keeps components focused on rendering and events while the reducer owns transition logic.
Splitting State and Dispatch Contexts
Splitting state and dispatch contexts is a common performance and clarity improvement.
const StateContext = createContext<State | null>(null);
const DispatchContext = createContext<Dispatch<Action> | null>(null);
Why this helps:
- Components that only dispatch actions do not need to subscribe to state changes.
- The dispatch function from
useReducerhas stable identity. - Consumers can choose what they need.
Example:
function AddTaskButton() {
const dispatch = useTasksDispatch();
return (
<button onClick={() => dispatch({ type: "added", task: createTask() })}>
Add task
</button>
);
}
This component does not rerender because the task list changed unless its own props or parent render force it.
Context Rerender Behavior
When a context provider's value changes, components that read that context can rerender.
This matters when the context value is large or changes frequently.
<AppContext.Provider value={{ state, dispatch }}>
{children}
</AppContext.Provider>
The object literal is new whenever the provider renders. If state changes frequently, all consumers that read AppContext can be affected, even if they only need one field.
Better options:
- Split state and dispatch contexts.
- Split unrelated state into separate providers.
- Move provider lower in the tree.
- Memoize provider values where appropriate.
- Use an external store with selectors when fine-grained subscriptions matter.
Context is not wrong here. The issue is subscription granularity.
Provider Scope
Provider placement defines state lifetime and rerender boundaries.
App-wide provider:
<AuthProvider>
<RouterProvider router={router} />
</AuthProvider>
Feature-scoped provider:
<CheckoutProvider>
<CheckoutRoutes />
</CheckoutProvider>
Prefer the narrowest provider scope that matches the state lifetime. A checkout reducer does not need to sit above the whole application unless other app areas depend on it.
External Stores
An external store keeps state outside React's component tree. Components subscribe to it and read snapshots or selected values.
External stores commonly provide:
- Centralized state.
- Selectors.
- Fine-grained subscriptions.
- Middleware.
- DevTools.
- Persistence.
- Undo/redo.
- Cross-route access.
- Framework-independent state access.
Redux-style shape:
const store = configureStore({
reducer: {
auth: authReducer,
preferences: preferencesReducer,
},
});
Component usage:
const userName = useSelector((state: RootState) => state.auth.user?.name);
const dispatch = useDispatch();
The important feature is not that the store is "global." The important feature is that subscribers can often select the exact state they need.
When Context Plus Reducer Fits
Context plus reducer is a good fit when:
- State belongs to one feature or subtree.
- Updates are moderately complex.
- Prop drilling is painful.
- The team wants no extra dependency.
- State changes are not extremely frequent.
- Most consumers need the same state.
- DevTools/time-travel/persistence are not major requirements.
Examples:
- Multi-step wizard.
- Checkout flow.
- Modal manager inside a feature area.
- Complex local editor panel.
- Feature-specific permissions snapshot.
- Nested task list screen.
It is a strong middle ground between local useState and a full app-level store.
When External Stores Fit
External stores are a better fit when:
- State is needed across distant routes.
- Many components need different slices of state.
- State updates frequently.
- Fine-grained subscriptions are important.
- Debugging update history matters.
- Middleware, persistence, or undo/redo is needed.
- Non-React code must read or update the state.
- The app has a large team and needs stronger conventions.
Examples:
- Authentication/session state used across the app.
- Feature flags.
- Complex client-side editor state.
- Global notifications.
- Collaborative presence.
- App-wide preferences.
- Large normalized client state.
External stores are not automatically better. They bring concepts, conventions, and dependency cost.
Server State Is Different
Server state should usually not be managed with reducer plus context or a plain client store unless the team is deliberately building a cache.
Server state has different needs:
- Loading state.
- Error state.
- Staleness.
- Refetching.
- Cache invalidation.
- Deduplication.
- Retries.
- Optimistic updates.
Use route loaders, RTK Query, TanStack Query, or another server-state tool when data is owned by the backend.
Bad fit:
dispatch({ type: "usersLoaded", users });
This may be fine for a tiny app, but large apps usually benefit from a real server-state cache.
Reducer Testability
Reducers are easy to test because they are pure functions.
it("adds a task", () => {
const state = { tasks: [] };
const next = tasksReducer(state, {
type: "added",
task: { id: "1", text: "Write tests", done: false },
});
expect(next.tasks).toHaveLength(1);
});
This is one reason reducer plus context is attractive for complex screen state. The update rules can be tested without rendering React components.
External Store Testability
External stores can also be testable, but the strategy depends on the library.
Common approaches:
- Test reducers or store actions directly.
- Create a fresh store per test.
- Preload state for component tests.
- Assert selectors return expected slices.
- Use integration tests for full flows.
External stores often add stronger test setup requirements, but they can make app-level behavior more consistent.
Performance Trade-Offs
Context plus reducer:
- Simple mental model.
- No external dependency.
- Good for feature-local state.
- Can rerender broad consumers when provider value changes.
- No built-in selector subscription model.
External stores:
- Better for fine-grained subscriptions.
- Better for cross-route state.
- Often have devtools and middleware.
- Add dependency and architecture decisions.
- Can be overkill for small feature state.
Performance should be measured. Do not migrate to a store because of a vague feeling. Identify the component rerendering too often and fix the subscription boundary.
Common Mistakes
Common mistakes include:
- Putting every state value in a global store.
- Using context as a high-frequency state store for the whole app.
- Creating one huge app context with unrelated state.
- Passing
{ state, dispatch }as a new object to every consumer without thinking about rerenders. - Using context for server state caching.
- Moving state far above where it is needed.
- Adding Redux or another store before local and feature-scoped state are exhausted.
- Ignoring selectors and subscription granularity in external stores.
- Mutating reducer state instead of returning new state.
Best Practices
Best practices include:
- Start with local state.
- Lift state only when multiple components need it.
- Use
useReducerwhen update logic becomes event-based or complex. - Use context to avoid prop drilling inside a clear subtree.
- Split state and dispatch contexts.
- Keep providers as low as practical.
- Split unrelated contexts.
- Use external stores when state is broad, frequent, selector-heavy, or needs tooling.
- Use server-state libraries for backend-owned data.
- Profile before changing state architecture.