React Hooks Explained: useState, useEffect, and Beyond
A practical guide to React Hooks for beginners. Learn useState, useEffect, useRef, and custom hooks with real-world examples that you can use in your projects today.
Introduction
React Hooks fundamentally changed how developers build React applications. Introduced in React 16.8, Hooks let you use state, side effects, and other React features inside functional components — no class components required.
Before Hooks, managing state meant writing verbose class components with constructor functions, this bindings, and lifecycle methods scattered across your code. Hooks replaced all of that with a cleaner, more composable API that keeps related logic together.
In this guide, you will learn the most important React Hooks through practical examples you can start using in your projects right away. We will cover useState, useEffect, useRef, custom hooks, the rules you must follow, and common mistakes that trip up beginners.
Why Hooks Replaced Class Components
Class components worked, but they came with real problems:
- Scattered logic. Related code ended up split across
componentDidMount,componentDidUpdate, andcomponentWillUnmount, making it hard to follow. - Confusing
thiskeyword. Forgetting to bind event handlers was one of the most common sources of bugs. - Difficult reuse. Sharing stateful logic between components required patterns like higher-order components or render props, which led to deeply nested "wrapper hell."
Hooks solve all three problems. Related logic stays in a single hook call. There is no this to worry about. And custom hooks let you extract and share stateful logic with a simple function call.
useState: Managing State in Functional Components
useState is the most fundamental hook. It lets you declare a state variable and a function to update it.
Basic Counter
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>
);
}
useState(0) returns an array with two elements: the current value (count) and a setter function (setCount). The argument 0 is the initial value. Every time you call setCount, React re-renders the component with the new value.
Form Inputs
Handling form inputs is one of the most common uses of useState. Each input gets its own state variable, or you can group related fields into an object.
function SignupForm() {
const [formData, setFormData] = useState({
name: '',
email: '',
password: '',
});
const handleChange = (e) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
};
const handleSubmit = (e) => {
e.preventDefault();
console.log('Submitted:', formData);
};
return (
<form onSubmit={handleSubmit}>
<input name="name" value={formData.name} onChange={handleChange} placeholder="Name" />
<input name="email" value={formData.email} onChange={handleChange} placeholder="Email" />
<input name="password" type="password" value={formData.password} onChange={handleChange} placeholder="Password" />
<button type="submit">Sign Up</button>
</form>
);
}
Notice the updater function pattern: setFormData((prev) => ({ ...prev, [name]: value })). When your new state depends on the previous state, always use the callback form. This avoids stale state bugs, especially when multiple updates happen in quick succession.
Toggle Pattern
Toggles appear everywhere — modals, dropdowns, dark mode switches.
function TogglePanel() {
const [isOpen, setIsOpen] = useState(false);
return (
<div>
<button onClick={() => setIsOpen((prev) => !prev)}>
{isOpen ? 'Hide' : 'Show'} Details
</button>
{isOpen && <p>Here are the details you wanted to see.</p>}
</div>
);
}
useEffect: Handling Side Effects
Side effects are anything that reaches outside the React rendering cycle: fetching data, setting up subscriptions, manually changing the DOM, or starting timers. useEffect is where all of that belongs.
Basic Data Fetching
import { useState, useEffect } from 'react';
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const fetchUser = async () => {
try {
setLoading(true);
setError(null);
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) throw new Error('Failed to fetch user');
const data = await response.json();
setUser(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchUser();
}, [userId]);
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
return <h1>{user.name}</h1>;
}
The Dependency Array
The second argument to useEffect is the dependency array. It controls when the effect runs:
- No array (
useEffect(() => {...})) — runs after every render. Rarely what you want. - Empty array (
useEffect(() => {...}, [])) — runs once after the initial render, similar tocomponentDidMount. - With dependencies (
useEffect(() => {...}, [userId])) — runs after the initial render and again whenever any dependency changes.
Get the dependency array wrong and you will either have effects that never re-run when they should, or effects that run in an infinite loop. A good rule: include every variable from the component scope that the effect reads.
Cleanup Functions
When your effect sets up something that persists — a subscription, a timer, an event listener — you need to clean it up. Return a function from useEffect and React will call it before the component unmounts and before the effect re-runs.
function WindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', handleResize);
// Cleanup: remove the listener when the component unmounts
return () => window.removeEventListener('resize', handleResize);
}, []);
return <p>Window width: {width}px</p>;
}
Without the cleanup function, every re-mount would add another event listener, leading to memory leaks and duplicate handler calls.
useRef: DOM References and Persistent Values
useRef serves two purposes: accessing DOM elements directly, and storing mutable values that persist across renders without triggering re-renders.
Focusing an Input
import { useRef, useEffect } from 'react';
function SearchBar() {
const inputRef = useRef(null);
useEffect(() => {
inputRef.current.focus();
}, []);
return <input ref={inputRef} type="text" placeholder="Search..." />;
}
Storing Previous Values
Because updating a ref does not cause a re-render, it is perfect for tracking values between renders.
function PreviousValue({ count }) {
const prevCountRef = useRef();
useEffect(() => {
prevCountRef.current = count;
});
return (
<p>
Current: {count}, Previous: {prevCountRef.current}
</p>
);
}
Tracking Mounted State
A common pattern is using a ref to prevent state updates on unmounted components, especially with async operations.
function AsyncComponent() {
const [data, setData] = useState(null);
const isMounted = useRef(true);
useEffect(() => {
fetch('/api/data')
.then((res) => res.json())
.then((result) => {
if (isMounted.current) {
setData(result);
}
});
return () => {
isMounted.current = false;
};
}, []);
return <div>{data ? JSON.stringify(data) : 'Loading...'}</div>;
}
Custom Hooks: Reusable Stateful Logic
Custom hooks are regular JavaScript functions that use other hooks. They let you extract component logic into reusable pieces. By convention, their names start with use.
Building a useFetch Hook
This is one of the most practical custom hooks you can build. It encapsulates data fetching with loading and error states.
import { useState, useEffect } from 'react';
function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
const fetchData = async () => {
try {
setLoading(true);
setError(null);
const response = await fetch(url, { signal: controller.signal });
if (!response.ok) throw new Error(`HTTP error: ${response.status}`);
const result = await response.json();
setData(result);
} catch (err) {
if (err.name !== 'AbortError') {
setError(err.message);
}
} finally {
setLoading(false);
}
};
fetchData();
return () => controller.abort();
}, [url]);
return { data, loading, error };
}
Now any component can fetch data in one line:
function UserList() {
const { data: users, loading, error } = useFetch('/api/users');
if (loading) return <p>Loading users...</p>;
if (error) return <p>Error: {error}</p>;
return (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
Notice how useFetch uses AbortController to cancel in-flight requests when the URL changes or the component unmounts. This prevents race conditions and state updates on unmounted components.
Rules of Hooks
React enforces two rules that you must never break:
-
Only call hooks at the top level. Never call hooks inside loops, conditions, or nested functions. React relies on the order of hook calls being the same on every render. If you wrap a hook in an
ifstatement, the order can change, and React will associate state with the wrong hook. -
Only call hooks from React functions. Use hooks in functional components or in custom hooks. Never call them from regular JavaScript functions.
// Wrong — hook inside a condition
function BadComponent({ isLoggedIn }) {
if (isLoggedIn) {
const [name, setName] = useState(''); // This breaks the rules
}
}
// Right — condition inside the hook's usage
function GoodComponent({ isLoggedIn }) {
const [name, setName] = useState('');
return isLoggedIn ? <p>Hello, {name}</p> : <p>Please log in</p>;
}
Install the eslint-plugin-react-hooks ESLint plugin. It catches rule violations automatically and will save you from subtle bugs.
Common Mistakes and How to Avoid Them
Mistake 1: Missing Dependencies in useEffect
// Bug: effect uses `count` but does not list it as a dependency
useEffect(() => {
const interval = setInterval(() => {
console.log(count); // Always logs the initial value
}, 1000);
return () => clearInterval(interval);
}, []); // Missing `count`
Fix: Add count to the dependency array, or use the updater form of setState if you are setting state based on the previous value.
Mistake 2: Creating Infinite Loops
// Bug: setting state inside useEffect without proper dependencies
const [data, setData] = useState([]);
useEffect(() => {
setData([...data, 'new item']); // `data` changes, effect re-runs, infinite loop
}, [data]);
Fix: Use the functional updater: setData((prev) => [...prev, 'new item']) and remove data from the dependency array. Or reconsider whether the effect is the right place for this logic.
Mistake 3: Directly Mutating State
// Bug: mutating the existing array instead of creating a new one
const [items, setItems] = useState(['apple', 'banana']);
const addItem = () => {
items.push('cherry'); // Direct mutation — React will not re-render
setItems(items);
};
// Fix: create a new array
const addItem = () => {
setItems((prev) => [...prev, 'cherry']);
};
React uses reference equality to detect changes. If you mutate an object or array in place and pass the same reference to the setter, React sees no change and skips the re-render.
Real-World Patterns
Loading States with Skeleton UI
function ProductPage({ productId }) {
const { data: product, loading, error } = useFetch(`/api/products/${productId}`);
if (error) return <ErrorBanner message={error} />;
return (
<div>
{loading ? (
<div className="animate-pulse">
<div className="h-8 bg-gray-200 rounded w-3/4 mb-4" />
<div className="h-4 bg-gray-200 rounded w-1/2" />
</div>
) : (
<>
<h1>{product.name}</h1>
<p>{product.description}</p>
<span>${product.price}</span>
</>
)}
</div>
);
}
Debounced Search Input
function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
function SearchPage() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 300);
const { data: results, loading } = useFetch(
debouncedQuery ? `/api/search?q=${debouncedQuery}` : null
);
return (
<div>
<input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search..." />
{loading && <p>Searching...</p>}
{results && results.map((item) => <p key={item.id}>{item.title}</p>)}
</div>
);
}
This pattern prevents firing an API request on every keystroke. The useDebounce hook waits until the user stops typing for 300 milliseconds before updating the debounced value, which then triggers the fetch.
Wrapping Up
React Hooks give you a powerful, composable toolkit for building modern UIs. To recap what we covered:
- useState manages component state with a simple array destructure.
- useEffect handles side effects like data fetching, subscriptions, and timers, with cleanup functions to prevent memory leaks.
- useRef gives you direct DOM access and a way to persist values without triggering re-renders.
- Custom hooks let you extract and reuse stateful logic across components.
- The two rules — call hooks at the top level, and only from React functions — keep everything working correctly.
The best way to internalize these patterns is to build something real. Start by converting a class component to hooks, or build a small app that fetches data from an API and handles loading and error states.
If you want a structured path to mastering React and modern JavaScript, check out the courses at Mctaba Academy. Our project-based curriculum takes you from fundamentals through to production-ready applications, with hands-on exercises that reinforce exactly the kind of patterns covered in this guide. Start building with confidence today.
Bonaventure Ogeto
Founder, Mctaba Labs
Software engineer building products for the African market. Teaching 10,000+ students across multiple platforms. BSc Mathematics & Computer Science from JKUAT.