References

Advanced Hook Patterns

Deep dive into useState vs useReducer, custom hooks, and hook composition

Core Patterns

  • When to Read This
  • useState vs useReducer
  • Custom Hooks
  • useRef Patterns

When to Read This

  • Deciding between useState and useReducer for complex state
  • Creating reusable custom hooks
  • Managing hook dependencies and closures
  • Working with refs for mutable values
  • Need stable references across renders

useState vs useReducer

Decision Matrix

ScenarioUseReason
Single primitive valueuseStateSimple, direct
2-3 related values that update togetheruseState with objectEasier to read
4+ related values with complex updatesuseReducerCentralized logic
State depends on previous stateuseReducerPrevents stale closures
Multiple state update functionsuseReducerPredictable transitions
Need to test state logic separatelyuseReducerTestable reducer

useState Patterns

✅ Simple State

const [count, setCount] = useState(0);
const [user, setUser] = useState<User | null>(null);
const [isOpen, setIsOpen] = useState(false);

✅ Functional Updates (Prevent Stale Closures)

// ❌ WRONG: Stale closure in setTimeout
const [count, setCount] = useState(0);
setTimeout(() => {
  setCount(count + 1); // Uses stale count
}, 1000);

// ✅ CORRECT: Functional update
setTimeout(() => {
  setCount((prev) => prev + 1); // Always current
}, 1000);

✅ Lazy Initialization

// ❌ WRONG: Expensive computation on every render
const [data, setData] = useState(expensiveComputation());

// ✅ CORRECT: Lazy initializer (runs once)
const [data, setData] = useState(() => expensiveComputation());

✅ Object State with Spreading

const [form, setForm] = useState({ name: "", email: "" });

// Update single field
setForm((prev) => ({ ...prev, name: "John" }));

// Update multiple fields
setForm((prev) => ({ ...prev, name: "John", email: "john@example.com" }));

useReducer Patterns

✅ Complex State Logic

interface State {
  user: User | null;
  loading: boolean;
  error: string | null;
}

type Action =
  | { type: "FETCH_START" }
  | { type: "FETCH_SUCCESS"; payload: User }
  | { type: "FETCH_ERROR"; payload: string }
  | { type: "LOGOUT" };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case "FETCH_START":
      return { ...state, loading: true, error: null };
    case "FETCH_SUCCESS":
      return { user: action.payload, loading: false, error: null };
    case "FETCH_ERROR":
      return { ...state, loading: false, error: action.payload };
    case "LOGOUT":
      return { user: null, loading: false, error: null };
    default:
      return state;
  }
}

const [state, dispatch] = useReducer(reducer, {
  user: null,
  loading: false,
  error: null,
});

dispatch({ type: "FETCH_START" });
dispatch({ type: "FETCH_SUCCESS", payload: userData });

✅ Reducer with Immer (Immutable Updates)

import { useReducer } from "react";
import { produce } from "immer";

const reducer = produce((draft: State, action: Action) => {
  switch (action.type) {
    case "ADD_TODO":
      draft.todos.push(action.payload);
      break;
    case "TOGGLE_TODO":
      const todo = draft.todos.find((t) => t.id === action.payload);
      if (todo) todo.completed = !todo.completed;
      break;
  }
});

Custom Hooks

✅ Extract Reusable Logic

function useLocalStorage<T>(key: string, initialValue: T) {
  const [value, setValue] = useState<T>(() => {
    const stored = localStorage.getItem(key);
    return stored ? JSON.parse(stored) : initialValue;
  });

  useEffect(() => {
    localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);

  return [value, setValue] as const;
}

// Usage
const [theme, setTheme] = useLocalStorage("theme", "dark");

✅ Custom Hook with Cleanup

function useInterval(callback: () => void, delay: number | null) {
  const savedCallback = useRef(callback);

  // Update ref when callback changes
  useEffect(() => {
    savedCallback.current = callback;
  }, [callback]);

  useEffect(() => {
    if (delay === null) return;

    const id = setInterval(() => savedCallback.current(), delay);
    return () => clearInterval(id);
  }, [delay]);
}

useInterval(() => {
  console.log("Tick");
}, 1000);

✅ Custom Hook with Multiple Values

function useToggle(initialValue = false) {
  const [value, setValue] = useState(initialValue);

  const toggle = useCallback(() => setValue((v) => !v), []);
  const setTrue = useCallback(() => setValue(true), []);
  const setFalse = useCallback(() => setValue(false), []);

  return { value, toggle, setTrue, setFalse };
}

const modal = useToggle();
modal.setTrue(); // Open
modal.toggle(); // Close

✅ Custom Hook Composition

function useAuth() {
  const [user, setUser] = useLocalStorage<User | null>("user", null);
  const [loading, setLoading] = useState(false);

  const login = useCallback(
    async (credentials: Credentials) => {
      setLoading(true);
      try {
        const user = await api.login(credentials);
        setUser(user);
      } finally {
        setLoading(false);
      }
    },
    [setUser],
  );

  const logout = useCallback(() => {
    setUser(null);
  }, [setUser]);

  return { user, loading, login, logout };
}

useRef Patterns

✅ DOM References

function TextInput() {
  const inputRef = useRef<HTMLInputElement>(null);

  const focus = () => {
    inputRef.current?.focus();
  };

  return <input ref={inputRef} />;
}

✅ Mutable Values (No Re-render)

function Timer() {
  const [count, setCount] = useState(0);
  const intervalRef = useRef<number>();

  const start = () => {
    intervalRef.current = setInterval(() => {
      setCount(c => c + 1);
    }, 1000);
  };

  const stop = () => {
    if (intervalRef.current) {
      clearInterval(intervalRef.current);
    }
  };

  return (
    <div>
      {count}
      <button onClick={start}>Start</button>
      <button onClick={stop}>Stop</button>
    </div>
  );
}

✅ Previous Value Tracking

function usePrevious<T>(value: T): T | undefined {
  const ref = useRef<T>();

  useEffect(() => {
    ref.current = value;
  }, [value]);

  return ref.current;
}

const [count, setCount] = useState(0);
const prevCount = usePrevious(count);
// prevCount is always the previous value

useImperativeHandle

✅ Exposing Custom Ref API

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

interface InputHandle {
  focus: () => void;
  clear: () => void;
}

const CustomInput = forwardRef<InputHandle, { placeholder?: string }>((props, ref) => {
  const inputRef = useRef<HTMLInputElement>(null);

  useImperativeHandle(ref, () => ({
    focus: () => {
      inputRef.current?.focus();
    },
    clear: () => {
      if (inputRef.current) {
        inputRef.current.value = '';
      }
    },
  }));

  return <input ref={inputRef} {...props} />;
});

function Parent() {
  const inputRef = useRef<InputHandle>(null);

  return (
    <div>
      <CustomInput ref={inputRef} />
      <button onClick={() => inputRef.current?.focus()}>Focus</button>
      <button onClick={() => inputRef.current?.clear()}>Clear</button>
    </div>
  );
}

Hook Dependencies & Closures

⚠️ Stale Closures Problem

// ❌ WRONG: Stale closure
function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      console.log(count); // Always logs 0 (stale)
      setCount(count + 1); // Always adds to 0
    }, 1000);
    return () => clearInterval(id);
  }, []); // Missing count dependency

  return <div>{count}</div>;
}

// ✅ CORRECT: Functional update
useEffect(() => {
  const id = setInterval(() => {
    setCount(c => c + 1); // Always current
  }, 1000);
  return () => clearInterval(id);
}, []); // No dependencies needed

✅ Exhaustive Dependencies

function SearchResults({ query, filters }) {
  const [results, setResults] = useState([]);

  useEffect(() => {
    fetchResults(query, filters).then(setResults);
  }, [query, filters]); // Both dependencies

  return <div>{/* ... */}</div>;
}

✅ Stable References with useCallback

function Parent() {
  const [count, setCount] = useState(0);

  // ❌ WRONG: New function on every render
  const handleClick = () => {
    setCount(count + 1);
  };

  // ✅ CORRECT: Stable function reference
  const handleClick = useCallback(() => {
    setCount(c => c + 1);
  }, []); // No dependencies with functional update

  return <MemoizedChild onClick={handleClick} />;
}

Advanced Patterns

✅ State Machine with useReducer

type State = "idle" | "loading" | "success" | "error";

type Action =
  | { type: "FETCH" }
  | { type: "SUCCESS" }
  | { type: "ERROR" }
  | { type: "RESET" };

function reducer(state: State, action: Action): State {
  switch (state) {
    case "idle":
      return action.type === "FETCH" ? "loading" : state;
    case "loading":
      return action.type === "SUCCESS"
        ? "success"
        : action.type === "ERROR"
          ? "error"
          : state;
    case "success":
    case "error":
      return action.type === "RESET" ? "idle" : state;
    default:
      return state;
  }
}

✅ Optimistic Updates

function useMutation<T>(mutationFn: (data: T) => Promise<void>) {
  const [optimisticData, setOptimisticData] = useState<T | null>(null);
  const [error, setError] = useState<Error | null>(null);

  const mutate = useCallback(
    async (data: T) => {
      setOptimisticData(data); // Show immediately
      setError(null);

      try {
        await mutationFn(data);
      } catch (err) {
        setError(err as Error);
        setOptimisticData(null); // Rollback on error
      }
    },
    [mutationFn],
  );

  return { optimisticData, error, mutate };
}

React 18+ Hooks

useId — SSR-Safe Unique IDs

Generate unique IDs stable across server and client rendering.

// ✅ CORRECT: useId for form label association
function FormField({ label }: { label: string }) {
  const id = useId();
  return (
    <div>
      <label htmlFor={id}>{label}</label>
      <input id={id} />
    </div>
  );
}

// ❌ WRONG: Math.random() or counter (SSR mismatch)
let counter = 0;
function FormField({ label }: { label: string }) {
  const id = `field-${counter++}`; // Hydration mismatch!
  return <input id={id} />;
}

useTransition — Non-Urgent State Updates

Mark state updates as non-urgent to keep UI responsive during heavy renders.

function SearchPage() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState<Item[]>([]);
  const [isPending, startTransition] = useTransition();

  const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
    setQuery(e.target.value); // Urgent: update input immediately

    startTransition(() => {
      setResults(filterItems(e.target.value)); // Non-urgent: can be interrupted
    });
  };

  return (
    <div>
      <input value={query} onChange={handleChange} />
      {isPending && <Spinner />}
      <ResultList items={results} />
    </div>
  );
}

useDeferredValue — Deferred Rendering

Defer re-rendering of a value to keep UI responsive.

function SearchResults({ query }: { query: string }) {
  const deferredQuery = useDeferredValue(query);
  const isStale = query !== deferredQuery;

  // Expensive computation uses deferred (stale) value
  const results = useMemo(() => filterLargeDataset(deferredQuery), [deferredQuery]);

  return (
    <div style={{ opacity: isStale ? 0.7 : 1 }}>
      <ResultList items={results} />
    </div>
  );
}

useTransition vs useDeferredValue:

  • useTransition: You control when to start the transition (wrap setState)
  • useDeferredValue: React defers the value automatically (wrap the value)

useSyncExternalStore — External Store Subscription

Subscribe to external stores (non-React state) safely.

function useWindowWidth(): number {
  return useSyncExternalStore(
    (callback) => {
      window.addEventListener('resize', callback);
      return () => window.removeEventListener('resize', callback);
    },
    () => window.innerWidth,       // Client snapshot
    () => 1024,                     // Server snapshot (SSR fallback)
  );
}

function ResponsiveLayout() {
  const width = useWindowWidth();
  return width > 768 ? <DesktopLayout /> : <MobileLayout />;
}

References