React Hooks Basics: useState and useEffect for State Management
What are hooks?
Hooks are JavaScript functions introduced in React 16.8 that provide capabilities previously available only in class components. The two most-used hooks are useState for tracking state and useEffect for running side effects. With hooks, you can add state, side effects, and lifecycle behaviour directly inside function components, with no class required.
Before hooks, a component that needed to track a counter value and respond to user input looked very different. You had to write a class, define a constructor, bind methods, and manage this. Hooks collapse all that boilerplate into a few lines of code.
import { useState, useEffect } from 'react';
function Greeting() {
const [name, setName] = useState('World');
return <h1>Hello, {name}!</h1>;
}
// name === 'World'
This tutorial walks through the two most important hooks, useState and useEffect, with plenty of runnable examples. By the end you’ll know how to declare state, run side effects at the right time, and clean up after yourself to prevent memory leaks.
Rules of Hooks
Hooks are regular JavaScript functions, but React imposes two hard rules around how you call them:
1. Only call hooks at the top level. Never call useState or useEffect inside loops, conditions, or nested functions. React relies on call order to associate each hook with the correct component instance. If the order changes between renders, React loses track of state and the component behaves unpredictably.
This is invalid:
function Broken() {
if (condition) {
const [x, setX] = useState(0); // Wrong — inside a condition
}
return null;
}
Always call hooks at the top level of your component function, before any early returns.
2. Only call hooks from React functions. Call useState and useEffect from function components or custom hooks — never from regular JavaScript functions, event handlers, or class components.
// Correct — called from a function component
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
// count === 0
Calling hooks from regular JavaScript functions breaks React’s internal tracking. Hooks must live inside the component function body, where React can associate call state with the correct fiber node during reconciliation.
// Wrong — calling a hook from an event handler
function handleClick() {
const [x, setX] = useState(0); // This breaks React's rules
}
A third practical rule: name your custom hooks with the use prefix. This enables React’s linting rules and makes it clear the function follows hook rules.
useState: declaring and updating state
useState declares a piece of state in your component. It takes one argument: the initial state value. It returns an array with exactly two elements — the current state value and a setter function.
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
// count === 0
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
The array destructuring is just a convention — useState returns an array so you can name the two elements whatever suits your component. The first element is always the current state, and the second is always a function that updates the state.
Updating State
Pass the new value directly to the setter:
const [name, setName] = useState('');
setName('Alice');
// name === 'Alice'
setName('Bob');
// name === 'Bob'
Functional Updates
Direct value assignment works when the new state is independent of the old value, as in setName('Alice'). When the next state depends on the previous state, however, this approach introduces subtle race conditions. Always use the functional form of the setter instead:
const [count, setCount] = useState(0);
// Functional update — safe when next state depends on previous
setCount(prev => prev + 1);
// count === 1
setCount(prev => prev + 1);
// count === 2
Why does this matter? React schedules state updates. If you call setCount(count + 1) rapidly, the value of count inside the callback might be stale. The functional form always receives the most recent state value, so you avoid closure bugs.
Object and array state
State can hold any JavaScript value: strings, numbers, booleans, objects, and arrays.
const [user, setUser] = useState({ name: '', age: 0 });
// Replace the whole object
setUser({ name: 'Alice', age: 30 });
// user.name === 'Alice'
// Partial update — always spread the previous object
setUser(prev => ({ ...prev, age: 31 }));
// user.age === 31
Working with arrays follows the same rule: never mutate in place. Use array spreading to add items and filter to remove them. React’s shallow comparison detects these new references and triggers a re-render. For deeply nested objects, consider useReducer or a library like Immer to avoid cascading spread operations that make updates harder to read.
const [items, setItems] = useState([]);
// Add an item — create a new array
setItems(prev => [...prev, 'new item']);
// items === ['new item']
// Remove an item — filter returns a new array
setItems(prev => prev.filter(item => item !== 'old item'));
// items === ['new item']
React state should be treated as immutable. Never mutate user.name directly — always call the setter with a new object or array. This is how React detects when to re-render.
useEffect: side effects with the dependency array
useEffect runs a side effect after your component renders. A side effect is anything that reaches outside the component: fetching data from an API, attaching an event listener, starting a timer, or updating the document title.
import { useEffect } from 'react';
function MyComponent() {
useEffect(() => {
// This runs after every render by default
document.title = 'Hello from useEffect';
});
return <div>Hello</div>;
}
Without a dependency array, this effect runs after every render, which can trigger performance problems if the effect does expensive work like API calls. The second argument, the dependency array, tells React which values the effect depends on. When those values change between renders, React re-runs the effect. When the array is empty, the effect runs once on mount:
// Runs once on mount — equivalent to componentDidMount in a class
useEffect(() => {
console.log('Component mounted');
}, []);
// Runs whenever `userId` changes between renders
useEffect(() => {
console.log('userId changed:', userId);
}, [userId]);
// Runs after every render — no dependency array at all
useEffect(() => {
console.log('Something rendered');
});
Think of the dependency array as a list of “reasons to re-run this effect.” If none of the values in the array changed since last render, React skips the effect entirely.
A common mistake is to leave a dependency out of the array. React’s lint rules catch this, but the underlying issue is that a stale closure captures an old value of a variable. If your effect reads count without listing it as a dependency, it will always see the value from the render when the effect was first attached.
Cleanup functions in useEffect
Many effects need to clean up after themselves — stopping timers, removing event listeners, or cancelling network requests. Return a function from your effect to handle this:
import { useState, useEffect } from 'react';
function Stopwatch() {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setSeconds(prev => prev + 1);
}, 1000);
// Cleanup: runs when the component unmounts
// or before the effect re-runs
return () => clearInterval(id);
}, []); // Empty array — this effect runs once on mount
return <p>Elapsed: {seconds}s</p>;
}
// seconds === 0
The cleanup function runs in two situations:
- Before the component unmounts (to prevent memory leaks)
- Before the effect re-executes when its dependencies change (to reset before re-running)
Skipping cleanup is one of the most common sources of bugs in React applications. A timer started in an effect without cleanup keeps running even after the component disappears, causing stale updates and performance problems.
Common Patterns
Data Fetching
Fetching data is one of the most frequent uses of useEffect. Always include a cleanup flag to avoid setting state on an unmounted component:
import { useState, useEffect } from 'react';
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
let cancelled = false;
fetch(`https://api.example.com/users/${userId}`)
.then(res => res.json())
.then(data => {
if (!cancelled) setUser(data);
});
return () => { cancelled = true; };
}, [userId]);
if (!user) return <p>Loading...</p>;
return <h1>{user.name}</h1>;
}
// user === null
The cancelled flag is a standard pattern for avoiding the “setState on unmounted component” warning in React. Without it, a slow network response arriving after the user navigated away would trigger a state update on a destroyed component. Modern React with strict mode surfaces this issue during development.
Event listeners
Attach listeners inside useEffect and clean them up when done:
import { useState, useEffect } from 'react';
function WindowSize() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
function handleResize() {
setWidth(window.innerWidth);
}
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return <p>Window width: {width}px</p>;
}
// width === 1024
This example tracks the browser window width, but the same pattern works for any DOM event: scroll, keydown, focus, or custom events. The key is that addEventListener in the effect must have a matching removeEventListener in the cleanup.
Timers
Timers started in effects must be cleared on cleanup:
import { useState, useEffect } from 'react';
function DelayCounter() {
const [value, setValue] = useState(0);
useEffect(() => {
const id = setTimeout(() => {
setValue(v => v + 1);
}, 1000);
return () => clearTimeout(id);
}, [value]);
return <p>Value: {value} (auto-increments every second)</p>;
}
// value === 0
Using setTimeout with value in the dependency array creates a self-rescheduling timer: each time the effect fires, it schedules the next increment. The cleanup cancels the old timeout before the new one starts, so you never get overlapping timers even if the effect re-runs quickly.
Subscriptions
Online status, WebSocket connections, and similar subscriptions follow the same pattern:
import { useState, useEffect } from 'react';
function OnlineStatus() {
const [isOnline, setIsOnline] = useState(navigator.onLine);
useEffect(() => {
function handleOnline() { setIsOnline(true); }
function handleOffline() { setIsOnline(false); }
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
return <p>{isOnline ? 'Online' : 'Offline'}</p>;
}
// isOnline === true
Form Inputs with useState
Tracking user input is one of the most common uses of useState. The pattern is called a controlled component: React owns the input value, and every keystroke updates state through the onChange handler. This gives you full control over validation, formatting, and when to propagate changes:
import { useState } from 'react';
function SearchBox() {
const [query, setQuery] = useState('');
return (
<div>
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search..."
/>
<p>You typed: {query}</p>
</div>
);
}
// query === ''
Toggle State
Boolean toggles are the simplest form of state and a pattern you will use in almost every React application. The functional update prev => !prev is safer than setOn(!on) because it always toggles from the latest state, even if React batches multiple rapid clicks into a single render cycle:
import { useState } from 'react';
function Toggle() {
const [on, setOn] = useState(false);
return (
<button onClick={() => setOn(prev => !prev)}>
{on ? 'ON' : 'OFF'}
</button>
);
}
// on === false
useState vs useEffect: when to use which
The distinction matters, and choosing the wrong hook leads to subtle bugs:
| Situation | Hook to use |
|---|---|
| Track a value that changes over time (counter, input, toggle) | useState |
| Run code as a side effect (fetch, timer, event listener) | useEffect |
| Next state depends on the previous state | Functional update setState(prev => ...) |
| React to a change in props or state | useEffect with dependency array |
A good mental model: if the result needs to appear in the render, it belongs in useState. If you need to synchronise with something outside React — the network, the DOM, a timer — it belongs in useEffect.
You will sometimes see code that uses useEffect to update state that could be computed during render. This creates an extra render cycle and is usually unnecessary. Ask yourself: can this value be derived from existing state or props? If yes, you probably don’t need a separate state variable.
Summary
Hooks let function components own state and side effects: useState holds data while useEffect performs actions. The two work together: useEffect can read state and props, and trigger updates via setters, while useState initialises values that useEffect reacts to.
The key rules to remember: call hooks only at the top level of your component and only from function components or custom hooks. Always include cleanup for timers, listeners, and subscriptions to prevent memory leaks and stale updates.
Once you internalise these patterns, you’ll find that most React components can be expressed as a few useState declarations, one or two useEffect calls with proper cleanup, and a render function that turns state into UI.
Next steps
Once useState and useEffect feel natural, the next hooks to learn are useContext for sharing state across the component tree without prop drilling, and useRef for holding mutable values that persist across renders without triggering re-renders. Custom hooks let you extract reusable logic from your components, making your codebase easier to test and maintain.
See Also
- React: Intro and Setup — get React running in your project
- React: Components and JSX — understand the component model before adding hooks
- React: Event Handling — handle user interactions in function components