πͺ State Management with Hooks
Hooks give function components memory and lifecycle. In this lesson you'll learn useState to hold changing data, useEffect to run side effects like data fetching, and useReducer for more complex state β plus the rules that keep them working reliably.
π― Learning Objectives
By the end of this lesson, you will be able to:
- Add local state to a component with
useStateand update it correctly - Apply functional updates and understand why state updates are asynchronous and batched
- Run side effects β data fetching, subscriptions, timers β with
useEffectand its dependency array - Follow the Rules of Hooks and explain why they exist
- Manage related, complex state with
useReducer, and know when to choose it overuseState
Estimated Time: 40β50 minutes β’ Difficulty: Intermediate
Hands-on: Build a live search box that fetches results with useEffect and tracks loading state.
In This Lesson
Why Hooks?
A plain function component is stateless: it runs, returns JSX, and forgets everything. But real UIs need memory β a counter's value, a form's inputs, whether a menu is open. Hooks, introduced in React 16.8, are special functions that let a function component "hook into" React features like state and lifecycle.
Before Hooks, only class components could hold state, and reusing stateful logic between components was awkward. Hooks made function components fully capable and are now the recommended way to write React. You can always spot one: a Hook is a function whose name starts with use.
π‘ Analogy. A function component without Hooks is like a whiteboard wiped clean every time you look away. useState is a sticky note that survives the wipe β React keeps it for you between renders and hands it back each time the component runs.
π The built-in Hooks you'll use most
useState β hold a value that changes over time.
useEffect β synchronize with something outside React (fetch data, set a timer, subscribe).
useReducer β manage complex or interrelated state with a reducer function.
useContext β read shared, app-wide state (the focus of the next lesson).
useState: Local State
useState declares a piece of state. It returns an array with exactly two items β the current value and a function to update it β which you destructure:
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0); // 0 is the initial value
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>+1</button>
<button onClick={() => setCount(0)}>Reset</button>
</div>
);
}
Calling setCount does two things: it stores the new value, and it tells React to re-render the component. On the next render, useState(0) hands back the updated value, not the initial one β React remembers it.
State can hold any value β a number, string, boolean, array, or object:
const [name, setName] = useState('');
const [isOpen, setIsOpen] = useState(false);
const [items, setItems] = useState([]);
const [user, setUser] = useState({ name: '', email: '' });
Updating State Correctly
State updates have two behaviors that surprise newcomers. Understanding them prevents a whole class of bugs.
State is treated as immutable
Never mutate state directly β always pass a new value to the setter. React detects changes by comparing references, so mutating in place won't trigger a re-render:
// β Mutating β React won't notice, no re-render
items.push(newItem);
setItems(items);
// β
Create a new array
setItems([...items, newItem]);
// β
Updating an object β spread the old, override the field
setUser({ ...user, email: 'new@site.com' });
Updates are asynchronous and batched
Calling a setter does not change the variable immediately β React schedules the update and may batch several together. This code only increments once, because all three calls read the same stale count:
// β οΈ Reads stale value three times β +1, not +3
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
When the next value depends on the previous one, use a functional update β pass a function that receives the latest state:
// β
Each call gets the freshest value β +3
setCount((prev) => prev + 1);
setCount((prev) => prev + 1);
setCount((prev) => prev + 1);
β Rule of thumb
If your new state is computed from the old state, reach for the functional form setX(prev => β¦). It's always safe, even inside timers, event handlers, and effects where the captured value might be stale.
useEffect: Side Effects
Rendering should be pure β it just computes JSX from props and state. Anything that reaches outside React is a side effect: fetching data, setting a timer, subscribing to an event, or manually touching the DOM. useEffect is where those belong.
import { useState, useEffect } from 'react';
function Clock() {
const [time, setTime] = useState(new Date());
useEffect(() => {
const id = setInterval(() => setTime(new Date()), 1000);
// Cleanup: React runs this before re-running the effect and on unmount
return () => clearInterval(id);
}, []); // empty deps β run once after the first render
return <p>{time.toLocaleTimeString()}</p>;
}
The dependency array controls when it runs
| You write | Effect runs⦠|
|---|---|
useEffect(fn) β no array | After every render |
useEffect(fn, []) β empty | Once, after the first render |
useEffect(fn, [query]) | After the first render and whenever query changes |
Fetching data
A classic effect: load data when a value changes. Note the cleanup flag to avoid setting state after the component has moved on (a common source of race-condition warnings):
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let active = true;
setLoading(true);
async function load() {
const res = await fetch(`/api/users/${userId}`);
const data = await res.json();
if (active) {
setUser(data);
setLoading(false);
}
}
load();
return () => { active = false; }; // ignore a stale response
}, [userId]); // re-fetch whenever userId changes
if (loading) return <p>Loadingβ¦</p>;
return <h2>{user.name}</h2>;
}
β οΈ The dependency array is not optional
List every prop, state value, or variable the effect uses. Leaving one out causes stale data; adding an object or function that's recreated each render causes infinite loops. The ESLint rule react-hooks/exhaustive-deps catches most mistakes β don't silence it without understanding why. For data fetching in production apps, a library like TanStack Query or the framework's built-in loader often replaces hand-written fetch effects.
The Rules of Hooks
Hooks rely on being called in the same order on every render β that's how React matches each useState call to its stored value. Two rules keep that guarantee:
- Only call Hooks at the top level. Never inside loops, conditions, or nested functions. If a Hook is sometimes skipped, React loses track of which state is which.
- Only call Hooks from React functions. That means function components or your own custom Hooks β not regular JavaScript functions.
// β Conditional Hook β breaks the call order
function Bad({ show }) {
if (show) {
const [value, setValue] = useState(0); // β not top-level
}
}
// β
Hook at the top level; put the condition inside
function Good({ show }) {
const [value, setValue] = useState(0);
if (!show) return null;
return <p>{value}</p>;
}
π Custom Hooks
You can extract stateful logic into your own Hook β just a function starting with use that calls other Hooks. For example, useLocalStorage(key, initial) could wrap useState and useEffect to persist a value. Custom Hooks are how React teams share behavior without copy-pasting. You'll build them as you grow more comfortable.
useReducer: Complex State
When several pieces of state change together, or the next state depends on the previous in intricate ways, many useState calls get unwieldy. useReducer centralizes that logic in a single reducer function β the same idea Redux popularized, built right into React.
A reducer takes the current state and an action, and returns the next state. You trigger changes by dispatching actions:
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.value };
case 'reset':
return initialState;
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<div>
<p>Count: {state.count}</p>
<button onClick={() => dispatch({ type: 'increment' })}>+{state.step}</button>
<button onClick={() => dispatch({ type: 'decrement' })}>-{state.step}</button>
<button onClick={() => dispatch({ type: 'reset' })}>Reset</button>
</div>
);
}
useState or useReducer?
Choose useState when⦠| Choose useReducer when⦠|
|---|---|
| State is a single, independent value | Several values change together |
| Updates are simple (set, toggle) | Next state depends on complex logic |
| A toggle, an input, a flag | A form wizard, a cart, an editor |
Both are valid; useReducer shines when update logic is worth naming and testing in one place. It also pairs beautifully with Context to manage app-wide state, which is exactly where the next lesson heads.
Hands-on Exercise
ποΈ Build a Live Search Box
Objective: Combine useState and useEffect to fetch and display results as the user types.
Requirements:
- Track the search
queryin state, bound to a controlled<input>. - Track
resultsand aloadingflag in state. - In a
useEffectthat depends onquery, fetch matches and store them. Skip the fetch when the query is empty. - Show "Searchingβ¦" while loading and render the results as a list with keys.
- Bonus: debounce the query so you don't fetch on every keystroke.
π‘ Hint
Use the cleanup flag pattern from the data-fetching example so a slow response for an old query can't overwrite a newer one. For the debounce bonus, set a setTimeout inside the effect and clear it in the cleanup function.
β Sample solution
import { useState, useEffect } from 'react';
function LiveSearch() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (query.trim() === '') {
setResults([]);
return;
}
let active = true;
setLoading(true);
// Debounce: wait 300ms after the last keystroke
const timer = setTimeout(async () => {
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
const data = await res.json();
if (active) {
setResults(data);
setLoading(false);
}
}, 300);
return () => {
active = false;
clearTimeout(timer);
};
}, [query]);
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Searchβ¦"
/>
{loading && <p>Searchingβ¦</p>}
<ul>
{results.map((r) => (
<li key={r.id}>{r.title}</li>
))}
</ul>
</div>
);
}
Best Practices
β Do
- Treat state as immutable β always pass a new array/object to the setter.
- Use the functional update form when the next value depends on the previous.
- List all dependencies a
useEffectuses, and return a cleanup when you subscribe or start a timer. - Keep state minimal β derive values during render instead of storing them redundantly.
- Reach for
useReduceronce related state and update logic get tangled.
β οΈ Don't
- Don't call Hooks inside conditions, loops, or nested functions.
- Don't mutate state directly (
state.push,state.x = β¦). - Don't assume a setter updates the variable immediately β it schedules a re-render.
- Don't overuse
useEffectfor things you can compute during render; effects are for syncing with the outside world. - Don't store in state what you can derive from existing state or props.
Summary & Quiz
π Key Takeaways
- Hooks (functions starting with
use) give function components state and lifecycle. useStatereturns[value, setter]; updating triggers a re-render.- Treat state as immutable; use functional updates when the next value depends on the last.
useEffectruns side effects; its dependency array controls when, and cleanup tears them down.- Follow the Rules of Hooks; graduate to
useReducerfor complex, interrelated state.
π― Quick Quiz
Question 1: What does useState return?
Question 2: When should you pass a function to a state setter, e.g. setCount(prev => prev + 1)?
Question 3: An empty dependency array β useEffect(fn, []) β causes the effect to runβ¦
π Further Reading
- React docs β useState reference
- React docs β useEffect reference
- React docs β You Might Not Need an Effect
- React docs β useReducer reference
π What's Next?
Local state works well within a component, but passing it through many layers gets tedious. Next we'll solve app-wide sharing with the Context API and global state β including how it pairs with useReducer.
π You made it interactive!
State and effects are the beating heart of every React app. Everything from here builds on them.