Doing React the Right Way - 5/10

Advanced Hooks: Powerful Tools for Complex Scenarios

You’ve mastered useState and useEffect. You can build interactive components, fetch data, and manage side effects. As your applications grow in complexity, you’ll encounter scenarios where these fundamental hooks need reinforcement from more specialized tools.

Today, we’re exploring React’s advanced hooks: useReducer for complex state logic, useRef for DOM access and persistent values, useLayoutEffect for synchronous DOM operations, and useImperativeHandle for component APIs. These aren’t everyday tools—they’re precision instruments for specific problems that arise in sophisticated applications.

Understanding when and how to use these hooks is what separates developers who know React from those who architect maintainable, scalable applications.

useReducer: Managing Complex State Transitions

As your state management becomes more complex, you’ll find yourself managing multiple related pieces of state that need to be updated together. Consider a data-fetching component that needs to track loading status, data, and error states:

// Using multiple useState hooks - functional but fragile
const [isLoading, setIsLoading] = useState<boolean>(true);
const [data, setData] = useState<User | null>(null);
const [error, setError] = useState<Error | null>(null);

// In your fetch logic, you need to remember to update all three:
setIsLoading(true);
setError(null); // Easy to forget this step
try {
  const result = await fetch(url);
  const jsonData = await result.json();
  setData(jsonData);
  setIsLoading(false);
} catch (e) {
  setError(e as Error);
  setIsLoading(false); // Easy to forget this step too
}

This approach is error-prone. It’s easy to forget to update one of the state variables, leaving your UI in an inconsistent state—showing data and a loading spinner simultaneously, for example.

useReducer solves this by centralizing state logic into a single function that manages all related state transitions atomically. Instead of calling multiple setState functions, you dispatch actions that describe what happened, and a reducer function determines how the state should change.

Building a Robust State Machine

Let’s refactor the data-fetching example using useReducer:

import { useReducer, useEffect } from 'react';

// 1. Define a comprehensive state shape
interface DataFetchingState<T> {
  status: 'idle' | 'loading' | 'success' | 'error';
  data: T | null;
  error: Error | null;
}

// 2. Define actions as a discriminated union for type safety
type DataFetchingAction<T> =
  | { type: 'FETCH_INIT' }
  | { type: 'FETCH_SUCCESS'; payload: T }
  | { type: 'FETCH_FAILURE'; payload: Error };

// 3. The reducer: a pure function that handles all state transitions
const dataFetchingReducer = <T>(
  state: DataFetchingState<T>,
  action: DataFetchingAction<T>
): DataFetchingState<T> => {
  switch (action.type) {
    case 'FETCH_INIT':
      return {
        ...state,
        status: 'loading',
        error: null, // Always clear errors when starting new request
      };
    case 'FETCH_SUCCESS':
      return {
        ...state,
        status: 'success',
        data: action.payload,
        error: null, // Clear any previous errors
      };
    case 'FETCH_FAILURE':
      return {
        ...state,
        status: 'error',
        error: action.payload,
        data: null, // Clear stale data on error
      };
    default:
      throw new Error(`Unhandled action type: ${(action as any).type}`);
  }
};

// 4. Using the reducer in a component
interface User {
  id: number;
  name: string;
}

function UserFetcher({ userId }: { userId: number }) {
  const initialState: DataFetchingState<User> = {
    status: 'idle',
    data: null,
    error: null,
  };

  const [state, dispatch] = useReducer(dataFetchingReducer, initialState);

  useEffect(() => {
    const fetchUser = async () => {
      dispatch({ type: 'FETCH_INIT' });

      try {
        const response = await fetch(`https://api.example.com/users/${userId}`);
        if (!response.ok) {
          throw new Error(`HTTP ${response.status}: ${response.statusText}`);
        }
        const userData: User = await response.json();
        dispatch({ type: 'FETCH_SUCCESS', payload: userData });
      } catch (error) {
        dispatch({ type: 'FETCH_FAILURE', payload: error as Error });
      }
    };

    fetchUser();
  }, [userId]);

  // 5. Render based on the predictable state
  const { status, data, error } = state;

  if (status === 'loading' || status === 'idle') {
    return <p>Loading user...</p>;
  }

  if (status === 'error') {
    return <p>Error: {error?.message}</p>;
  }

  return <div>Welcome, {data?.name}!</div>;
}

Benefits of the Reducer Pattern

This approach provides several advantages:

Atomic Updates: All related state changes happen together in a single reducer call, eliminating inconsistent intermediate states.

Predictable Logic: The reducer is a pure function that’s easy to test and reason about. Given the same state and action, it always produces the same result.

Explicit Intent: Actions clearly communicate what happened in your application (FETCH_INIT, FETCH_SUCCESS) rather than how the state should change.

Scalability: As your state logic grows, you can add new action types without modifying existing logic.

Use useReducer when you have complex state logic involving multiple sub-values or when the next state depends on the previous one in non-trivial ways.

useRef: Escaping React’s Declarative World

React’s declarative nature is powerful, but sometimes you need to interact with imperative APIs—focusing an input, scrolling to an element, or integrating with third-party libraries. useRef provides two distinct capabilities for these scenarios.

Accessing DOM Elements

The most common use of useRef is getting direct access to DOM elements. A ref is like a persistent container that React automatically populates with the actual DOM node after rendering:

import { useRef, useEffect } from "react";

function VideoPlayer({ src }: { src: string }) {
  // Create a ref to hold the video element
  const videoRef = useRef<HTMLVideoElement>(null);

  useEffect(() => {
    // The ref is guaranteed to be populated after the initial render
    const videoElement = videoRef.current;
    if (videoElement) {
      // Set up event listeners, configure the element, etc.
      videoElement.addEventListener("ended", () => {
        console.log("Video finished playing");
      });
    }

    return () => {
      // Cleanup event listeners
      if (videoElement) {
        videoElement.removeEventListener("ended", () => {});
      }
    };
  }, []);

  const handlePlay = () => {
    videoRef.current?.play();
  };

  const handlePause = () => {
    videoRef.current?.pause();
  };

  return (
    <div>
      <video ref={videoRef} src={src} controls width="400" />
      <div>
        <button onClick={handlePlay}>Play</button>
        <button onClick={handlePause}>Pause</button>
      </div>
    </div>
  );
}

Storing Mutable Values

The second, more subtle use of useRef is storing values that need to persist across renders but don’t trigger re-renders when changed. Think of it as instance variables for functional components:

import { useEffect, useRef, useState } from "react";

function ValueChangeTracker({ value }: { value: number }) {
  const previousValueRef = useRef<number>();
  const [changeCount, setChangeCount] = useState(0);

  useEffect(() => {
    // Check if value changed since last render
    if (
      previousValueRef.current !== undefined &&
      previousValueRef.current !== value
    ) {
      setChangeCount((prev) => prev + 1);
    }

    // Store current value for next render
    previousValueRef.current = value;
  }, [value]);

  const previousValue = previousValueRef.current;

  return (
    <div>
      <p>Current: {value}</p>
      <p>Previous: {previousValue ?? "N/A"}</p>
      <p>Changes: {changeCount}</p>
    </div>
  );
}

The key insight: changing previousValueRef.current doesn’t trigger a re-render, making it perfect for storing data that should persist but shouldn’t affect the render cycle.

Advanced Ref Pattern: Storing Timeouts and Intervals

A common pattern is using refs to store timer IDs that need to be cleared on cleanup:

function DebounceInput({
  onDebouncedChange,
  delay = 500,
}: {
  onDebouncedChange: (value: string) => void;
  delay?: number;
}) {
  const [inputValue, setInputValue] = useState("");
  const timeoutRef = useRef<NodeJS.Timeout>();

  const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const newValue = e.target.value;
    setInputValue(newValue);

    // Clear existing timeout
    if (timeoutRef.current) {
      clearTimeout(timeoutRef.current);
    }

    // Set new timeout
    timeoutRef.current = setTimeout(() => {
      onDebouncedChange(newValue);
    }, delay);
  };

  // Cleanup on unmount
  useEffect(() => {
    return () => {
      if (timeoutRef.current) {
        clearTimeout(timeoutRef.current);
      }
    };
  }, []);

  return (
    <input
      type="text"
      value={inputValue}
      onChange={handleInputChange}
      placeholder="Type to search..."
    />
  );
}

useLayoutEffect: The Synchronous Alternative

Most of the time, useEffect is exactly what you want—it runs asynchronously after the browser has painted the screen, keeping your app responsive. But occasionally, you need to measure or modify the DOM before the user sees anything.

useLayoutEffect has the same signature as useEffect, but it runs synchronously after all DOM mutations but before the browser paints. This makes it perfect for measurements and DOM manipulations that would cause visual flicker if done in useEffect:

import { useLayoutEffect, useRef, useState } from "react";

function Tooltip({
  children,
  tooltip,
}: {
  children: React.ReactNode;
  tooltip: string;
}) {
  const [isVisible, setIsVisible] = useState(false);
  const [position, setPosition] = useState({ top: 0, left: 0 });
  const triggerRef = useRef<HTMLDivElement>(null);
  const tooltipRef = useRef<HTMLDivElement>(null);

  useLayoutEffect(() => {
    if (isVisible && triggerRef.current && tooltipRef.current) {
      // Measure the trigger element
      const triggerRect = triggerRef.current.getBoundingClientRect();
      const tooltipRect = tooltipRef.current.getBoundingClientRect();

      // Calculate position (above the trigger, centered)
      const newPosition = {
        top: triggerRect.top - tooltipRect.height - 10,
        left: triggerRect.left + (triggerRect.width - tooltipRect.width) / 2,
      };

      setPosition(newPosition);
    }
  }, [isVisible]); // Run when visibility changes

  return (
    <>
      <div
        ref={triggerRef}
        onMouseEnter={() => setIsVisible(true)}
        onMouseLeave={() => setIsVisible(false)}
        className="tooltip-trigger"
      >
        {children}
      </div>
      {isVisible && (
        <div
          ref={tooltipRef}
          className="tooltip"
          style={{
            position: "fixed",
            top: position.top,
            left: position.left,
            zIndex: 1000,
          }}
        >
          {tooltip}
        </div>
      )}
    </>
  );
}

Important: Only use useLayoutEffect when you’re measuring or modifying the DOM and need to prevent visual flicker. It blocks the browser’s paint cycle, so overusing it can hurt performance.

useImperativeHandle: Creating Component APIs

This is the most specialized hook, used in less than 1% of React components. It’s for exposing imperative methods from a child component to its parent through a ref. You’ll typically use it when building reusable component libraries or integrating with imperative third-party systems.

import { useImperativeHandle, useRef, forwardRef } from "react";

// 1. Define the API your component will expose
export interface FormHandle {
  submit: () => void;
  reset: () => void;
  focus: () => void;
}

interface FormProps {
  onSubmit: (data: { email: string; name: string }) => void;
}

// 2. Use forwardRef to receive the ref from parent
const CustomForm = forwardRef<FormHandle, FormProps>(({ onSubmit }, ref) => {
  const emailRef = useRef<HTMLInputElement>(null);
  const nameRef = useRef<HTMLInputElement>(null);

  // 3. Define the imperative API
  useImperativeHandle(ref, () => ({
    submit: () => {
      if (emailRef.current && nameRef.current) {
        onSubmit({
          email: emailRef.current.value,
          name: nameRef.current.value,
        });
      }
    },
    reset: () => {
      if (emailRef.current) emailRef.current.value = "";
      if (nameRef.current) nameRef.current.value = "";
    },
    focus: () => {
      emailRef.current?.focus();
    },
  }));

  return (
    <form>
      <input ref={emailRef} type="email" placeholder="Email" required />
      <input ref={nameRef} type="text" placeholder="Name" required />
    </form>
  );
});

// 4. Parent component using the imperative API
function FormPage() {
  const formRef = useRef<FormHandle>(null);

  return (
    <div>
      <CustomForm
        ref={formRef}
        onSubmit={(data) => console.log("Form data:", data)}
      />
      <button onClick={() => formRef.current?.submit()}>
        Submit From Outside
      </button>
      <button onClick={() => formRef.current?.reset()}>Reset Form</button>
    </div>
  );
}

Before reaching for useImperativeHandle, ask yourself: “Can I solve this declaratively with props?” In most cases, the answer is yes. For example, instead of exposing a submit() method, the parent could pass an isSubmitting prop that triggers submission. Reserve this hook for scenarios where you truly need imperative control, typically when integrating with non-React systems.

Choosing the Right Tool

These advanced hooks solve specific problems:

  • useReducer: Complex state logic with multiple related values or state transitions
  • useRef: DOM access, storing mutable values that don’t trigger re-renders
  • useLayoutEffect: DOM measurements or modifications that must happen before paint
  • useImperativeHandle: Exposing imperative APIs from components (rare)

The key is recognizing when your problem matches these patterns. Most React development uses useState and useEffect. These advanced hooks are surgical instruments—powerful when you need them, but unnecessary complexity when you don’t.

Understanding these tools gives you the confidence to handle edge cases and complex requirements that arise in production applications. You now have the complete toolkit for building sophisticated React applications that scale beyond simple demos to real-world complexity.

In our next article, we’ll tackle one of the most common problems in large React applications: prop drilling and how useContext provides an elegant solution for sharing state across component trees.