It's not about knowing the API. It's about knowing when your component needs to think differently.
There's a specific moment in a code review that tells me everything I need to know about where a developer is in their React code.
I ask: "Why did you use useState here instead of useReducer?"
The junior answer: "Because it's simpler."
The senior answer: "Because the state changes don't depend on each other, so there's no state machine here. If they did, I'd use useReducer."
Both answers reference the same two hooks. But the senior answer reveals something the junior answer doesn't: a mental model for when the problem changes shape. The junior knows the API. The senior knows the diagnosis.
The two hooks that most consistently reveal the gap between junior and senior thinking are useReducer and useRef.
Not because they're the hardest hooks. Because they're the ones that require you to think about your problem differently before you write any code.
useState
Here's the thing about useState: it's so easy that it becomes the answer to every question.
Component needs something? useState. Need two things? Two useState calls. Need five things? Five useState calls.
And it works. Until it doesn't.
The moment it stops working isn't dramatic. It's subtle. It looks like this:
// The component that's starting to tell you something
function CheckoutFlow() {
const [step, setStep] = useState<1 | 2 | 3>(1);
const [items, setItems] = useState<CartItem[]>([]);
const [discount, setDiscount] = useState<string>('');
const [isValidatingDiscount, setIsValidatingDiscount] = useState(false);
const [discountError, setDiscountError] = useState<string | null>(null);
const [discountAmount, setDiscountAmount] = useState(0);
const [isSubmitting, setIsSubmitting] = useState(false);
const [orderError, setOrderError] = useState<string | null>(null);
const [orderId, setOrderId] = useState<string | null>(null);
// Now every handler touches multiple pieces of state.
// The coordination logic is implicit — it lives in your head, not in the code.
const handleApplyDiscount = async () => {
setIsValidatingDiscount(true);
setDiscountError(null);
try {
const result = await validateDiscount(discount);
setDiscountAmount(result.amount);
setIsValidatingDiscount(false);
} catch (err) {
setDiscountError('Invalid discount code');
setDiscountAmount(0);
setIsValidatingDiscount(false);
}
};
// And this is just ONE handler.
// There are five more like it.
}
Nine useState calls. Every handler coordinates multiple setters. The state transitions are implicit scattered across event handlers, useEffect dependencies, and callback chains.
This isn't a useState problem. It's a diagnosis problem. The component is managing a state machine — a set of explicit states with explicit transitions between them — but using a tool designed for independent values.
That's the gap useReducerfills.
useReducer — When Your State Has a Narrative
I'd like to start with this question: Can I describe the states my component can be in as named states with named transitions?
If yes — you have a state machine. useReducer is how you make that machine explicit.
Let me show you the checkout flow refactored. Not because useReducer makes the code shorter — it doesn't, necessarily. Because it makes the state machine readable.
// The shape of possible states — explicit, named, complete
type CheckoutStatus =
| 'browsing' // User is reviewing their cart
| 'validating_discount' // Discount code is being validated
| 'discount_applied' // Discount was valid and applied
| 'submitting' // Order is being placed
| 'success' // Order placed successfully
| 'error'; // Something went wrong
interface CheckoutState {
status: CheckoutStatus;
step: 1 | 2 | 3;
items: CartItem[];
discountCode: string;
discountAmount: number;
orderId: string | null;
error: string | null;
}
// Actions — named events that happen in the world
type CheckoutAction =
| { type: 'DISCOUNT_CODE_CHANGED'; payload: string }
| { type: 'DISCOUNT_VALIDATION_STARTED' }
| { type: 'DISCOUNT_VALIDATED'; payload: { amount: number } }
| { type: 'DISCOUNT_REJECTED'; payload: string }
| { type: 'NEXT_STEP' }
| { type: 'PREV_STEP' }
| { type: 'ORDER_SUBMISSION_STARTED' }
| { type: 'ORDER_PLACED'; payload: { orderId: string } }
| { type: 'ORDER_FAILED'; payload: string }
| { type: 'RESET' };
const initialCheckoutState: CheckoutState = {
status: 'browsing',
step: 1,
items: [],
discountCode: '',
discountAmount: 0,
orderId: null,
error: null,
};
function checkoutReducer(state: CheckoutState, action: CheckoutAction): CheckoutState {
switch (action.type) {
case 'DISCOUNT_CODE_CHANGED':
// Can only change discount code when not currently validating
// This is an implicit rule that used to live in the handler —
// now it lives in the reducer, where it's explicit and testable
if (state.status === 'validating_discount') return state;
return {
...state,
discountCode: action.payload,
// Clear any previous discount when the code changes
discountAmount: 0,
error: null,
status: 'browsing',
};
case 'DISCOUNT_VALIDATION_STARTED':
return {
...state,
status: 'validating_discount',
error: null,
};
case 'DISCOUNT_VALIDATED':
return {
...state,
status: 'discount_applied',
discountAmount: action.payload.amount,
error: null,
};
case 'DISCOUNT_REJECTED':
return {
...state,
status: 'browsing',
discountAmount: 0,
error: action.payload,
};
case 'NEXT_STEP':
// Can only advance if not in a blocking state
if (state.status === 'validating_discount' || state.status === 'submitting') {
return state;
}
return {
...state,
step: Math.min(3, state.step + 1) as 1 | 2 | 3,
error: null,
};
case 'PREV_STEP':
return {
...state,
step: Math.max(1, state.step - 1) as 1 | 2 | 3,
error: null,
};
case 'ORDER_SUBMISSION_STARTED':
return { ...state, status: 'submitting', error: null };
case 'ORDER_PLACED':
return {
...state,
status: 'success',
orderId: action.payload.orderId,
error: null,
};
case 'ORDER_FAILED':
return {
...state,
status: 'error',
error: action.payload,
};
case 'RESET':
return initialCheckoutState;
default:
return state;
}
}
Now the component:
function CheckoutFlow() {
const [state, dispatch] = useReducer(checkoutReducer, initialCheckoutState);
const handleApplyDiscount = async () => {
if (!state.discountCode.trim()) return;
dispatch({ type: 'DISCOUNT_VALIDATION_STARTED' });
try {
const result = await validateDiscount(state.discountCode);
dispatch({ type: 'DISCOUNT_VALIDATED', payload: { amount: result.amount } });
} catch {
dispatch({ type: 'DISCOUNT_REJECTED', payload: 'Invalid discount code' });
}
};
const handleSubmitOrder = async () => {
dispatch({ type: 'ORDER_SUBMISSION_STARTED' });
try {
const { orderId } = await placeOrder({
items: state.items,
discountCode: state.discountCode,
});
dispatch({ type: 'ORDER_PLACED', payload: { orderId } });
} catch (err) {
dispatch({
type: 'ORDER_FAILED',
payload: err instanceof Error ? err.message : 'Order failed',
});
}
};
// Each handler is now clean: one action, no coordination
// The coordination logic lives in the reducer
if (state.status === 'success') {
return <OrderConfirmation orderId={state.orderId!} />;
}
return (
<div>
<StepIndicator current={state.step} />
{state.step === 1 && (
<CartReview
items={state.items}
onNext={() => dispatch({ type: 'NEXT_STEP' })}
/>
)}
{state.step === 2 && (
<DiscountStep
discountCode={state.discountCode}
discountAmount={state.discountAmount}
isValidating={state.status === 'validating_discount'}
isApplied={state.status === 'discount_applied'}
error={state.error}
onCodeChange={(code) =>
dispatch({ type: 'DISCOUNT_CODE_CHANGED', payload: code })
}
onApply={handleApplyDiscount}
onNext={() => dispatch({ type: 'NEXT_STEP' })}
onBack={() => dispatch({ type: 'PREV_STEP' })}
/>
)}
{state.step === 3 && (
<OrderReview
state={state}
isSubmitting={state.status === 'submitting'}
error={state.error}
onSubmit={handleSubmitOrder}
onBack={() => dispatch({ type: 'PREV_STEP' })}
/>
)}
</div>
);
}
The component is clean. Every handler dispatches one action. The coordination logic lives in the reducer — where it's explicit, named, and testable without mounting anything.
This is the thing juniors miss. The reducer is a pure function. No React. No DOM. No mocks.
// checkout.reducer.test.ts
// Pure function tests — fast, simple, comprehensive
describe('checkoutReducer', () => {
describe('DISCOUNT_CODE_CHANGED', () => {
it('updates the discount code', () => {
const state = checkoutReducer(initialCheckoutState, {
type: 'DISCOUNT_CODE_CHANGED',
payload: 'SAVE20',
});
expect(state.discountCode).toBe('SAVE20');
});
it('clears previous discount amount when code changes', () => {
const stateWithDiscount: CheckoutState = {
...initialCheckoutState,
discountAmount: 20,
status: 'discount_applied',
};
const state = checkoutReducer(stateWithDiscount, {
type: 'DISCOUNT_CODE_CHANGED',
payload: 'NEWSAVE',
});
// The discount amount is cleared when the code changes
expect(state.discountAmount).toBe(0);
expect(state.status).toBe('browsing');
});
it('ignores changes while validation is in progress', () => {
const validatingState: CheckoutState = {
...initialCheckoutState,
status: 'validating_discount',
discountCode: 'SAVE20',
};
const state = checkoutReducer(validatingState, {
type: 'DISCOUNT_CODE_CHANGED',
payload: 'DIFFERENTCODE',
});
// Should not change — we're validating
expect(state.discountCode).toBe('SAVE20');
});
});
describe('NEXT_STEP', () => {
it('advances the step', () => {
const state = checkoutReducer(initialCheckoutState, { type: 'NEXT_STEP' });
expect(state.step).toBe(2);
});
it('does not advance while submitting', () => {
const submittingState: CheckoutState = {
...initialCheckoutState,
step: 2,
status: 'submitting',
};
const state = checkoutReducer(submittingState, { type: 'NEXT_STEP' });
expect(state.step).toBe(2); // Did not advance
});
it('does not go beyond step 3', () => {
const lastStepState: CheckoutState = {
...initialCheckoutState,
step: 3,
};
const state = checkoutReducer(lastStepState, { type: 'NEXT_STEP' });
expect(state.step).toBe(3); // Stayed at 3
});
});
});
When a junior says "this component is hard to test" — it usually means the logic is inside the component, entangled with the rendering. When a senior says "I made this testable" — it usually means the logic is in a pure function like a reducer, separated from the rendering entirely.
That's the real value of useReducer. Not less code. Testable logic.
useRef — The Three Different Problems It Solves
Here's the confusion with useRef: it solves three distinct problems, and they look like they have nothing to do with each other. Developers who only know one use case are regularly confused by the other two.
The diagnostic question: Which of these three problems do I have?
- I need to access a DOM element directly
- I need to store a value that persists across renders but should NOT trigger a re-render
- I need a stable reference to a value that changes over time, accessible inside a callback
Three different problems. One hook. Let me show each one clearly.
Problem 1: DOM Access
// The most common use — direct DOM interaction
// You need this when React's declarative model can't express
// what you need to do (focus, play/pause, measure, animate)
function SearchInput({ onSearch }: { onSearch: (query: string) => void }) {
const inputRef = useRef<HTMLInputElement>(null);
const [query, setQuery] = useState('');
// Focus the input when the component mounts
useEffect(() => {
// inputRef.current is null on the first render
// By the time this effect runs, the DOM is ready and current is set
inputRef.current?.focus();
}, []);
// Also focus when user presses Cmd/Ctrl + K anywhere on the page
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
inputRef.current?.focus();
inputRef.current?.select(); // Select existing text
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, []);
return (
<input
ref={inputRef}
type="search"
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') onSearch(query);
}}
placeholder="Search... (⌘K)"
/>
);
}
Problem 2: Mutable Value Without Re-render
// Values that React doesn't need to know about
// Storing them in state would cause unnecessary re-renders
// Storing them in a regular variable would reset on every render
function useStopwatch() {
const [displayTime, setDisplayTime] = useState(0);
const [isRunning, setIsRunning] = useState(false);
// The interval ID needs to persist across renders
// But changing it shouldn't trigger a re-render — it's not UI state
// A regular variable would reset to null on every render
// useState would trigger a re-render every time we set it
// useRef is the correct tool
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
// The start time also shouldn't trigger re-renders
const startTimeRef = useRef<number>(0);
const elapsedRef = useRef<number>(0);
const start = () => {
if (isRunning) return;
startTimeRef.current = Date.now() - elapsedRef.current;
intervalRef.current = setInterval(() => {
setDisplayTime(Date.now() - startTimeRef.current);
}, 10);
setIsRunning(true);
};
const pause = () => {
if (!isRunning) return;
clearInterval(intervalRef.current!);
elapsedRef.current = Date.now() - startTimeRef.current;
setIsRunning(false);
};
const reset = () => {
clearInterval(intervalRef.current!);
intervalRef.current = null;
elapsedRef.current = 0;
startTimeRef.current = 0;
setDisplayTime(0);
setIsRunning(false);
};
// Cleanup on unmount
useEffect(() => {
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, []);
return { displayTime, isRunning, start, pause, reset };
}
Problem 3: Stable Reference to a Changing Value
This is the one that trips up even experienced developers. It's the stale closure problem and useRef is one of the solutions.
// The problem: a callback captures a stale value from the closure
function useDebounce<T extends (...args: unknown[]) => void>(
callback: T,
delay: number
): T {
// 🚩 Naive implementation — has a stale closure bug
// Every time callback changes (e.g., because it captures state),
// the debounced function is recreated — defeating the purpose of debouncing
const debouncedFn = useCallback(
debounce(callback, delay),
[callback, delay] // callback changing recreates the debounced function
);
return debouncedFn as T;
}
// ✅ Correct implementation using useRef
function useDebounce<T extends (...args: unknown[]) => void>(
callback: T,
delay: number
): T {
// Store the latest callback in a ref
// The ref is always up to date — no stale closures
const callbackRef = useRef(callback);
// Keep the ref current whenever callback changes
// This runs after every render where callback changed
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
// The debounced function is created ONCE (empty dep array)
// It always calls the latest callback via the ref
// So the debounce timer is stable AND the callback is always current
const debouncedFn = useCallback(
debounce((...args: Parameters<T>) => {
callbackRef.current(...args);
}, delay),
[delay] // Only recreate if delay changes
);
return debouncedFn as T;
}
// Another stale closure example: event listener with state
function useKeyboardShortcut(
key: string,
onTrigger: () => void
) {
// onTrigger might capture state from the component.
// If we add it to the useEffect dependency array,
// the event listener is removed and re-added on every render — inefficient.
// If we don't add it, we have a stale closure.
// useRef solves this: the listener is added once,
// but always calls the latest version of onTrigger
const onTriggerRef = useRef(onTrigger);
useEffect(() => {
onTriggerRef.current = onTrigger;
}, [onTrigger]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === key) {
onTriggerRef.current(); // Always calls the latest version
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [key]); // Only re-subscribe if the key changes
}
The Pattern That Combines Both
Here's something I haven't seen documented anywhere but use regularly in production: combining useReducer and useRef in a custom hook to handle async operations cleanly.
The problem: when an async operation completes, the component might have unmounted or the state might have changed. The reducer handles state transitions. The ref handles the "am I still mounted?" check.
// useAsyncReducer.ts
// A pattern for reducers that need to handle async operations
// without race conditions or updates on unmounted components
interface AsyncState<T> {
data: T | null;
status: 'idle' | 'loading' | 'success' | 'error';
error: string | null;
}
type AsyncAction<T> =
| { type: 'FETCH_STARTED' }
| { type: 'FETCH_SUCCESS'; payload: T }
| { type: 'FETCH_ERROR'; payload: string }
| { type: 'RESET' };
function asyncReducer<T>(
state: AsyncState<T>,
action: AsyncAction<T>
): AsyncState<T> {
switch (action.type) {
case 'FETCH_STARTED':
return { ...state, status: 'loading', error: null };
case 'FETCH_SUCCESS':
return { status: 'success', data: action.payload, error: null };
case 'FETCH_ERROR':
return { ...state, status: 'error', error: action.payload };
case 'RESET':
return { data: null, status: 'idle', error: null };
default:
return state;
}
}
function useAsyncData<T>(
fetchFn: () => Promise<T>,
deps: React.DependencyList
) {
const [state, dispatch] = useReducer(asyncReducer<T>, {
data: null,
status: 'idle',
error: null,
});
// Track whether the component is still mounted
// If it unmounts before the fetch completes, don't dispatch
// (dispatching to an unmounted component causes a React warning
// and can cause memory leaks)
const isMountedRef = useRef(true);
useEffect(() => {
isMountedRef.current = true;
return () => {
// Set to false when the component unmounts
isMountedRef.current = false;
};
}, []);
useEffect(() => {
dispatch({ type: 'FETCH_STARTED' });
fetchFn()
.then(data => {
// Only dispatch if still mounted
if (isMountedRef.current) {
dispatch({ type: 'FETCH_SUCCESS', payload: data });
}
})
.catch(err => {
if (isMountedRef.current) {
dispatch({
type: 'FETCH_ERROR',
payload: err instanceof Error ? err.message : 'Unknown error',
});
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
const reset = () => dispatch({ type: 'RESET' });
return { ...state, reset };
}
// Usage: clean, safe, handles unmount correctly
function UserProfile({ userId }: { userId: string }) {
const { data: user, status, error } = useAsyncData(
() => fetchUser(userId),
[userId]
);
if (status === 'loading') return <Skeleton />;
if (status === 'error') return <ErrorMessage message={error!} />;
if (!user) return null;
return <ProfileCard user={user} />;
}
The useReducer handles the state machine. The useRef handles the imperative concern (is the component still alive?). Together, they make a pattern that is both declarative and safe.
Here's what I want to say that most "advanced hooks" articles skip:
The reason junior developers don't reach for useReducer isn't ignorance. It's confidence.
Writing useState feels safe. The feedback loop is fast. It works. Refactoring to useReducer requires committing to a mental model — state machines, explicit transitions, action names — before you're sure the component will need it.
The senior developer's advantage isn't technical knowledge. It's the willingness to invest in structure before the complexity demands it, because they've seen what happens when you don't. They've debugged the ten-useState component at 11 PM before a launch. They've spent two hours tracing which handler set which flag. They know the cost.
And `useRef "the reason juniors misuse it is simpler: they don't know which of the three problems they have." They reach for it because they've seen it used, not because they've diagnosed their specific situation.
The upgrade isn't learning more syntax. It's learning to ask, "What problem do I actually have?" before picking a tool.
✨ Let's keep the conversation going!
If you found this interesting, I'd love for you to check out more of my work or just drop in to say hello.
✍️ Read more on my blog: bishoy-bishai.github.io
☕ Let's chat on LinkedIn: linkedin.com/in/bishoybishai
📘 Curious about AI?: You can also check out my book: Surrounded by AI
Top comments (0)