g the state and its mutations. Using useReducer provides predictable state transitions and batches updates efficiently, which is critical for complex UI interactions.
// features/data-mapper/types.ts
export interface MappingField {
id: string;
sourceKey: string;
targetKey: string;
transformType: 'uppercase' | 'lowercase' | 'passthrough';
}
export interface MapperState {
currentStep: number;
fields: MappingField[];
isSubmitting: boolean;
}
export type MapperAction =
| { type: 'SET_STEP'; payload: number }
| { type: 'ADD_FIELD'; payload: MappingField }
| { type: 'REMOVE_FIELD'; payload: string }
| { type: 'UPDATE_FIELD'; payload: { id: string; updates: Partial<MappingField> } }
| { type: 'TOGGLE_SUBMITTING'; payload: boolean };
Step 2: Build the Provider with Value Stabilization
The context value must be memoized. Without useMemo, React treats the context object as a new reference on every render, forcing all consumers to re-render regardless of actual state changes.
// features/data-mapper/context/MapperContext.tsx
"use client";
import React, { createContext, useContext, useReducer, useMemo, useCallback } from 'react';
import { MapperState, MapperAction, MappingField } from '../types';
const initialState: MapperState = {
currentStep: 1,
fields: [],
isSubmitting: false,
};
function mapperReducer(state: MapperState, action: MapperAction): MapperState {
switch (action.type) {
case 'SET_STEP':
return { ...state, currentStep: action.payload };
case 'ADD_FIELD':
return { ...state, fields: [...state.fields, action.payload] };
case 'REMOVE_FIELD':
return { ...state, fields: state.fields.filter(f => f.id !== action.payload) };
case 'UPDATE_FIELD':
return {
...state,
fields: state.fields.map(f =>
f.id === action.payload.id ? { ...f, ...action.payload.updates } : f
),
};
case 'TOGGLE_SUBMITTING':
return { ...state, isSubmitting: action.payload };
default:
return state;
}
}
interface MapperContextValue {
state: MapperState;
addField: (field: MappingField) => void;
removeField: (id: string) => void;
updateField: (id: string, updates: Partial<MappingField>) => void;
advanceStep: () => void;
resetMapper: () => void;
}
const MapperContext = createContext<MapperContextValue | null>(null);
export function MapperProvider({ children }: { children: React.ReactNode }) {
const [state, dispatch] = useReducer(mapperReducer, initialState);
const addField = useCallback((field: MappingField) => dispatch({ type: 'ADD_FIELD', payload: field }), []);
const removeField = useCallback((id: string) => dispatch({ type: 'REMOVE_FIELD', payload: id }), []);
const updateField = useCallback((id: string, updates: Partial<MappingField>) => dispatch({ type: 'UPDATE_FIELD', payload: { id, updates } }), []);
const advanceStep = useCallback(() => dispatch({ type: 'SET_STEP', payload: state.currentStep + 1 }), [state.currentStep]);
const resetMapper = useCallback(() => dispatch({ type: 'SET_STEP', payload: 1 }), []);
const contextValue = useMemo<MapperContextValue>(
() => ({ state, addField, removeField, updateField, advanceStep, resetMapper }),
[state, addField, removeField, updateField, advanceStep, resetMapper]
);
return <MapperContext.Provider value={contextValue}>{children}</MapperContext.Provider>;
}
Step 3: Create a Safe Consumption Hook
Custom hooks enforce usage boundaries and prevent silent failures when components render outside the provider tree.
// features/data-mapper/hooks/useMapper.ts
import { useContext } from 'react';
import { MapperContext } from '../context/MapperContext';
export function useMapper() {
const context = useContext(MapperContext);
if (!context) {
throw new Error('useMapper must be used within a MapperProvider boundary');
}
return context;
}
Step 4: Mount at the Feature Boundary
Place the provider at the exact entry point of the feature module. This creates a hard render boundary that isolates internal updates from the rest of the application.
// features/data-mapper/DataMapperWidget.tsx
import { MapperProvider } from './context/MapperContext';
import StepNavigator from './components/StepNavigator';
import FieldEditor from './components/FieldEditor';
import SummaryPanel from './components/SummaryPanel';
export default function DataMapperWidget() {
return (
<MapperProvider>
<section className="border rounded-lg p-4 bg-slate-50">
<StepNavigator />
<FieldEditor />
<SummaryPanel />
</section>
</MapperProvider>
);
}
Architecture Decisions & Rationale
useReducer over useState: Complex UI state benefits from explicit action types. Reducers centralize mutation logic, making state transitions traceable and testable. They also batch updates automatically, reducing intermediate renders.
useMemo on Context Value: Context triggers re-renders when its value reference changes. Wrapping the context object in useMemo ensures consumers only update when actual state or callbacks change, not on every parent render.
useCallback for Dispatch Wrappers: Inline functions create new references on each render. Memoizing action dispatchers prevents unnecessary re-renders in child components that receive them as props.
- Strict Boundary Mounting: Placing the provider at the feature root ensures that state updates never escape the subtree. This aligns with React's composition model and prevents accidental global leakage.
Pitfall Guide
1. Context Value Instability
Explanation: Failing to memoize the context value causes React to treat it as a new object on every render, forcing all consumers to re-render regardless of actual state changes.
Fix: Always wrap the context value in useMemo. Ensure dependencies accurately reflect state and callback references.
2. Missing Hook Safety Check
Explanation: Consuming context without verifying provider presence leads to silent undefined errors or cryptic runtime crashes when components render outside the expected tree.
Fix: Implement a guard clause in the custom hook that throws a descriptive error if context is null or undefined.
3. Inline Object Creation in Provider
Explanation: Creating arrays or objects directly in the provider body (e.g., value={{ state, actions }}) generates new references on every render, breaking memoization and triggering subtree re-renders.
Fix: Extract the value object into a useMemo block. Avoid inline object literals in the value prop.
4. Over-Engineering Simple State
Explanation: Applying useReducer and complex action types to trivial state (e.g., a single toggle or counter) introduces unnecessary boilerplate and cognitive overhead.
Fix: Reserve reducers for state with multiple interdependent fields or complex transition logic. Use useState for simple, isolated values.
5. Ignoring Context Selector Pattern
Explanation: Consuming the entire context object forces components to re-render whenever any part of the state changes, even if they only depend on a single field.
Fix: Implement a selector hook or use useContext with a memoized selector function. Alternatively, split context into multiple focused providers (e.g., MapperStateProvider, MapperActionsProvider).
6. Mixing Global and Local State Without Contracts
Explanation: Attempting to sync feature-scoped state with a global store without clear boundaries creates bidirectional update loops, stale data, and unpredictable render cycles.
Fix: Establish a unidirectional data flow. If global sync is required, expose a single serialization method (e.g., getSnapshot()) that the global store calls explicitly, rather than maintaining two-way bindings.
7. Forgetting Next.js App Router Compatibility
Explanation: Client-side context providers fail in Server Components if not properly marked. Attempting to use useState or useContext in a server component throws a runtime error.
Fix: Always include "use client" at the top of context files. Keep server components purely for data fetching and layout composition, delegating interactive state to client boundaries.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Cross-feature data sharing (auth, theme, locale) | Global Store or Root Context | Required by multiple independent modules | Low overhead, high coupling |
| Complex interactive widget (wizard, mapper, dashboard) | Feature-Scoped Context | Isolates renders, auto-cleans memory | Minimal overhead, high modularity |
| Simple toggle or counter | Local useState | No cross-component sharing needed | Zero overhead |
| Server-fetched data with client mutations | React Query / SWR + Local Context | Separates async caching from UI state | Moderate setup, high performance |
| URL-driven state (filters, pagination) | Search Params / Router State | Syncs with browser history, enables sharing | Low memory, high UX consistency |
Configuration Template
Copy this template to scaffold a new feature-scoped context. Replace placeholders with your domain-specific types and actions.
// features/[feature-name]/context/[Feature]Context.tsx
"use client";
import React, { createContext, useContext, useReducer, useMemo, useCallback } from 'react';
// 1. Define types
interface [Feature]State { /* ... */ }
type [Feature]Action = /* ... */;
// 2. Initial state
const initialState: [Feature]State = { /* ... */ };
// 3. Reducer
function [feature]Reducer(state: [Feature]State, action: [Feature]Action): [Feature]State {
// Handle actions
return state;
}
// 4. Context interface
interface [Feature]ContextValue {
state: [Feature]State;
// Expose memoized actions
}
const [Feature]Context = createContext<[Feature]ContextValue | null>(null);
// 5. Provider
export function [Feature]Provider({ children }: { children: React.ReactNode }) {
const [state, dispatch] = useReducer([feature]Reducer, initialState);
// Memoize actions
const actions = useMemo(() => ({
// dispatch wrappers
}), [dispatch]);
const value = useMemo<[Feature]ContextValue>(
() => ({ state, ...actions }),
[state, actions]
);
return <[Feature]Context.Provider value={value}>{children}</[Feature]Context.Provider>;
}
// 6. Safe hook
export function use[Feature]() {
const ctx = useContext([Feature]Context);
if (!ctx) throw new Error('use[Feature] must be inside [Feature]Provider');
return ctx;
}
Quick Start Guide
- Create the feature directory:
mkdir -p features/my-widget/context features/my-widget/components
- Define types and reducer: Draft the state interface, action union, and reducer logic in
types.ts and context/MyWidgetContext.tsx
- Implement provider and hook: Add
useMemo/useCallback wrappers, export the provider and safe consumption hook
- Mount at boundary: Import
MyWidgetProvider into your main feature component and wrap the JSX subtree
- Consume in children: Use
useMyWidget() in any descendant component to read state or trigger actions
- Verify isolation: Open React DevTools, trigger a state change, and confirm only the subtree re-renders