React State Management with useReducer: A Comparison-Based Guide for Design Systems
Before You Begin
- Node.js 18+ and npm/yarn installed
- A React project (Create React App, Vite, or Next.js) – we'll use Vite
- Familiarity with React hooks (useState, useEffect)
- Basic understanding of JavaScript arrow functions, destructuring, and switch statements
Versions used in this tutorial: React 18.2, Vite 5, React 18 types.
1. The Problem: Multiple useState Calls in a Component Library
Imagine you're building a component library called FruitDesignSystem. Your first component is a Button that can be disabled, loading, show success, or show error. A naive approach with useState:
function Button({ label }) {
const [disabled, setDisabled] = useState(false);
const [loading, setLoading] = useState(false);
const [status, setStatus] = useState('idle'); // idle | success | error
// ... handlers that need to update multiple states
}This works, but watch what happens when you need to disable the button while loading, then clear loading after success. You end up calling setDisabled, setLoading, setStatus in sequence, creating three separate re-renders and potential race conditions. Worse, the logic is scattered across the component.
Comparison: useState is fine for independent toggles (like a single checkbox). But when state transitions involve multiple variables that must change together (disabled + loading + status), useReducer gives you a single place to define those transitions.
2. Defining the State Shape and Actions
Instead of scattered state, we define one object that holds everything the Button needs. In our fruit-themed system, we'll use fruit names for action types to keep it memorable.
// ButtonReducer.js
// State shape
const initialState = {
disabled: false,
status: 'idle', // 'idle' | 'loading' | 'success' | 'error'
label: 'Click Me',
};
// Action types (as constants)
export const BUTTON_ACTIONS = {
GRAPE_PRESS: 'GRAPE_PRESS', // user clicks -> start loading
MANGO_SUCCESS: 'MANGO_SUCCESS', // operation succeeded
APPLE_ERROR: 'APPLE_ERROR', // operation failed
BANANA_RESET: 'BANANA_RESET', // reset to idle
CHERRY_TOGGLE: 'CHERRY_TOGGLE', // toggle disabled
};Each action carries a payload when needed. The reducer is a pure function that takes the current state and an action, and returns a new state.
3. Building the Reducer Function
A reducer must not mutate state. It returns a new object. Here's the reducer for our Button:
export function buttonReducer(state, action) {
switch (action.type) {
case BUTTON_ACTIONS.GRAPE_PRESS:
// Button becomes disabled and loading
return {
...state,
disabled: true,
status: 'loading',
label: 'Loading...',
};
case BUTTON_ACTIONS.MANGO_SUCCESS:
return {
...state,
disabled: false,
status: 'success',
label: 'Done!',
};
case BUTTON_ACTIONS.APPLE_ERROR:
return {
...state,
disabled: false,
status: 'error',
label: 'Retry',
};
case BUTTON_ACTIONS.BANANA_RESET:
return {
...state,
disabled: false,
status: 'idle',
label: 'Click Me',
};
case BUTTON_ACTIONS.CHERRY_TOGGLE:
return {
...state,
disabled: !state.disabled,
};
default:
return state;
}
}Notice how each case returns a complete new state. No multiple setState calls. All transitions are centralized.
4. Using useReducer in the Button Component
Now we wire the reducer into the component with useReducer:
import { useReducer } from 'react';
import { buttonReducer, initialState, BUTTON_ACTIONS } from './ButtonReducer';
function Button({ label = 'Click Me', onClick }) {
const [state, dispatch] = useReducer(buttonReducer, {
...initialState,
label, // override initial label with prop
});
const handleClick = async () => {
dispatch({ type: BUTTON_ACTIONS.GRAPE_PRESS });
try {
await onClick(); // assume onClick returns a promise
dispatch({ type: BUTTON_ACTIONS.MANGO_SUCCESS });
setTimeout(() => dispatch({ type: BUTTON_ACTIONS.BANANA_RESET }), 2000);
} catch {
dispatch({ type: BUTTON_ACTIONS.APPLE_ERROR });
}
};
return (
<button
disabled={state.disabled}
onClick={handleClick}
className={`btn btn--${state.status}`}
>
{state.label}
</button>
);
}The component only dispatches actions. It doesn't need to know how to combine disabled and status. The reducer handles that. This makes the component much simpler to read and test.
5. Comparison with useState: Performance and Predictability
Let's put both approaches side by side for a hypothetical scenario: user clicks, loading starts, success occurs, then reset after 2 seconds.
With useState (3 hooks):
- Each
setStatecall triggers a re-render. That's 3+ re-renders for the click → loading → success → reset sequence. - State updates might be batched in React 18, but the logic is still spread across the component.
- Testing requires mocking three separate state setter functions.
With useReducer:
- One dispatch per event. The reducer computes the new state in one go. React 18 batches updates, so even if you dispatch multiple actions in a row (e.g., success then reset via setTimeout), they cause separate renders – but the reducer is pure and predictable.
- All state transition logic is in one pure function, easily testable without React.
- The component only cares about
dispatch({ type: ... }).
For a design system with dozens of components, this consistency is gold. You can export reducers and test them independently.
6. Adding a Second Component: Toggle with Complex State
Let's extend our FruitDesignSystem with a Toggle component that has an internal state: { on: boolean, disabled: boolean, label: string }. The toggle can be flipped, disabled, or reset.
export const TOGGLE_ACTIONS = {
FLIP: 'FLIP',
DISABLE: 'DISABLE',
ENABLE: 'ENABLE',
};
const toggleInitialState = {
on: false,
disabled: false,
label: 'Off',
};
export function toggleReducer(state, action) {
switch (action.type) {
case TOGGLE_ACTIONS.FLIP:
const newOn = !state.on;
return {
...state,
on: newOn,
label: newOn ? 'On' : 'Off',
};
case TOGGLE_ACTIONS.DISABLE:
return { ...state, disabled: true };
case TOGGLE_ACTIONS.ENABLE:
return { ...state, disabled: false };
default:
return state;
}
}Now you can reuse the same pattern across components. The component library's useReducer pattern becomes a standard.
7. Common Issues with useReducer
Issue 1: Forgetting to return state in the default case.
// Wrong: missing default
switch (action.type) {
case 'A': return { ... };
// no default – returns undefined
}Always include default: return state; – otherwise the reducer returns undefined and your component breaks.
Issue 2: Mutating state directly.
// Wrong
case 'FLIP':
state.on = !state.on;
return state;React will not detect the change because the object reference hasn't changed. Always return a new object.
Issue 3: Stale closure in dispatch.
If you call dispatch inside a useEffect or callback that captures an old state, the reducer still works correctly because it receives the current state via the dispatch function, not from the closure. This is actually a benefit – useReducer avoids stale state issues that plague useState with closures.
Issue 4: Using too many reducers.
For a single component, use one reducer per logical state domain. Don't split into ten tiny reducers – that defeats the purpose. A good rule: if your component manages 3+ related state variables, useReducer is a better fit.
8. Testing the Reducer (Bonus)
Since reducers are pure functions, you can test them without React:
import { buttonReducer, initialState, BUTTON_ACTIONS } from './ButtonReducer';
describe('buttonReducer', () => {
it('should set loading on GRAPE_PRESS', () => {
const next = buttonReducer(initialState, { type: BUTTON_ACTIONS.GRAPE_PRESS });
expect(next.disabled).toBe(true);
expect(next.status).toBe('loading');
});
});This is far simpler than testing component state with render and act. You can compose your reducers and reuse them across components.
Summary
useReducer isn't just for global state. It's a powerful tool for managing local component state that has multiple dependent fields. By centralizing transition logic, you make components easier to read, test, and reason about. In a design system, this pattern scales beautifully – every component's reducer becomes a self-contained state machine.
Next time you reach for a third useState in a single component, ask yourself: is this a good candidate for useReducer?
Memuat komentar...