
Mastering React Hooks & State: A Practical Guide

D. Rout
March 2, 2026 8 min read
On this page
React Hooks changed everything. Introduced in React 16.8, they let you use state and other React features in function components — no class components required. Whether you're just getting started or looking to sharpen your fundamentals, this guide walks through the essential hooks with real, runnable examples.
What Are Hooks?
Hooks are functions that let you "hook into" React features like state and lifecycle methods from within function components. They follow two simple rules:
- Only call hooks at the top level — not inside loops, conditions, or nested functions.
- Only call hooks from React function components (or other custom hooks).
useState — Managing Local State
useState is the most fundamental hook. It returns a state variable and a setter function.
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
<button onClick={() => setCount(count - 1)}>Decrement</button>
<button onClick={() => setCount(0)}>Reset</button>
</div>
);
}
State with Objects
When state is an object, always spread the previous state to avoid losing other fields:
function UserForm() {
const [user, setUser] = useState({ name: '', email: '' });
const handleChange = (e) => {
setUser(prev => ({
...prev,
[e.target.name]: e.target.value,
}));
};
return (
<form>
<input name="name" value={user.name} onChange={handleChange} placeholder="Name" />
<input name="email" value={user.email} onChange={handleChange} placeholder="Email" />
<p>Hello, {user.name || 'stranger'}!</p>
</form>
);
}
Functional Updates
When the new state depends on the previous state, use the functional form of the setter. This ensures correctness even in async or batched contexts:
// ❌ Potentially stale
setCount(count + 1);
// ✅ Always correct
setCount(prev => prev + 1);
useEffect — Side Effects & Lifecycle
useEffect runs side effects after rendering — things like data fetching, subscriptions, or manually touching the DOM.
import { useState, useEffect } from 'react';
function GitHubUser({ username }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
fetch(`https://api.github.com/users/${username}`)
.then(res => res.json())
.then(data => {
setUser(data);
setLoading(false);
});
}, [username]); // Re-runs whenever `username` changes
if (loading) return <p>Loading...</p>;
return <p>{user?.name} has {user?.public_repos} public repos.</p>;
}
The Dependency Array
The second argument to useEffect controls when it runs:
| Dependency Array | When Effect Runs |
|---|---|
| Omitted | After every render |
[] |
Once, on mount only |
[a, b] |
When a or b changes |
Cleanup
Return a cleanup function to cancel subscriptions, timers, or anything that could cause a memory leak:
useEffect(() => {
const id = setInterval(() => {
console.log('tick');
}, 1000);
// Cleanup: runs before the next effect or on unmount
return () => clearInterval(id);
}, []);
useReducer — Complex State Logic
When state logic becomes complex — multiple sub-values, or the next state depends on the previous — useReducer is often a better fit than useState.
import { useReducer } from 'react';
const initialState = { count: 0, step: 1 };
function reducer(state, action) {
switch (action.type) {
case 'increment':
return { ...state, count: state.count + state.step };
case 'decrement':
return { ...state, count: state.count - state.step };
case 'setStep':
return { ...state, step: action.payload };
case 'reset':
return initialState;
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
function StepCounter() {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<div>
<p>Count: {state.count} (step: {state.step})</p>
<button onClick={() => dispatch({ type: 'increment' })}>+</button>
<button onClick={() => dispatch({ type: 'decrement' })}>-</button>
<input
type="number"
value={state.step}
onChange={e => dispatch({ type: 'setStep', payload: Number(e.target.value) })}
/>
<button onClick={() => dispatch({ type: 'reset' })}>Reset</button>
</div>
);
}
Rule of thumb: Start with
useState. Reach foruseReducerwhen you have 3+ related state values or complex transitions.
useRef — Persistent Values Without Re-renders
useRef gives you a mutable container that persists across renders — without triggering a re-render when changed. Common use cases: DOM references and storing previous values.
import { useState, useEffect, useRef } from 'react';
function Timer() {
const [seconds, setSeconds] = useState(0);
const [running, setRunning] = useState(false);
const intervalRef = useRef(null);
useEffect(() => {
if (running) {
intervalRef.current = setInterval(() => {
setSeconds(prev => prev + 1);
}, 1000);
} else {
clearInterval(intervalRef.current);
}
return () => clearInterval(intervalRef.current);
}, [running]);
return (
<div>
<p>{seconds}s</p>
<button onClick={() => setRunning(r => !r)}>
{running ? 'Pause' : 'Start'}
</button>
<button onClick={() => { setRunning(false); setSeconds(0); }}>Reset</button>
</div>
);
}
Accessing DOM Elements
function AutoFocusInput() {
const inputRef = useRef(null);
useEffect(() => {
inputRef.current?.focus();
}, []);
return <input ref={inputRef} placeholder="Auto-focused!" />;
}
useMemo & useCallback — Performance Optimisation
These hooks let you memoize computed values and functions — useful when passing callbacks to deeply nested children or performing expensive computations.
import { useState, useMemo, useCallback } from 'react';
function ExpensiveList({ items, onSelect }) {
const sorted = useMemo(() => {
// Only recalculates when `items` changes
return [...items].sort((a, b) => a.localeCompare(b));
}, [items]);
return (
<ul>
{sorted.map(item => (
<li key={item} onClick={() => onSelect(item)}>{item}</li>
))}
</ul>
);
}
function Parent() {
const [items] = useState(['banana', 'apple', 'cherry']);
const [selected, setSelected] = useState(null);
// Stable reference — won't cause ExpensiveList to re-render unnecessarily
const handleSelect = useCallback((item) => {
setSelected(item);
}, []);
return (
<>
<ExpensiveList items={items} onSelect={handleSelect} />
{selected && <p>Selected: {selected}</p>}
</>
);
}
Caution: Don't over-optimise.
useMemoanduseCallbackadd overhead themselves. Only reach for them when you have a measured performance problem.
Building Custom Hooks
Custom hooks let you extract and share stateful logic between components. They're just functions whose names start with use.
useFetch — Reusable Data Fetching
import { useState, useEffect } from 'react';
function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
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);
setLoading(false);
}
})
.catch(err => {
if (!cancelled) {
setError(err.message);
setLoading(false);
}
});
return () => { cancelled = true; };
}, [url]);
return { data, loading, error };
}
// Usage
function PostList() {
const { data: posts, loading, error } = useFetch('https://jsonplaceholder.typicode.com/posts');
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
return (
<ul>
{posts.slice(0, 5).map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
useLocalStorage — Persisting State
import { useState } from 'react';
function useLocalStorage(key, initialValue) {
const [storedValue, setStoredValue] = useState(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch {
return initialValue;
}
});
const setValue = (value) => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
console.error(error);
}
};
return [storedValue, setValue];
}
// Usage — works just like useState, but persists across page refreshes
function ThemeToggle() {
const [theme, setTheme] = useLocalStorage('theme', 'light');
return (
<button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>
Current theme: {theme}
</button>
);
}
Common Mistakes to Avoid
1. Missing dependencies in useEffect
Always include every value from the component scope that the effect uses. The ESLint plugin eslint-plugin-react-hooks will catch these automatically.
2. Infinite loops
Setting state inside a useEffect with no dependency array — or with a dependency that changes on every render (like an inline object) — will cause infinite loops.
// ❌ Infinite loop — new object reference on every render
useEffect(() => { ... }, [{ id: 1 }]);
// ✅ Stable primitive
useEffect(() => { ... }, [userId]);
3. Calling hooks conditionally Hooks must always be called in the same order on every render.
// ❌ Breaks the rules of hooks
if (isLoggedIn) {
const [user, setUser] = useState(null);
}
// ✅ Call unconditionally, use the value conditionally
const [user, setUser] = useState(null);
if (isLoggedIn) { /* use user */ }
Further Learning
Ready to go deeper? These resources are worth bookmarking:
- Official React Hooks Reference — the authoritative source, with detailed API docs and interactive examples.
- Thinking in React — the canonical guide to React's mental model, including how to structure state.
- useHooks.com — a growing collection of community-contributed custom hooks you can drop into your projects.
- Practical React Query by TkDodo — if you're fetching data, this series on React Query is essential reading.
- React Hooks Explained – Fireship (YouTube) — a fast-paced, visual overview of every major hook in under 12 minutes.
Summary
| Hook | Use It For |
|---|---|
useState |
Simple local state |
useEffect |
Side effects, data fetching, subscriptions |
useReducer |
Complex state with multiple transitions |
useRef |
DOM access, mutable values without re-renders |
useMemo |
Memoising expensive computed values |
useCallback |
Stable function references for child components |
| Custom hooks | Reusable stateful logic across components |
Hooks reward a functional, composable way of thinking. Start simple with useState and useEffect, extract logic into custom hooks as patterns emerge, and reach for useReducer when state transitions get complex. The best React code reads like a series of small, focused hooks that each do one thing well.
Read next
Comments (0)
Join the conversation
Sign in to leave a comment on this post.
No comments yet. to be the first!