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 (1)

Collapse
 
shemith_mohanan_6361bb8a2 profile image
shemith mohanan

This was a solid breakdown. I really liked how you turned each hook into real, testable patterns instead of just theory. The traffic light example made the state machine idea click instantly, and the AbortController part for async hooks is something many tutorials skip. Clear, practical, and easy to reuse — great work!

Some comments may only be visible to logged-in visitors. Sign in to view all comments.