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