React Hooks Explained — useState, useEffect & Beyond
A practical deep-dive into React Hooks. Learn how useState, useEffect, useRef, useMemo, useCallback, and custom hooks work — with real examples you can use today.
Before Hooks, managing state and side effects in React meant writing class components — full of boilerplate, lifecycle methods, and this keyword confusion. In 2019, React 16.8 changed everything.
Hooks let you use state, side effects, and other React features inside simple function components. No classes. No this. No confusion.
In this guide, we'll cover every essential Hook with real-world examples — and build a custom Hook from scratch by the end.
1. What Are Hooks?
Hooks are plain JavaScript functions that start with use. They let you "hook into" React features from function components.
The Golden Rules of Hooks
There are only two rules — and they are non-negotiable:
- Only call Hooks at the top level — never inside loops, conditions, or nested functions
- Only call Hooks from React function components (or custom Hooks)
Break either rule and React's internal state tracking breaks.
// ❌ Wrong — Hook inside a condition
function MyComponent({ isLoggedIn }) {
if (isLoggedIn) {
const [name, setName] = useState(""); // React will break
}
}
// ✅ Correct — Hook always at the top level
function MyComponent({ isLoggedIn }) {
const [name, setName] = useState("");
if (!isLoggedIn) return null;
}
2. useState — Managing Local State
useState is the most fundamental Hook. It adds a piece of state to your component and gives you a function to update it.
const [state, setState] = useState(initialValue);
Basic Example
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>
);
}
Updating State Based on Previous Value
When your new state depends on the old state, always use the functional update form:
// ❌ Unreliable — may use stale value
setCount(count + 1);
// ✅ Reliable — always uses the latest state
setCount(prev => prev + 1);
State with Objects
function UserForm() {
const [user, setUser] = useState({ name: "", email: "" });
const handleChange = (field, value) => {
// Spread existing state — only update the changed field
setUser(prev => ({ ...prev, [field]: value }));
};
return (
<div>
<input
value={user.name}
onChange={e => handleChange("name", e.target.value)}
placeholder="Name"
/>
<input
value={user.email}
onChange={e => handleChange("email", e.target.value)}
placeholder="Email"
/>
<p>{user.name} — {user.email}</p>
</div>
);
}
Common Mistake:
setStatereplaces state entirely — it does not merge likethis.setStatein class components. Always spread the previous state when updating objects.
3. useEffect — Handling Side Effects
useEffect runs code after the component renders. It's the right place for side effects — data fetching, subscriptions, manually updating the DOM, and timers.
useEffect(() => {
// your side effect here
return () => {
// optional cleanup (runs before next effect or on unmount)
};
}, [dependencies]);
The Dependency Array — The Key to useEffect
| Dependency Array | When the Effect Runs |
|---|---|
| Not provided | After every render |
[] (empty) |
Once — after the first render only |
[a, b] |
After first render + whenever a or b changes |
Example: Fetching Data
import { useState, useEffect } from "react";
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false; // prevents state update on unmounted component
async function fetchUser() {
try {
setLoading(true);
const res = await fetch(`/api/users/${userId}`);
if (!res.ok) throw new Error("Failed to fetch");
const data = await res.json();
if (!cancelled) setUser(data);
} catch (err) {
if (!cancelled) setError(err.message);
} finally {
if (!cancelled) setLoading(false);
}
}
fetchUser();
return () => { cancelled = true; }; // cleanup on unmount
}, [userId]); // re-fetch whenever userId changes
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
return <h2>{user?.name}</h2>;
}
Example: Event Listeners & Cleanup
function WindowSize() {
const [size, setSize] = useState({
width: window.innerWidth,
height: window.innerHeight
});
useEffect(() => {
const handleResize = () => {
setSize({ width: window.innerWidth, height: window.innerHeight });
};
window.addEventListener("resize", handleResize);
// Cleanup — remove listener when component unmounts
return () => window.removeEventListener("resize", handleResize);
}, []); // empty array — set up once
return <p>{size.width} x {size.height}</p>;
}
Always clean up. Forgetting to remove event listeners or cancel subscriptions is one of the most common causes of memory leaks in React apps.
4. useRef — Values That Don't Trigger Re-renders
useRef gives you a mutable container that persists across renders — without causing a re-render when it changes. It has two main uses.
Use 1: Accessing DOM Elements Directly
import { useRef } from "react";
function AutoFocusInput() {
const inputRef = useRef(null);
useEffect(() => {
inputRef.current.focus(); // directly access the DOM node
}, []);
return <input ref={inputRef} placeholder="I auto-focus on mount" />;
}
Use 2: Storing Values Without Re-renders
function Timer() {
const [seconds, setSeconds] = useState(0);
const intervalRef = useRef(null); // store interval ID — no re-render needed
const start = () => {
intervalRef.current = setInterval(() => {
setSeconds(prev => prev + 1);
}, 1000);
};
const stop = () => {
clearInterval(intervalRef.current);
};
return (
<div>
<p>{seconds}s</p>
<button onClick={start}>Start</button>
<button onClick={stop}>Stop</button>
</div>
);
}
useState |
useRef |
|---|---|
| Causes re-render when updated | No re-render when updated |
| Use for UI-visible data | Use for internal values, timers, DOM refs |
5. useMemo — Caching Expensive Calculations
useMemo memoizes the result of an expensive computation — it only recalculates when its dependencies change.
const memoizedValue = useMemo(() => expensiveCalculation(a, b), [a, b]);
import { useState, useMemo } from "react";
function ProductList({ products, searchTerm }) {
// This filter only re-runs when products or searchTerm changes
const filteredProducts = useMemo(() => {
console.log("Filtering..."); // won't spam on every render
return products.filter(p =>
p.name.toLowerCase().includes(searchTerm.toLowerCase())
);
}, [products, searchTerm]);
return (
<ul>
{filteredProducts.map(p => <li key={p.id}>{p.name}</li>)}
</ul>
);
}
Don't over-use useMemo. Memoization itself has a cost. Only use it when a calculation is genuinely slow or when the result is passed as a prop to a memoized child component.
6. useCallback — Caching Functions
useCallback memoizes a function definition — it returns the same function instance across renders unless its dependencies change.
const memoizedFn = useCallback(() => doSomething(a, b), [a, b]);
import { useState, useCallback, memo } from "react";
// Child wrapped in memo — only re-renders if props change
const SearchBar = memo(({ onSearch }) => {
console.log("SearchBar rendered");
return <input onChange={e => onSearch(e.target.value)} />;
});
function App() {
const [query, setQuery] = useState("");
const [theme, setTheme] = useState("light");
// Without useCallback, this creates a NEW function on every render
// — causing SearchBar to re-render even when only theme changed
const handleSearch = useCallback((value) => {
setQuery(value);
}, []); // stable reference — no dependencies
return (
<div>
<SearchBar onSearch={handleSearch} />
<button onClick={() => setTheme(t => t === "light" ? "dark" : "light")}>
Toggle Theme ({theme})
</button>
<p>Query: {query}</p>
</div>
);
}
7. useContext — Sharing State Without Prop Drilling
useContext lets you read a value from a Context — avoiding the need to pass props through every level of the component tree.
import { createContext, useContext, useState } from "react";
// 1. Create the context
const ThemeContext = createContext("light");
// 2. Provide it at the top level
function App() {
const [theme, setTheme] = useState("light");
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
<Navbar />
<MainContent />
</ThemeContext.Provider>
);
}
// 3. Consume it anywhere in the tree — no props needed
function Navbar() {
const { theme, setTheme } = useContext(ThemeContext);
return (
<nav style={{ background: theme === "dark" ? "#111" : "#fff" }}>
<button onClick={() => setTheme(t => t === "light" ? "dark" : "light")}>
Toggle Theme
</button>
</nav>
);
}
Context is not a state management library. It's great for low-frequency updates like theme or auth status. For complex, high-frequency state, consider Zustand or Redux Toolkit instead.
8. Custom Hooks — Reusable Logic
Custom Hooks let you extract and reuse stateful logic across multiple components. Any function that starts with use and calls other Hooks is a custom Hook.
Example: useFetch
// hooks/useFetch.js
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;
async function fetchData() {
try {
setLoading(true);
setError(null);
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
if (!cancelled) setData(json);
} catch (err) {
if (!cancelled) setError(err.message);
} finally {
if (!cancelled) setLoading(false);
}
}
fetchData();
return () => { cancelled = true; };
}, [url]);
return { data, loading, error };
}
export default useFetch;
// Use it in any component — clean and reusable
function Posts() {
const { data, loading, error } = useFetch("/api/posts");
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
return (
<ul>
{data.map(post => <li key={post.id}>{post.title}</li>)}
</ul>
);
}
Example: useLocalStorage
// hooks/useLocalStorage.js
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 {
setStoredValue(value);
window.localStorage.setItem(key, JSON.stringify(value));
} catch (err) {
console.error(err);
}
};
return [storedValue, setValue];
}
export default useLocalStorage;
// Works exactly like useState — but persists across page refreshes
function Settings() {
const [theme, setTheme] = useLocalStorage("theme", "light");
return (
<button onClick={() => setTheme(t => t === "light" ? "dark" : "light")}>
Current Theme: {theme}
</button>
);
}
9. Common Mistakes to Avoid
Mistake 1: Missing Dependencies in useEffect
// ❌ userId is used but not listed — stale closure bug
useEffect(() => {
fetchUser(userId);
}, []);
// ✅ List every value used inside the effect
useEffect(() => {
fetchUser(userId);
}, [userId]);
Mistake 2: Creating Objects/Arrays Inline as Dependencies
// ❌ New object created on every render — infinite loop
useEffect(() => {
fetchData(options);
}, [{ page: 1, limit: 10 }]); // new reference every render!
// ✅ Stable primitive dependencies
const page = 1;
const limit = 10;
useEffect(() => {
fetchData({ page, limit });
}, [page, limit]);
Mistake 3: Overusing useEffect for Derived State
// ❌ Unnecessary effect — derived state shouldn't live in useEffect
const [firstName, setFirstName] = useState("Ankur");
const [lastName, setLastName] = useState("Goswami");
const [fullName, setFullName] = useState("");
useEffect(() => {
setFullName(`${firstName} ${lastName}`); // extra render, not needed
}, [firstName, lastName]);
// ✅ Just compute it during render — no effect needed
const fullName = `${firstName} ${lastName}`;
10. Quick Reference
| Hook | Purpose |
|---|---|
useState |
Local component state |
useEffect |
Side effects — fetch, subscriptions, timers |
useRef |
DOM access or mutable value without re-render |
useMemo |
Cache expensive calculated values |
useCallback |
Cache function references for stable props |
useContext |
Read from React Context — avoid prop drilling |
| Custom Hook | Extract & reuse stateful logic across components |
Hooks fundamentally changed how we write React. Once you internalize these patterns — especially building your own custom Hooks — you'll find yourself writing cleaner, more reusable, and far more maintainable components.
The best way to learn them is to build: take one component from your current project and refactor it using the patterns from this guide. 🚀
Up next: React Performance Optimization — memo, lazy, Suspense & Profiler.