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.tsx02-useEffect/TrafficLightWithEffect.tsx02-useEffect/TrafficLightWithHook.tsxhooks/useTrafficLight.tsx-
hooks/usePokemon.tsx+PokemonPage.tsx playground/FocusScreen.tsxhooks/useCounter.tsx
TL;DR — What you’ll learn
- Turn
useStateinto a finite state machine you won’t fight later.- Write side‑effect safe
useEffectwith cleanup and event semantics.- Use
useReffor 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>
);
}
Why this scales
- The
nexttransition 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
}
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>
);
}
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 };
}
// TrafficLightWithHook.tsx
import { useTrafficLight } from "../hooks/useTrafficLight";
export function TrafficLightWithHook() {
const { light } = useTrafficLight(750);
return <div className={`bulb ${light}`}>{light}</div>;
}
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 };
}
// 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>
);
}
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
useRefto 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 };
}
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
useEffectfor idempotence and cleanup; keep deps narrow. - [ ] Introduce
useReffor 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>
);
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)