DEV Community

Cover image for The Two Hooks That Separate Junior from Senior React Developers
Bishoy Bishai
Bishoy Bishai

Posted on

The Two Hooks That Separate Junior from Senior React Developers

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.
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

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?

  1. I need to access a DOM element directly
  2. I need to store a value that persists across renders but should NOT trigger a re-render
  3. 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)"
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode
// 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
}
Enter fullscreen mode Exit fullscreen mode

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} />;
}
Enter fullscreen mode Exit fullscreen mode

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)