Introduction to React Hooks: Tutorial with Code Examples
Hooks are a set of APIs for handling state, side effects, context, memoization, external stores, and DOM integration in function components. Assuming React 18 and later, this guide explains practical usage, design tips, pitfalls, and performance considerations for Hooks, along with JavaScript code samples.
useState — Minimal state management and functional updates
Using functional updates ensures you always compute the next value from the latest state, preventing stale values during burst updates or async flows. Also, whenever you use side effects like timers or event listeners in useEffect, make sure to clean them up when the component unmounts.
import { useEffect, useState } from "react";
export function Counter() {
const [count, setCount] = useState(0);
const inc = () => setCount((c) => c + 1);
useEffect(() => {
const id = setInterval(() => setCount((c) => c + 1), 1000);
return () => clearInterval(id);
}, []);
return <button onClick={inc}>Count: {count}</button>;
}useEffect — Side effects, cleanup, and dependency array principles
For tasks like data fetching, make them cancelable when they become unnecessary. Also, include all values and functions used inside the effect in its dependency array to avoid bugs from missing dependencies and unnecessary reruns.
import { useEffect, useState } from "react";
export function UsersList() {
const [users, setUsers] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
const ac = new AbortController();
(async () => {
try {
setError(null);
const res = await fetch("/api/users", { signal: ac.signal });
if (!res.ok) throw new Error("Network error");
const data = await res.json();
setUsers(data);
} catch (e) {
if (e.name !== "AbortError") setError(e);
}
})();
return () => ac.abort();
}, []);
if (error) return <p role="alert">{error.message}</p>;
if (!users) return <p>Loading...</p>;
return (
<ul>
{users.map((u) => (
<li key={u.id}>{u.name}</li>
))}
</ul>
);
}useRef — A mutable box and DOM references
Use it to hold values without causing re-renders, or to directly access DOM elements. A small custom hook to keep the previous value is also common.
import { useEffect, useRef, useState } from "react";
export function SearchBox() {
const inputRef = useRef(null);
const [q, setQ] = useState("");
useEffect(() => {
inputRef.current?.focus();
}, []);
return (
<label>
Query:
<input ref={inputRef} value={q} onChange={(e) => setQ(e.target.value)} />
</label>
);
}import { useEffect, useRef } from "react";
export function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value;
}, [value]);
return ref.current;
}useMemo / useCallback — Prevent unnecessary work and re-creation
Use these selectively for expensive computations, filtering large arrays, and when you need to pass memoized callbacks through props.
import { memo, useCallback, useMemo, useState } from "react";
const Row = memo(function Row({ item, onSelect }) {
return <li onClick={() => onSelect(item.id)}>{item.label}</li>;
});
export function FilteredList({ items }) {
const [q, setQ] = useState("");
const filtered = useMemo(() => {
const lower = q.toLowerCase();
return items.filter((i) => i.label.toLowerCase().includes(lower));
}, [items, q]);
const handleSelect = useCallback((id) => {
console.log("selected", id);
}, []);
return (
<>
<input
placeholder="search"
value={q}
onChange={(e) => setQ(e.target.value)}
/>
<ul>
{filtered.map((i) => (
<Row key={i.id} item={i} onSelect={handleSelect} />
))}
</ul>
</>
);
}useReducer — For complex state and event-driven updates
Collecting state transitions in a reducer clarifies intent and makes testing easier.
import { useReducer, useState } from "react";
function uid() {
return Math.random().toString(36).slice(2, 10);
}
function reducer(state, action) {
switch (action.type) {
case "add":
return [...state, { id: uid(), text: action.text, done: false }];
case "toggle":
return state.map((t) =>
t.id === action.id ? { ...t, done: !t.done } : t
);
case "remove":
return state.filter((t) => t.id !== action.id);
default:
return state;
}
}
export function TodoApp() {
const [todos, dispatch] = useReducer(reducer, []);
const [text, setText] = useState("");
return (
<>
<form
onSubmit={(e) => {
e.preventDefault();
if (text.trim()) dispatch({ type: "add", text });
setText("");
}}
>
<input
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Add todo"
/>
<button>Add</button>
</form>
<ul>
{todos.map((t) => (
<li key={t.id}>
<label>
<input
type="checkbox"
checked={t.done}
onChange={() => dispatch({ type: "toggle", id: t.id })}
/>
{t.text}
</label>
<button onClick={() => dispatch({ type: "remove", id: t.id })}>
x
</button>
</li>
))}
</ul>
</>
);
}useContext — Make global dependencies explicit
Providing a custom read hook makes it easier to use. It also clarifies dependencies and encourages a loosely coupled design.
import { createContext, useContext, useMemo, useState } from "react";
const ThemeContext = createContext(null);
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState("light");
const value = useMemo(() => ({ theme, setTheme }), [theme]);
return (
<ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
);
}
export function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error("useTheme must be inside ThemeProvider");
return ctx;
}Concurrent rendering — useTransition and useDeferredValue
When you want to prioritize input responsiveness while deferring heavy rendering, use useTransition. If you only need to delay a value, useDeferredValue is simpler.
import { useState, useTransition } from "react";
export function SearchWithTransition() {
const [input, setInput] = useState("");
const [query, setQuery] = useState("");
const [isPending, startTransition] = useTransition();
const onChange = (v) => {
setInput(v);
startTransition(() => setQuery(v));
};
return (
<>
<input
value={input}
onChange={(e) => onChange(e.target.value)}
placeholder="search"
/>
{isPending && <p>Updating...</p>}
<LargeList filter={query} />
</>
);
}
function LargeList({ filter }) {
return <div>Filtering by: {filter}</div>;
}import { useDeferredValue, useMemo, useState } from "react";
export function SearchWithDeferred({ items }) {
const [q, setQ] = useState("");
const deferredQ = useDeferredValue(q);
const results = useMemo(() => {
return items.filter((s) => s.includes(deferredQ));
}, [items, deferredQ]);
return (
<>
<input value={q} onChange={(e) => setQ(e.target.value)} />
<div>{results.length} items</div>
</>
);
}useId — Stable associations across SSR and CSR
Generate stable IDs that match between the server and the client for form labels and ARIA relationships.
import { useId } from "react";
export function LabeledInput() {
const id = useId();
return (
<div>
<label htmlFor={id}>Name</label>
<input id={id} />
</div>
);
}useSyncExternalStore — Consistent subscription to external stores
This example subscribes to a browser API’s state. You can provide a snapshot for SSR.
import { useSyncExternalStore } from "react";
function subscribe(cb) {
window.addEventListener("online", cb);
window.addEventListener("offline", cb);
return () => {
window.removeEventListener("online", cb);
window.removeEventListener("offline", cb);
};
}
function getSnapshot() {
return navigator.onLine;
}
function getServerSnapshot() {
return true;
}
export function OnlineBadge() {
const online = useSyncExternalStore(
subscribe,
getSnapshot,
getServerSnapshot
);
return <span>{online ? "Online" : "Offline"}</span>;
}useLayoutEffect — When DOM measurement or synchronous layout is needed
Runs synchronously after DOM mutations and before the browser paints. Use it for measurements or preventing flicker. Prefer useEffect when possible.
import { useLayoutEffect, useRef, useState } from "react";
export function MeasureBox() {
const ref = useRef(null);
const [rect, setRect] = useState(null);
useLayoutEffect(() => {
if (ref.current) setRect(ref.current.getBoundingClientRect());
}, []);
return (
<div>
<div
ref={ref}
style={{ width: 200, height: 120, background: "#eef" }}
/>
<pre>{rect ? JSON.stringify(rect.toJSON()) : "measuring..."}</pre>
</div>
);
}forwardRef + useImperativeHandle — Expose an imperative API from a child
Safely export imperative operations such as focus and scroll.
import { forwardRef, useImperativeHandle, useRef, useState } from "react";
export const TextInput = forwardRef(function TextInput({ initial = "" }, ref) {
const inputRef = useRef(null);
const [value, setValue] = useState(initial);
useImperativeHandle(ref, () => ({
focus: () => inputRef.current?.focus(),
clear: () => setValue(""),
}));
return (
<input
ref={inputRef}
value={value}
onChange={(e) => setValue(e.target.value)}
/>
);
});import { useRef } from "react";
import { TextInput } from "./TextInput";
export function ImperativeExample() {
const ref = useRef(null);
return (
<div>
<TextInput ref={ref} />
<button onClick={() => ref.current?.focus()}>Focus</button>
<button onClick={() => ref.current?.clear()}>Clear</button>
</div>
);
}Common pitfalls
- Missing items in dependency arrays are a major source of bugs. Creating objects or functions inline on every render destabilizes dependencies and can trigger excessive reruns. Use useMemo and useCallback to stabilize references when needed.
- You don’t need useEffect for purely render-time derived values. If a derived value can be computed during render, avoid state—this helps prevent loops and consistency issues.
- Updating a value stored in useRef does not cause a re-render. Use state if you want UI to update. Conversely, use a ref for ephemeral data you don’t want to trigger re-renders.
Summary
Hooks become easier to design around when you master: state management (useState/useReducer), side effects (useEffect), references (useRef), memoization (useMemo/useCallback), context (useContext), concurrent UI (useTransition/useDeferredValue), external stores (useSyncExternalStore), and DOM synchronization (useLayoutEffect).