DEV Community

Cristian Sifuentes
Cristian Sifuentes

Posted on

React Hooks Mastery in 2025 — useState, useEffect, useRef, and Real‑World Custom Hooks

React Hooks Mastery in 2025 — useState, useEffect, useRef, and Real‑World Custom Hooks

React Hooks Mastery in 2025 — useState, useEffect, useRef, and Real‑World Custom Hooks

React Hooks are stable, battle‑tested, and still the best primitives for building resilient UI logic.
This post is a hands‑on guide using real .tsx components you can run today:
state with useState, lifecycle with useEffect, ergonomics with useRef, and custom hooks that model behavior (not components).

Repo pieces used in this tutorial (you can adapt them 1:1):

  • 01-useState/TrafficLight.tsx
  • 02-useEffect/TrafficLightWithEffect.tsx
  • 02-useEffect/TrafficLightWithHook.tsx
  • hooks/useTrafficLight.tsx
  • hooks/usePokemon.tsx + PokemonPage.tsx
  • playground/FocusScreen.tsx
  • hooks/useCounter.tsx

TL;DR — What you’ll learn

  • Turn useState into a finite state machine you won’t fight later.
  • Write side‑effect safe useEffect with cleanup and event semantics.
  • Use useRef for imperative handles (focus, scroll, measuring) without re‑renders.
  • Design custom hooks that compose state + effects and expose a tiny API.
  • Production guardrails: dependency arrays, idempotent effects, and teardown hygiene.

1) useState — From counters to state machines

A traffic light is a perfect discrete system: red → green → yellow → red.
Here’s a condensed version of TrafficLight.tsx that models the light as a state machine.

import { useState } from "react";

type Light = "red" | "green" | "yellow";

export function TrafficLight() {
  const [light, setLight] = useState<Light>("red");
  const next: Record<Light, Light> = { red: "green", green: "yellow", yellow: "red" };

  return (
    <div>
      <div className={`bulb ${light}`} aria-live="polite">{light}</div>
      <button onClick={() => setLight(next[light])}>Next</button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Why this scales

  • The next transition table is explicit (great for tests).
  • Adding flashing or maintenance states is a single line change.
  • The UI is a pure function of state (light).

Pro tip — keep the UI dumb and transitions centralized. If transitions grow, extract them into a reducer or a tiny state‑chart.


2) useEffect — Lifecycle and teardown done right

Effects run after paint and must be idempotent with cleanup.
TrafficLightWithEffect.tsx automatically cycles the light every N seconds and tears down the timer when unmounted or when the cadence changes.

import { useEffect, useState } from "react";

export function TrafficLightWithEffect() {
  const [light, setLight] = useState<"red"|"green"|"yellow">("red");
  const [intervalMs, setIntervalMs] = useState(1000);

  useEffect(() => {
    const order = ["red","green","yellow"] as const;
    let i = order.indexOf(light);
    const id = setInterval(() => setLight(prev => order[(order.indexOf(prev)+1)%order.length]), intervalMs);
    return () => clearInterval(id); // ✅ cleanup
  }, [intervalMs]); // ✅ don’t include `light`; cadence controls the effect
}
Enter fullscreen mode Exit fullscreen mode

Effect checklist

  • Put all values that control the *lifecycle* of the effect in the deps array.
  • Keep logic inside referentially stable callbacks if the effect doesn’t depend on props/state changes.
  • Always return a cleanup to avoid leaks (timers, events, sockets, observers).

3) useRef — Imperative handles without re‑renders

FocusScreen.tsx shows a classic pattern: capture an input’s DOM node and focus it without changing state (and triggering renders).

import { useRef } from "react";

export function FocusScreen() {
  const inputRef = useRef<HTMLInputElement>(null);
  return (
    <div>
      <input ref={inputRef} placeholder="Type here…" />
      <button onClick={() => inputRef.current?.focus()}>Focus input</button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

When to use useRef

  • Focusing, selecting text, scrolling containers.
  • Keeping mutable values across renders (e.g., previous value, a timeout id).
  • Storing imperative APIs from third‑party widgets.

4) Custom Hook, v1 — useTrafficLight (behavior as an API)

Instead of embedding timers into components, extract behavior into a hook and return a tiny, ergonomic API.

Files: hooks/useTrafficLight.tsx + 02-useEffect/TrafficLightWithHook.tsx

// useTrafficLight.tsx
import { useEffect, useState } from "react";
type Light = "red" | "green" | "yellow";

export function useTrafficLight(intervalMs = 1000) {
  const [light, setLight] = useState<Light>("red");
  useEffect(() => {
    const order: Light[] = ["red","green","yellow"];
    const id = setInterval(() => setLight(p => order[(order.indexOf(p)+1)%3]), intervalMs);
    return () => clearInterval(id);
  }, [intervalMs]);
  return { light, setLight };
}
Enter fullscreen mode Exit fullscreen mode
// TrafficLightWithHook.tsx
import { useTrafficLight } from "../hooks/useTrafficLight";

export function TrafficLightWithHook() {
  const { light } = useTrafficLight(750);
  return <div className={`bulb ${light}`}>{light}</div>;
}
Enter fullscreen mode Exit fullscreen mode

Why this rocks

  • Components stay declarative; behavior lives in hooks.
  • The hook is trivially testable (mock timers and assert transitions).
  • You can reuse the same logic in multiple pages with different cadences.

5) Custom Hook, v2 — usePokemon (async + effect orchestration)

Files: hooks/usePokemon.tsx + PokemonPage.tsx

usePokemon demonstrates data fetching with cancellation and a small status machine.

import { useEffect, useState } from "react";

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

export function usePokemon(name: string) {
  const [status, setStatus] = useState<Status>("idle");
  const [data, setData] = useState<any>(null);
  const [error, setError] = useState<unknown>(null);

  useEffect(() => {
    if (!name) return;
    const ctrl = new AbortController();
    setStatus("loading");

    fetch(\`https://pokeapi.co/api/v2/pokemon/\${name}\`, { signal: ctrl.signal })
      .then(r => r.ok ? r.json() : Promise.reject(r.status))
      .then(json => { setData(json); setStatus("success"); })
      .catch(e => { if ((e as any).name !== "AbortError") { setError(e); setStatus("error"); } });

    return () => ctrl.abort(); // ✅ cancel in-flight request
  }, [name]);

  return { status, data, error };
}
Enter fullscreen mode Exit fullscreen mode
// PokemonPage.tsx
export function PokemonPage() {
  const [name, setName] = useState("pikachu");
  const { status, data } = usePokemon(name);
  if (status === "loading") return <p>Loading…</p>;
  if (status === "error")   return <p>Not found</p>;
  return (
    <section>
      <input value={name} onChange={e => setName(e.target.value)} />
      {data && <img src={data.sprites.front_default} alt={data.name} />}
    </section>
  );
}
Enter fullscreen mode Exit fullscreen mode

Patterns worth copying

  • AbortController for fetch cancellation on prop changes/unmount.
  • Small status machine instead of booleans (isLoading, isError).
  • Return a minimal surface (status, data, error) to keep consumers simple.

6) Production Guardrails & Anti‑patterns

  • Effect dependencies are about lifecycle, not “everything used inside.” If changing a value should not restart the effect, don’t include it (or derive it inside).
  • Cleanup always: timers, listeners, sockets, observers, and in‑flight requests.
  • Refs are not state: using useRef to hold data that drives rendering will desync your UI.
  • Don’t over‑generalize hooks: a hook should solve one behavior. Compose multiple hooks instead of adding options for everything.
  • Testing hooks: use React Testing Library + fake timers for time‑based hooks; MSW for data hooks.

7) A tiny Hooks toolbox you’ll reuse everywhere

// useCounter.tsx
import { useState, useCallback } from "react";
export function useCounter(initial = 0, step = 1) {
  const [value, set] = useState(initial);
  const inc = useCallback(() => set(v => v + step), [step]);
  const dec = useCallback(() => set(v => v - step), [step]);
  const reset = useCallback(() => set(initial), [initial]);
  return { value, inc, dec, reset };
}
Enter fullscreen mode Exit fullscreen mode

This pattern (value + command functions) is ergonomic for forms, pagination, steppers, and more.


Migration & Adoption Checklist

  • [ ] Move transient UI state to useState; model transitions explicitly.
  • [ ] Audit useEffect for idempotence and cleanup; keep deps narrow.
  • [ ] Introduce useRef for focus/scroll/measure to avoid unnecessary renders.
  • [ ] Extract repeated behavior into custom hooks (fetching, polling, media queries).
  • [ ] Co-locate example components and hooks in examples/ to teach your team.
  • [ ] Add tests with fake timers for time‑based hooks and MSW for data hooks.

Appendix — Wiring it up (Vite)

// main.tsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { TrafficLightWithHook } from "./02-useEffect/TrafficLightWithHook";

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <TrafficLightWithHook />
  </StrictMode>
);
Enter fullscreen mode Exit fullscreen mode

That’s it — you’ve got a production‑quality mental model for Hooks and a set of drop‑in patterns you can reuse tomorrow.


Written by Cristian Sifuentes — building resilient front‑ends and teaching teams how to reason about async UI.

Top comments (0)