React Hooks: The Complete Guide for 2026
React Hooks transformed how we build components. Since their introduction in React 16.8, they have become the standard for state management, side effects, and logic reuse in React applications. With React 19 adding powerful new hooks like useActionState, useOptimistic, and the use() API, the hooks ecosystem is richer than ever. This guide covers every hook you need to know — with practical, real-world examples you can use today.
1. useState — State Management Basics
useState is the most fundamental hook. It declares a state variable and returns a pair: the current value and a function to update it.
import { useState } from "react";
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}
Functional Updates
When the next state depends on the previous state, always use the functional form. This avoids stale closures, especially in event handlers and intervals:
// Wrong: stale closure in setInterval
setCount(count + 1);
// Correct: always uses the latest state
setCount(prev => prev + 1);
// Batched updates in React 18+ — both use latest state
function handleClick() {
setCount(prev => prev + 1);
setCount(prev => prev + 1); // Results in +2, not +1
}
Lazy Initialization
If your initial state requires an expensive computation, pass a function to useState. It runs only on the first render:
// Runs JSON.parse on EVERY render (wasteful)
const [user, setUser] = useState(JSON.parse(localStorage.getItem("user")));
// Runs JSON.parse only on the FIRST render
const [user, setUser] = useState(() => {
const saved = localStorage.getItem("user");
return saved ? JSON.parse(saved) : null;
});
Updating Objects and Arrays
State updates must be immutable. React only re-renders when it detects a new reference:
const [form, setForm] = useState({ name: "", email: "", role: "user" });
// Update one field — spread existing state
setForm(prev => ({ ...prev, name: "Alice" }));
// Arrays — create new arrays instead of mutating
const [items, setItems] = useState(["React", "Vue"]);
setItems(prev => [...prev, "Svelte"]); // add
setItems(prev => prev.filter(i => i !== "Vue")); // remove
setItems(prev => prev.map(i => i === "React" ? "React 19" : i)); // update
2. useEffect — Side Effects
useEffect lets you synchronize your component with external systems — APIs, subscriptions, the DOM, timers, and more. It runs after the component renders.
import { useState, useEffect } from "react";
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
setLoading(true);
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => {
if (!cancelled) {
setUser(data);
setLoading(false);
}
});
// Cleanup: prevent state updates on unmounted component
return () => { cancelled = true; };
}, [userId]); // Re-run only when userId changes
if (loading) return <p>Loading...</p>;
return <h2>{user.name}</h2>;
}
Dependency Array Patterns
// Runs after EVERY render (rarely what you want)
useEffect(() => { console.log("rendered"); });
// Runs ONCE on mount, cleans up on unmount
useEffect(() => {
const ws = new WebSocket("wss://api.example.com");
return () => ws.close();
}, []);
// Runs when specific values change
useEffect(() => {
document.title = `${count} items`;
}, [count]);
Common Pitfalls
Infinite loops: If your effect updates state that is in its own dependency array, you get an infinite loop. Fix it with functional updates or restructure your logic:
// INFINITE LOOP: items changes -> effect runs -> items changes
useEffect(() => {
setItems([...items, newItem]);
}, [items, newItem]);
// Fixed: functional update doesn't need items in deps
useEffect(() => {
setItems(prev => [...prev, newItem]);
}, [newItem]);
Missing dependencies: The eslint-plugin-react-hooks warns about missing deps. Never suppress the warning without understanding why — missing deps cause stale closures.
useEffect vs useLayoutEffect: useEffect runs asynchronously after paint. useLayoutEffect runs synchronously before paint. Use useLayoutEffect only when you need to measure the DOM or prevent visual flicker:
import { useLayoutEffect, useRef } from "react";
function Tooltip({ text }) {
const ref = useRef(null);
useLayoutEffect(() => {
// Measure before browser paints — no flicker
const { width } = ref.current.getBoundingClientRect();
ref.current.style.marginLeft = `${-width / 2}px`;
}, [text]);
return <span ref={ref}>{text}</span>;
}
3. useContext — Context API Integration
useContext reads a context value without prop drilling. Combined with useReducer, it creates a lightweight state management solution:
import { createContext, useContext, useState } from "react";
const ThemeContext = createContext("light");
function ThemeProvider({ children }) {
const [theme, setTheme] = useState("light");
const toggle = () => setTheme(t => t === "light" ? "dark" : "light");
return (
<ThemeContext.Provider value={{ theme, toggle }}>
{children}
</ThemeContext.Provider>
);
}
// Custom hook for cleaner consumption
function useTheme() {
const context = useContext(ThemeContext);
if (!context) throw new Error("useTheme must be used within ThemeProvider");
return context;
}
function Header() {
const { theme, toggle } = useTheme();
return <button onClick={toggle}>Current: {theme}</button>;
}
Performance tip: Every consumer re-renders when the context value changes. Split contexts by update frequency — keep fast-changing values (like mouse position) separate from slow-changing values (like theme):
// Split into two contexts instead of one large one
const UserContext = createContext(null); // Changes rarely
const NotificationContext = createContext([]); // Changes often
4. useReducer — Complex State Logic
useReducer is preferable to useState when state transitions follow defined rules or when multiple state values change together:
import { useReducer } from "react";
const initialState = { items: [], loading: false, error: null };
function cartReducer(state, action) {
switch (action.type) {
case "ADD_ITEM":
return { ...state, items: [...state.items, action.payload] };
case "REMOVE_ITEM":
return { ...state, items: state.items.filter(i => i.id !== action.payload) };
case "SET_LOADING":
return { ...state, loading: action.payload, error: null };
case "SET_ERROR":
return { ...state, loading: false, error: action.payload };
case "CLEAR":
return initialState;
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
function ShoppingCart() {
const [state, dispatch] = useReducer(cartReducer, initialState);
const addItem = (product) => dispatch({ type: "ADD_ITEM", payload: product });
const removeItem = (id) => dispatch({ type: "REMOVE_ITEM", payload: id });
return (
<div>
{state.items.map(item => (
<div key={item.id}>
{item.name} — ${item.price}
<button onClick={() => removeItem(item.id)}>Remove</button>
</div>
))}
<p>Total: ${state.items.reduce((sum, i) => sum + i.price, 0)}</p>
</div>
);
}
Combine useReducer with useContext for app-wide state management without external libraries. See our TypeScript Utility Types Guide for typing reducer actions with discriminated unions.
5. useMemo & useCallback — Performance Optimization
useMemo
useMemo caches the result of an expensive computation and only recalculates when dependencies change:
import { useMemo, useState } from "react";
function ProductList({ products, query }) {
// Only recomputes when products or query changes
const filtered = useMemo(() => {
console.log("Filtering...");
return products
.filter(p => p.name.toLowerCase().includes(query.toLowerCase()))
.sort((a, b) => a.price - b.price);
}, [products, query]);
return filtered.map(p => <ProductCard key={p.id} product={p} />);
}
useCallback
useCallback caches a function reference. It matters when passing callbacks to memoized child components:
import { useCallback, memo } from "react";
const TodoItem = memo(function TodoItem({ todo, onToggle }) {
console.log(`Rendering: ${todo.text}`);
return (
<li onClick={() => onToggle(todo.id)}>
{todo.completed ? "☑" : "☐"} {todo.text}
</li>
);
});
function TodoList({ todos, setTodos }) {
// Without useCallback, onToggle is a new function every render,
// causing every TodoItem to re-render even if its todo didn't change
const onToggle = useCallback((id) => {
setTodos(prev => prev.map(t =>
t.id === id ? { ...t, completed: !t.completed } : t
));
}, [setTodos]);
return todos.map(todo => (
<TodoItem key={todo.id} todo={todo} onToggle={onToggle} />
));
}
When NOT to Memoize
Memoization is not free — it adds memory and comparison overhead. Skip it when:
- The computation is cheap (simple arithmetic, short array maps)
- The component re-renders rarely
- The child component is not wrapped in
React.memo - You have not profiled and confirmed a performance problem
React DevTools Profiler is your best friend for finding actual bottlenecks before reaching for memoization.
6. useRef — DOM References and Mutable Values
useRef returns a mutable object that persists across renders without triggering re-renders. It has two primary uses:
DOM References
import { useRef, useEffect } from "react";
function SearchInput() {
const inputRef = useRef(null);
useEffect(() => {
inputRef.current.focus(); // Focus on mount
}, []);
return <input ref={inputRef} placeholder="Search..." />;
}
Mutable Values (No Re-render)
Use useRef for values that need to persist but should not trigger renders — like interval IDs, previous values, or render counts:
function Timer() {
const [seconds, setSeconds] = useState(0);
const intervalRef = useRef(null);
const start = () => {
if (intervalRef.current) return; // Prevent duplicates
intervalRef.current = setInterval(() => {
setSeconds(prev => prev + 1);
}, 1000);
};
const stop = () => {
clearInterval(intervalRef.current);
intervalRef.current = null;
};
useEffect(() => stop, []); // Cleanup on unmount
return (
<div>
<p>{seconds}s</p>
<button onClick={start}>Start</button>
<button onClick={stop}>Stop</button>
</div>
);
}
// Track previous value
function usePrevious(value) {
const ref = useRef();
useEffect(() => { ref.current = value; });
return ref.current;
}
Key insight: Mutating ref.current never causes a re-render. If you need the UI to update, use useState instead.
7. Custom Hooks — Building Reusable Logic
Custom hooks are functions that start with use and compose other hooks. They are the primary mechanism for sharing stateful logic between components.
useLocalStorage
function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
try {
const stored = localStorage.getItem(key);
return stored !== null ? JSON.parse(stored) : initialValue;
} catch {
return initialValue;
}
});
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue];
}
// Usage
function Settings() {
const [theme, setTheme] = useLocalStorage("theme", "dark");
return <button onClick={() => setTheme(t => t === "dark" ? "light" : "dark")}>{theme}</button>;
}
useFetch
function useFetch(url) {
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
fetch(url)
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(json => { if (!cancelled) setData(json); })
.catch(err => { if (!cancelled) setError(err.message); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [url]);
return { data, error, loading };
}
// Usage
function RepoList() {
const { data, error, loading } = useFetch("/api/repos");
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
return data.map(repo => <div key={repo.id}>{repo.name}</div>);
}
useDebounce
function useDebounce(value, delay = 300) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}
// Usage: debounce search input to avoid API spam
function Search() {
const [query, setQuery] = useState("");
const debouncedQuery = useDebounce(query, 400);
const { data } = useFetch(
debouncedQuery ? `/api/search?q=${debouncedQuery}` : null
);
return <input value={query} onChange={e => setQuery(e.target.value)} />;
}
8. React 19+ Hooks
React 19 introduced new hooks that simplify patterns that previously required verbose boilerplate. These hooks are designed for frameworks like Next.js and server-side rendering, but work in any React 19 project.
useActionState
Manages form state with a reducer-like pattern. Replaces the older useFormState and integrates with React's transition system:
import { useActionState } from "react";
async function submitForm(prevState, formData) {
const name = formData.get("name");
const email = formData.get("email");
const res = await fetch("/api/signup", {
method: "POST",
body: JSON.stringify({ name, email }),
headers: { "Content-Type": "application/json" }
});
if (!res.ok) return { error: "Signup failed. Try again." };
return { success: true, message: `Welcome, ${name}!` };
}
function SignupForm() {
const [state, formAction, isPending] = useActionState(submitForm, {});
return (
<form action={formAction}>
<input name="name" required disabled={isPending} />
<input name="email" type="email" required disabled={isPending} />
<button type="submit" disabled={isPending}>
{isPending ? "Signing up..." : "Sign Up"}
</button>
{state.error && <p style={{ color: "red" }}>{state.error}</p>}
{state.success && <p style={{ color: "green" }}>{state.message}</p>}
</form>
);
}
useFormStatus
Reads the pending status of a parent <form>. Must be called inside a component rendered within the form:
import { useFormStatus } from "react-dom";
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? "Saving..." : "Save"}
</button>
);
}
// Use inside any form — automatically tracks pending state
function EditProfile() {
return (
<form action={saveProfile}>
<input name="bio" />
<SubmitButton />
</form>
);
}
useOptimistic
Shows an optimistic value while an async action is pending, automatically reverting if the action fails:
import { useOptimistic } from "react";
function MessageList({ messages, sendMessage }) {
const [optimisticMessages, addOptimistic] = useOptimistic(
messages,
(current, newMsg) => [...current, { ...newMsg, sending: true }]
);
async function handleSend(formData) {
const text = formData.get("text");
addOptimistic({ id: Date.now(), text, author: "You" });
await sendMessage(text); // If this fails, optimistic update reverts
}
return (
<div>
{optimisticMessages.map(msg => (
<p key={msg.id} style={{ opacity: msg.sending ? 0.6 : 1 }}>
{msg.author}: {msg.text}
</p>
))}
<form action={handleSend}>
<input name="text" />
<button type="submit">Send</button>
</form>
</div>
);
}
use() API
use() reads a promise or context value directly in render. Unlike other hooks, it can be called inside conditionals:
import { use, Suspense } from "react";
// Read a promise — component suspends until it resolves
function UserName({ userPromise }) {
const user = use(userPromise);
return <h2>{user.name}</h2>;
}
// Read context — equivalent to useContext but works in conditionals
function OptionalTheme({ useCustomTheme }) {
if (useCustomTheme) {
const theme = use(ThemeContext);
return <div className={theme}>Custom themed</div>;
}
return <div>Default theme</div>;
}
// Parent wraps in Suspense
function App() {
const userPromise = fetch("/api/me").then(r => r.json());
return (
<Suspense fallback={<p>Loading user...</p>}>
<UserName userPromise={userPromise} />
</Suspense>
);
}
9. Hook Rules and Common Mistakes
The Two Rules
- Only call hooks at the top level — never inside loops, conditions, or nested functions. React relies on call order to track state.
- Only call hooks from React functions — either function components or custom hooks. Never from regular JavaScript functions or class methods.
// WRONG: conditional hook call
function Profile({ isLoggedIn }) {
if (isLoggedIn) {
const [user, setUser] = useState(null); // Hook order changes!
}
}
// CORRECT: always call, conditionally use
function Profile({ isLoggedIn }) {
const [user, setUser] = useState(null);
// Use the state conditionally, not the hook
if (!isLoggedIn) return <p>Please log in</p>;
return <p>Welcome, {user?.name}</p>;
}
Common Mistakes
1. Object/array dependencies creating infinite loops:
// BUG: { sort: "name" } is a new object every render
function UserList({ sort = "name" }) {
const options = { sort }; // New reference each render!
useEffect(() => { fetchUsers(options); }, [options]); // Infinite loop
}
// FIX: use primitive values or useMemo
function UserList({ sort = "name" }) {
useEffect(() => { fetchUsers({ sort }); }, [sort]); // Primitive dep
}
2. Forgetting cleanup:
// Memory leak: event listener never removed
useEffect(() => {
window.addEventListener("resize", handleResize);
}, []);
// Fixed: always clean up subscriptions
useEffect(() => {
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
3. Setting state on unmounted components: Use the cleanup pattern shown in the useEffect section with a cancelled flag to prevent updates after unmount.
Install eslint-plugin-react-hooks to catch these issues automatically. It enforces both rules and warns about missing or incorrect dependencies.
10. Testing Hooks
Use @testing-library/react to test hooks through the components that use them. For custom hooks, renderHook isolates the hook from any UI:
Testing a Component with Hooks
import { render, screen, fireEvent } from "@testing-library/react";
import Counter from "./Counter";
test("increments count on click", () => {
render(<Counter />);
expect(screen.getByText("Count: 0")).toBeInTheDocument();
fireEvent.click(screen.getByText("Increment"));
expect(screen.getByText("Count: 1")).toBeInTheDocument();
});
Testing a Custom Hook
import { renderHook, act } from "@testing-library/react";
import { useLocalStorage } from "./useLocalStorage";
test("reads and writes localStorage", () => {
const { result } = renderHook(() => useLocalStorage("key", "default"));
// Initial value
expect(result.current[0]).toBe("default");
// Update value
act(() => { result.current[1]("updated"); });
expect(result.current[0]).toBe("updated");
expect(localStorage.getItem("key")).toBe('"updated"');
});
Testing Async Effects
import { render, screen, waitFor } from "@testing-library/react";
// Mock the API
global.fetch = jest.fn(() =>
Promise.resolve({ ok: true, json: () => Promise.resolve({ name: "Alice" }) })
);
test("fetches and displays user", async () => {
render(<UserProfile userId={1} />);
expect(screen.getByText("Loading...")).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText("Alice")).toBeInTheDocument();
});
});
Tip: Test behavior, not implementation. Assert on what the user sees, not on internal state values. This makes tests resilient to refactoring.
Frequently Asked Questions
What are React Hooks and why were they introduced?
React Hooks are functions that let you use state and other React features in functional components without writing classes. They were introduced in React 16.8 to solve problems with class components: complex lifecycle methods, difficulty sharing stateful logic between components, and confusing this binding. Hooks make components simpler, more reusable, and easier to test.
What is the difference between useMemo and useCallback?
useMemo memoizes a computed value and returns the cached result until its dependencies change. useCallback memoizes a function reference itself. Use useMemo for expensive calculations like sorting or filtering large arrays, and useCallback when passing callbacks to child components wrapped in React.memo. Don't overuse either — only apply them when profiling reveals actual performance issues, as unnecessary memoization adds overhead.
How do I create a custom Hook in React?
A custom Hook is a JavaScript function whose name starts with use and that calls other Hooks. Extract shared stateful logic from components into a function named useYourHookName. For example, useLocalStorage manages localStorage state, useFetch handles API calls, and useDebounce delays value updates. Custom Hooks follow the same rules as built-in Hooks: they must be called at the top level and only from React functions.
What new Hooks were added in React 19?
React 19 introduced several new hooks: useActionState manages form action state and pending status. useOptimistic provides optimistic UI updates that automatically revert if an async action fails. The use() API can read promises and context directly inside render, enabling Suspense-based data fetching. useFormStatus reads the status of a parent form element. These hooks simplify patterns that previously required complex state management.
What are the rules of Hooks and why do they matter?
There are two fundamental rules: (1) Only call Hooks at the top level — never inside loops, conditions, or nested functions; (2) Only call Hooks from React function components or custom Hooks. These rules exist because React relies on the order in which Hooks are called to correctly associate state with each Hook. Breaking call order between renders causes bugs where state gets assigned to the wrong Hook. Use eslint-plugin-react-hooks to enforce these rules automatically.
Related Resources
- TypeScript Utility Types Guide — type your hooks with discriminated unions and utility types
- Next.js Complete Guide — use React 19 hooks in a full-stack framework
- JavaScript Promises & Async/Await — the async foundations behind useEffect and use()
- JavaScript Cheat Sheet — quick reference for destructuring, spread, and arrow functions
- JSON Formatter — format and validate API responses while building hooks
Keep building: Hooks are the foundation of modern React. Combine them with TypeScript for type safety, use the JavaScript Debugging Guide to troubleshoot tricky re-render issues, and explore our JSON Formatter when working with API data in your effects and custom hooks.