DEV Community

Cover image for Understanding useReducer and useRef in React
Bishoy Bishai
Bishoy Bishai

Posted on • Originally published at bishoy-bishai.github.io

Understanding useReducer and useRef in React

Mastering State and References: A Deep Dive into useReducer and useRef for Professional React Developers

We've all been there: a React component starts simple, maybe a few useState calls, a couple of props. Then, the feature requests roll in. Suddenly, your useState calls are multiplying, updates depend on previous values, and you're dispatching multiple setters in a single handler just to keep the UI in sync. Chasing down a bug in a component with ten useState variables is, in my experience, a special kind of debugging hell.

This isn't just about avoiding "prop drilling" or finding a global state solution; it's about managing component-level complexity gracefully. This is where useReducer and useRef step onto the stage, offering powerful, often under-utilized, solutions for common professional challenges. They are not just advanced hooks; they are essential tools in a seasoned developer's arsenal for building resilient, high-performance applications.

Escaping useState Sprawl with useReducer

Think of useReducer as bringing a mini-Redux pattern right into your component. While useState is fantastic for simple, isolated state values, useReducer truly shines when your component's state is more complex:

  • It consists of multiple sub-values.
  • Updates to one sub-value depend on others.
  • The update logic is intricate, perhaps involving multiple steps.

The beauty of useReducer lies in centralizing your state update logic into a single reducer function. This makes your component leaner, your state transitions explicit, and your code much easier to reason about and test.

How it Works: The Mental Model

useReducer takes two (or three) arguments: a reducer function, and an initialState. It returns the current state and a dispatch function, just like useState returns state and setState.

const [state, dispatch] = useReducer(reducer, initialState, initFunction?);
Enter fullscreen mode Exit fullscreen mode

The reducer function is pure: (state, action) => newState.

  • state: The current state of your component.
  • action: An object describing what happened. By convention, actions have a type property and an optional payload.
  • newState: The new state after the action is applied.

A Practical Example: Managing a Complex Form

Let's imagine a multi-step user registration form where the state includes user details, validation flags, and submission status.

// types.ts
interface UserFormState {
  firstName: string;
  lastName: string;
  email: string;
  agreedToTerms: boolean;
  isValid: boolean;
  isSubmitting: boolean;
  error: string | null;
}

type UserFormAction =
  | { type: 'CHANGE_FIELD'; field: keyof Omit<UserFormState, 'isValid' | 'isSubmitting' | 'error'>; value: string | boolean }
  | { type: 'VALIDATE_FORM' }
  | { type: 'SUBMIT_START' }
  | { type: 'SUBMIT_SUCCESS' }
  | { type: 'SUBMIT_ERROR'; message: string };

// reducer.ts
const userFormReducer = (state: UserFormState, action: UserFormAction): UserFormState => {
  switch (action.type) {
    case 'CHANGE_FIELD':
      const newState = { ...state, [action.field]: action.value };
      // Recalculate validity if needed, or trigger a separate VALIDATE_FORM action
      return newState;
    case 'VALIDATE_FORM':
      const { firstName, lastName, email, agreedToTerms } = state;
      const isValid = firstName.trim().length > 0 && lastName.trim().length > 0 && email.includes('@') && agreedToTerms;
      return { ...state, isValid };
    case 'SUBMIT_START':
      return { ...state, isSubmitting: true, error: null };
    case 'SUBMIT_SUCCESS':
      return { ...state, isSubmitting: false, error: null };
    case 'SUBMIT_ERROR':
      return { ...state, isSubmitting: false, error: action.message };
    default:
      return state;
  }
};

const initialFormState: UserFormState = {
  firstName: '',
  lastName: '',
  email: '',
  agreedToTerms: false,
  isValid: false,
  isSubmitting: false,
  error: null,
};

// UserRegistrationForm.tsx
import React, { useReducer, useEffect } from 'react';
// ... import types and reducer

const UserRegistrationForm: React.FC = () => {
  const [state, dispatch] = useReducer(userFormReducer, initialFormState);

  useEffect(() => {
    // Re-validate whenever relevant fields change
    dispatch({ type: 'VALIDATE_FORM' });
  }, [state.firstName, state.lastName, state.email, state.agreedToTerms]); // eslint-disable-line react-hooks/exhaustive-deps

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!state.isValid) {
      dispatch({ type: 'SUBMIT_ERROR', message: 'Please fill out all required fields and agree to terms.' });
      return;
    }

    dispatch({ type: 'SUBMIT_START' });
    try {
      // Simulate API call
      await new Promise(resolve => setTimeout(resolve, 1500));
      console.log('Submitting data:', { firstName: state.firstName, lastName: state.lastName, email: state.email });
      dispatch({ type: 'SUBMIT_SUCCESS' });
      alert('Registration successful!');
      // Potentially reset form here by dispatching a 'RESET_FORM' action
    } catch (err: any) {
      dispatch({ type: 'SUBMIT_ERROR', message: err.message || 'Submission failed.' });
    }
  };

  return (
    <form onSubmit={handleSubmit} className="p-4 bg-gray-800 text-white rounded-lg max-w-md mx-auto">
      <h2 className="text-2xl font-bold mb-4 text-gold-500">Register</h2>
      {state.error && <p className="text-red-500 mb-2">{state.error}</p>}

      <div className="mb-3">
        <label htmlFor="firstName" className="block text-sm font-medium mb-1">First Name:</label>
        <input
          type="text"
          id="firstName"
          className="w-full p-2 bg-gray-700 border border-gray-600 rounded"
          value={state.firstName}
          onChange={(e) => dispatch({ type: 'CHANGE_FIELD', field: 'firstName', value: e.target.value })}
        />
      </div>
      {/* ... similar inputs for lastName, email */}
      <div className="mb-3">
        <label className="flex items-center">
          <input
            type="checkbox"
            className="form-checkbox"
            checked={state.agreedToTerms}
            onChange={(e) => dispatch({ type: 'CHANGE_FIELD', field: 'agreedToTerms', value: e.target.checked })}
          />
          <span className="ml-2 text-sm">I agree to the terms and conditions</span>
        </label>
      </div>

      <button
        type="submit"
        className={`w-full p-3 rounded font-bold transition-colors ${state.isSubmitting || !state.isValid ? 'bg-gray-600 cursor-not-allowed' : 'bg-gold-500 hover:bg-gold-600'}`}
        disabled={state.isSubmitting || !state.isValid}
      >
        {state.isSubmitting ? 'Submitting...' : 'Register'}
      </button>
    </form>
  );
};
Enter fullscreen mode Exit fullscreen mode

This example clearly separates "what happened" (actions) from "how state changes" (reducer). It's incredibly powerful for managing complex UI logic and side effects.

When to Prefer useReducer (and When Not To)

Use useReducer when:

  • State logic is complex and involves multiple sub-values.
  • The next state depends on the previous state in intricate ways.
  • You need to centralize state update logic for better testability.
  • You're passing a dispatch function down to deeply nested components – it's guaranteed to be stable and won't cause unnecessary re-renders.
  • You're using it with useContext for a performant, lightweight global state solution.

Stick to useState when:

  • State is a simple primitive (boolean, number, string).
  • Updates are straightforward and don't depend on other state values.

Taming the DOM and Mutable Values with useRef

While useReducer helps manage internal component state, useRef solves a different, equally critical set of problems: interacting directly with the DOM, storing mutable values that don't trigger re-renders, and persisting values across renders without them being part of the reactive state system.

Here's the thing: React is declarative. We describe what the UI should look like, and React handles the "how." But sometimes, we need to break out of that paradigm and perform imperative actions. That's where useRef comes in.

How it Works: The Mutable Box

useRef returns a mutable ref object whose .current property is initialized to the argument passed (initialValue). The returned object will persist for the full lifetime of the component. Crucially, changing the .current property does not trigger a re-render.

const refContainer = useRef(initialValue);
Enter fullscreen mode Exit fullscreen mode

Primary Use Cases:

  1. Accessing DOM Elements Directly: This is the most common use. Need to focus an input, play a video, or measure an element's dimensions?

    const inputRef = useRef<HTMLInputElement>(null);
    
    useEffect(() => {
      if (inputRef.current) {
        inputRef.current.focus();
      }
    }, []);
    
    return <input ref={inputRef} type="text" />;
    
  2. Storing Mutable Values That Don't Trigger Re-renders: Perfect for things like timer IDs, WebSocket instances, or even a previous value that you want to compare against in a useEffect without adding it to the dependency array (which would make it unstable).

    const timerIdRef = useRef<number | null>(null);
    
    const startTimer = () => {
      timerIdRef.current = window.setInterval(() => {
        console.log('Timer ticking...');
      }, 1000);
    };
    
    const stopTimer = () => {
      if (timerIdRef.current) {
        clearInterval(timerIdRef.current);
        timerIdRef.current = null;
      }
    };
    
    useEffect(() => {
      startTimer();
      return () => stopTimer(); // Cleanup on unmount
    }, []);
    

    In this case, timerIdRef.current can be mutated without causing the TimerComponent to re-render, which is exactly what we want.

  3. Holding a Reference to a Function: While useCallback is generally preferred for memoizing functions, useRef can be used to store a function if you absolutely need a stable reference and you don't want it to cause re-renders if the function itself changes. This is less common and often a sign that useCallback or a dispatch from useReducer might be better.

Pitfalls and Best Practices with useRef

  • Don't Overuse for State: If changing a value should trigger a re-render, it's state (useState or useReducer), not a ref. useRef is for values that are incidental to rendering or for direct imperative actions.
  • The .current Property: Always remember to access ref.current. Without it, you're interacting with the ref object itself, not the value it holds.
  • Initialization: For DOM refs, initialize with null and handle the potential null value in your useEffect or event handlers.
  • Mutating in Render Phase: Avoid writing to .current during the render phase (directly in the component body) unless you're initializing it. It can lead to unpredictable behavior, as React might not always guarantee when components render or how many times. Stick to useEffect or event handlers for mutations.

Beyond the Basics: Lessons Learned

In my experience, truly mastering these hooks transforms your approach to building React applications:

  • useReducer as a Local State Machine: It encourages thinking about state transitions more declaratively. Actions define the what, and the reducer defines the how. This structure is incredibly powerful for complex features. I've found it makes feature additions much smoother because you can often extend the reducer without touching the component's render logic.
  • Context API + useReducer for Lightweight Global State: For many applications, this combination offers a highly performant and understandable alternative to larger state management libraries. The dispatch function from useReducer is stable, so you can pass it down via context without causing unnecessary re-renders in consumers.
  • useRef for Performance and Escapes: When you need to interact with third-party libraries, media elements, or manage timers and subscriptions, useRef is your escape hatch to the imperative world. It allows you to optimize performance by holding values that don't need to be part of React's render cycle, preventing needless re-renders.

Wrapping Up

useReducer and useRef are not just alternative hooks; they are powerful, distinct tools designed to solve specific problems in React development. useReducer brings structure and predictability to complex state management, making your components more robust and testable. useRef provides a safe and idiomatic way to interact with the DOM imperatively and to persist mutable values across renders without triggering unnecessary UI updates.

By deeply understanding when and how to leverage these hooks, you elevate your React applications from merely functional to truly professional-grade: clearer, more performant, and significantly easier to maintain. Challenge yourself to reach for them when useState feels insufficient – you'll be amazed at the clarity they bring.


✨ 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


Top comments (0)