React Hooks Masterclass: useState, useEffect, and Custom Hooks
How does React remember information between renders? The answer is hooks — and understanding them deeply changes how you think about building components, not just how you write them.
Table of Contents
1. Why React Hooks Were Introduced
Before hooks, React had two kinds of components: class components (which could have state and lifecycle methods) and function components (which were just for display — no state, no side effects).
If a component needed to track state or fetch data, it had to be a class:
// Pre-hooks — a class component to fetch and display a user
class UserProfile extends React.Component {
constructor(props) {
super(props);
this.state = { user: null, loading: true };
}
componentDidMount() {
fetch(`/api/users/${this.props.userId}`)
.then(res => res.json())
.then(user => this.setState({ user, loading: false }));
}
componentDidUpdate(prevProps) {
if (prevProps.userId !== this.props.userId) {
this.setState({ loading: true });
fetch(`/api/users/${this.props.userId}`)
.then(res => res.json())
.then(user => this.setState({ user, loading: false }));
}
}
render() {
if (this.state.loading) return <p>Loading...</p>;
return <h1>{this.state.user.name}</h1>;
}
}
This has three problems. The data-fetching logic is split across componentDidMount and componentDidUpdate — two lifecycle methods — even though it's the same operation. It can't be extracted and reused in another component without copying the class. And the this keyword creates confusion that even experienced developers stumble on.
Hooks were introduced in React 16.8 (2019) to solve these three problems simultaneously:
Related logic stays together, not split across lifecycle methods
Stateful logic can be extracted into reusable functions (custom hooks)
Function components can do everything class components could — no
thisrequired
// With hooks — the same component, dramatically simpler
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(user => {
setUser(user);
setLoading(false);
});
}, [userId]);
if (loading) return <p>Loading...</p>;
return <h1>{user.name}</h1>;
}
The fetch logic is in one place, not split across two lifecycle methods. If this logic needs to be reused, it can be extracted into a useUser custom hook. And there's no class, no constructor, no this.
Hooks are functions that start with use. They let function components opt into React features — state, side effects, context — that previously required class components.
2. Understanding useState
Every render of a React component is a fresh function call. Variables declared inside the component function are created anew on each render. So how does a component remember what the user typed, how many items are in the cart, or whether a modal is open?
It remembers because React stores state outside the component function, in a stable location that persists between renders. useState is the hook that connects your component to that storage.
import { useState } from "react";
function Counter() {
const [count, setCount] = useState(0);
// ↑ ↑ ↑
// current value setter fn initial value (used only on first render)
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>+</button>
<button onClick={() => setCount(count - 1)}>-</button>
</div>
);
}
useState(0) returns an array of two items — the current value and a function to update it. Destructuring gives them names. The names are yours to choose; React doesn't care what you call them as long as they're destructured in the right order.
What happens when you call the setter
User clicks + button
│
▼
setCount(count + 1) is called
│
▼
React queues a state update: "count should be 1"
│
▼
React re-renders the Counter component
│
▼
useState(0) is called again — but React ignores the 0 (already initialised)
React returns the new stored value: 1
│
▼
count is now 1 inside this render
UI reflects: "Count: 1"
The initial value — the 0 in useState(0) — is used exactly once: on the very first render. On every subsequent render, React returns the stored value, not the initial value.
Multiple state variables
One component can have as many useState calls as it needs. Each one is independent — updating one doesn't affect the others.
function ProfileEditor() {
const [name, setName] = useState("Priya");
const [bio, setBio] = useState("");
const [isEditing, setIsEditing] = useState(false);
const [saved, setSaved] = useState(false);
function handleSave() {
// save to API...
setSaved(true);
setIsEditing(false);
setTimeout(() => setSaved(false), 3000);
}
return (
<div>
{isEditing ? (
<>
<input value={name} onChange={e => setName(e.target.value)} />
<textarea value={bio} onChange={e => setBio(e.target.value)} />
<button onClick={handleSave}>Save</button>
</>
) : (
<>
<h2>{name}</h2>
<p>{bio}</p>
<button onClick={() => setIsEditing(true)}>Edit</button>
</>
)}
{saved && <p className="success">Saved!</p>}
</div>
);
}
Four separate pieces of state, each independent. isEditing controls which view is shown. name and bio hold the form values. saved shows the success message temporarily.
Updating state based on previous state
When the new state depends on the old state, use the functional form of the setter. This guarantees you're working with the most recent value, even if multiple updates are batched together.
// Potentially stale — uses count from the current render's closure
setCount(count + 1);
// Safe — React passes the latest state value to the function
setCount(prevCount => prevCount + 1);
The functional form matters most when you call the setter multiple times in rapid succession, or when the setter is called inside an event handler or async function where the count variable might be stale.
Grouping related state
When multiple state values always update together, consider grouping them into one object. This reduces the number of individual setter calls and makes the relationship explicit.
// Three separate state variables — always updated together
const [lat, setLat] = useState(0);
const [lng, setLng] = useState(0);
const [zoom, setZoom] = useState(10);
// Better — one object, one setter, one conceptual unit
const [mapView, setMapView] = useState({ lat: 0, lng: 0, zoom: 10 });
// Spread to update only one field
setMapView(prev => ({ ...prev, zoom: prev.zoom + 1 }));
3. Understanding React Re-renders
A component re-renders when its state changes. Understanding exactly what "re-render" means — and what it costs — prevents a whole class of performance misconceptions.
When a component re-renders, React calls the function again, from top to bottom. Every line of code in the component body runs again. Variables are re-declared. Expressions are re-evaluated. JSX is produced again.
React then compares the new output to the previous output (the virtual DOM diffing process) and updates only the real DOM nodes that actually changed.
Initial render of Counter:
count = 0
Component runs → produces: <button>-</button> <p>Count: 0</p> <button>+</button>
React creates DOM nodes
User clicks +:
setCount(1) called
React re-renders Counter
count = 1
Component runs again → produces: <button>-</button> <p>Count: 1</p> <button>+</button>
React diffs: only the text inside <p> changed
React updates: just that text node in the real DOM
Buttons are unchanged — React leaves them alone
What triggers a re-render
1. State changes → setCount(), setUser(), etc.
2. Props change → parent passes a different value
3. Context changes → a consumed context updates
4. Parent re-renders → children re-render by default
Re-renders are not expensive by default. The component function runs — plain JavaScript. The expensive part is updating the real DOM, and React minimises that through diffing. Most re-render performance problems are structural (unnecessary state at too high a level) rather than something React.memo can solve.
Avoid storing derivable data in state
A common mistake: putting data in state that can be computed from other state or props. This creates synchronisation problems and unnecessary re-renders.
// WRONG — itemCount is derived from items, storing it separately
const [items, setItems] = useState([]);
const [itemCount, setItemCount] = useState(0);
function addItem(item) {
setItems(prev => [...prev, item]);
setItemCount(prev => prev + 1); // ← easy to forget this, easy to get out of sync
}
// CORRECT — derive itemCount during render
const [items, setItems] = useState([]);
const itemCount = items.length; // ← always accurate, zero maintenance
function addItem(item) {
setItems(prev => [...prev, item]); // ← one update, both values stay in sync
}
The rule: if a value can be computed from existing state or props, compute it in the function body during render. Don't store it in state.
4. Understanding useEffect
While useState manages data inside a component, useEffect manages everything that reaches outside the component — fetching data from an API, subscribing to browser events, starting a timer, updating the page title, connecting to a WebSocket.
These are called side effects — operations that affect something beyond the component's own rendered output.
The mental model for useEffect is not "run this after mount" or "run this when X changes." The better model is: synchronise something external with the current state of your component.
useEffect(() => {
// side effect code here — runs after render
return () => {
// cleanup code — runs before the next effect, or before unmount
};
}, [/* dependencies */]);
Why effects run after render, not during
React renders your component — produces the JSX output — then updates the DOM, then runs effects. This order ensures the DOM is ready before effects touch it, and that the component's render is never blocked by potentially slow operations like network requests.
Component renders
│
▼
DOM updates
│
▼
useEffect runs (after render is complete)
│
▼
[state update inside effect?]
│
▼
Component re-renders with new state
│
▼
DOM updates again
│
▼
useEffect runs again (if dependencies changed)
A first example — updating the document title
function ProfilePage({ user }) {
useEffect(() => {
document.title = `${user.name}'s Profile — MyApp`;
return () => {
document.title = "MyApp"; // reset when leaving the page
};
}, [user.name]); // re-run when user.name changes
return <h1>{user.name}</h1>;
}
This synchronises document.title with user.name. Whenever user.name changes, the title updates. When the component unmounts (the user navigates away), the cleanup function resets the title.
5. Dependency Arrays Explained
The dependency array is the third argument to useEffect. It controls when the effect runs. This is where most useEffect bugs come from, and where the deepest understanding is required.
Three distinct forms
// Form 1: No dependency array — runs after EVERY render
useEffect(() => {
console.log("renders: ", count);
});
// Form 2: Empty dependency array — runs only ONCE, after the first render
useEffect(() => {
console.log("mounted — runs once");
return () => console.log("unmounting");
}, []);
// Form 3: With dependencies — runs after renders where a dependency changed
useEffect(() => {
console.log("userId changed to:", userId);
}, [userId]);
How React evaluates dependencies
React compares each dependency to its value from the previous render using Object.is — essentially strict equality (===). If any value changed, the effect runs again.
function UserPage({ userId }) {
const [userData, setUserData] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => setUserData(data));
}, [userId]); // re-fetch only when userId changes, not on every render
}
The effect runs once on mount (when userId is first available), then again any time userId changes. It doesn't run when unrelated state in UserPage changes.
The infinite loop — the most common useEffect bug
// BROKEN — infinite loop
function BrokenComponent() {
const [data, setData] = useState(null);
useEffect(() => {
fetch("/api/data")
.then(res => res.json())
.then(result => setData(result)); // ← setData causes a re-render
}); // ← no dependency array → runs after EVERY render
// setData → re-render → effect runs → setData → ...
}
The fix is the dependency array:
// FIXED — runs once, on mount
useEffect(() => {
fetch("/api/data")
.then(res => res.json())
.then(result => setData(result));
}, []); // ← empty array: run once, don't re-run
Missing dependencies — the subtle bug
The opposite mistake: omitting dependencies to prevent re-runs, causing the effect to use stale values.
// BROKEN — stale closure
function SearchResults({ query }) {
const [results, setResults] = useState([]);
useEffect(() => {
fetch(`/api/search?q=${query}`) // ← uses query from the first render only
.then(res => res.json())
.then(setResults);
}, []); // ← empty array — never re-runs, even when query changes
return <ul>{results.map(r => <li key={r.id}>{r.name}</li>)}</ul>;
}
Type a new search query — nothing happens. The effect captured the original query value and never updated.
// FIXED — query is a dependency
useEffect(() => {
fetch(`/api/search?q=${query}`)
.then(res => res.json())
.then(setResults);
}, [query]); // ← re-runs whenever query changes — correct
The rule: every value used inside useEffect that comes from the component (state, props, or anything derived from them) must be listed in the dependency array. The ESLint exhaustive-deps rule enforces this automatically.
Cleanup functions
Some effects create resources that need to be released: event listeners, timers, subscriptions, WebSocket connections. The cleanup function returned from useEffect handles this.
useEffect(() => {
// Set up the effect
const subscription = chatService.subscribe(roomId, handleMessage);
// Return a cleanup function
return () => {
subscription.unsubscribe(); // run before the effect re-runs or before unmount
};
}, [roomId]);
When roomId changes, React runs the cleanup (unsubscribes the old room), then runs the effect again (subscribes to the new room). When the component unmounts, React runs the cleanup one final time.
Without cleanup, subscribing to a new room doesn't unsubscribe from the old one. After navigating between enough rooms, you'd have dozens of active subscriptions, all processing the same messages.
6. Common useEffect Patterns
Data fetching with loading and error states
function ArticlePage({ articleId }) {
const [article, setArticle] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false; // prevents setting state on an unmounted component
setLoading(true);
setError(null);
fetch(`/api/articles/${articleId}`)
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(data => {
if (!cancelled) {
setArticle(data);
setLoading(false);
}
})
.catch(err => {
if (!cancelled) {
setError(err.message);
setLoading(false);
}
});
return () => {
cancelled = true; // if articleId changes before fetch completes, ignore the result
};
}, [articleId]);
if (loading) return <Spinner />;
if (error) return <ErrorMessage message={error} />;
return <Article data={article} />;
}
The cancelled flag handles a subtle race condition: if the user navigates to a different article before the first fetch completes, the first response arrives and calls setArticle on what might now be a different article's component. Setting cancelled = true in the cleanup prevents that stale update.
Listening to browser events
function MouseTracker() {
const [position, setPosition] = useState({ x: 0, y: 0 });
useEffect(() => {
function handleMouseMove(event) {
setPosition({ x: event.clientX, y: event.clientY });
}
window.addEventListener("mousemove", handleMouseMove);
return () => {
window.removeEventListener("mousemove", handleMouseMove); // cleanup
};
}, []); // empty array — set up once, clean up on unmount
return <p>Mouse: {position.x}, {position.y}</p>;
}
Timers and intervals
function Countdown({ seconds }) {
const [remaining, setRemaining] = useState(seconds);
useEffect(() => {
if (remaining <= 0) return; // don't start a timer if already at zero
const timer = setInterval(() => {
setRemaining(prev => prev - 1);
}, 1000);
return () => clearInterval(timer); // cleanup when component unmounts or seconds changes
}, [remaining]); // re-run when remaining changes (stops naturally at 0)
return <p>Time remaining: {remaining}s</p>;
}
7. Custom Hooks
A custom hook is a JavaScript function whose name starts with use and that can call other hooks inside it. That's the entire definition. There's no special API, no registration step, no ceremony.
Custom hooks exist to extract and reuse stateful logic. Any pattern you find yourself writing in multiple components — fetching a user, tracking window size, managing a form — can be extracted into a custom hook and shared.
The problem without custom hooks
Imagine you need data-fetching logic in three components: UserProfile, ProductPage, and OrderHistory. Without custom hooks, you copy the pattern three times:
// In UserProfile:
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`)...
}, [userId]);
// In ProductPage: (exact same structure, different URL)
const [product, setProduct] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch(`/api/products/${productId}`)...
}, [productId]);
// In OrderHistory: (same again)
const [orders, setOrders] = useState(null);
...
Three copies of the same logic, three opportunities for the pattern to diverge, three places to fix any bug in the pattern.
Extracting the logic into a custom hook
// 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(() => {
if (!url) return;
let cancelled = false;
setLoading(true);
setError(null);
fetch(url)
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(data => {
if (!cancelled) {
setData(data);
setLoading(false);
}
})
.catch(err => {
if (!cancelled) {
setError(err.message);
setLoading(false);
}
});
return () => { cancelled = true; };
}, [url]);
return { data, loading, error };
}
export default useFetch;
Now all three components become simple:
function UserProfile({ userId }) {
const { data: user, loading, error } = useFetch(`/api/users/${userId}`);
if (loading) return <Spinner />;
if (error) return <ErrorMessage message={error} />;
return <h1>{user.name}</h1>;
}
function ProductPage({ productId }) {
const { data: product, loading, error } = useFetch(`/api/products/${productId}`);
if (loading) return <Spinner />;
if (error) return <ErrorMessage message={error} />;
return <h2>{product.name} — ₹{product.price}</h2>;
}
The pattern is in one file. A bug fix, an improvement (adding retry logic, caching, cancellation), or a new feature (like abort controllers) benefits every consumer automatically.
Each hook call has its own isolated state
An important subtlety: calling useFetch in two components gives each component its own independent state. The hook logic is shared; the state is not. This is exactly the same as calling useState twice in the same component — each call creates separate storage.
// Two independent useFetch calls — separate data, separate loading states
const { data: user } = useFetch(`/api/users/${userId}`);
const { data: orders } = useFetch(`/api/orders?userId=${userId}`);
8. When to Create Custom Hooks
Any time you notice yourself copying the same useState/useEffect pattern into more than one component, that's a signal to extract a custom hook. Here are the most common patterns.
Theme and user preferences
function useTheme() {
const [theme, setTheme] = useState(
() => localStorage.getItem("theme") || "light"
);
useEffect(() => {
document.documentElement.setAttribute("data-theme", theme);
localStorage.setItem("theme", theme);
}, [theme]);
const toggleTheme = () => setTheme(t => t === "light" ? "dark" : "light");
return { theme, toggleTheme };
}
// Usage in any component:
function Header() {
const { theme, toggleTheme } = useTheme();
return (
<header>
<button onClick={toggleTheme}>
{theme === "light" ? "🌙 Dark" : "☀️ Light"}
</button>
</header>
);
}
Window dimensions
function useWindowSize() {
const [size, setSize] = useState({
width: window.innerWidth,
height: window.innerHeight
});
useEffect(() => {
function handleResize() {
setSize({ width: window.innerWidth, height: window.innerHeight });
}
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
return size;
}
// Usage:
function ResponsiveLayout() {
const { width } = useWindowSize();
return width < 768 ? <MobileLayout /> : <DesktopLayout />;
}
Debounced value — for search inputs
function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => clearTimeout(timer); // cancel the timer if value changes before delay
}, [value, delay]);
return debouncedValue;
}
// Usage — only fires search after user stops typing for 500ms
function SearchBar() {
const [query, setQuery] = useState("");
const debouncedQuery = useDebounce(query, 500);
const { data: results } = useFetch(
debouncedQuery ? `/api/search?q=${debouncedQuery}` : null
);
return (
<div>
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search..."
/>
{results?.map(r => <SearchResult key={r.id} result={r} />)}
</div>
);
}
Local storage persistence
function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
try {
const stored = localStorage.getItem(key);
return stored !== null ? JSON.parse(stored) : initialValue;
} catch {
return initialValue;
}
});
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue];
}
// Replaces useState — automatically persists to localStorage
function Settings() {
const [language, setLanguage] = useLocalStorage("language", "en");
const [fontSize, setFontSize] = useLocalStorage("fontSize", 16);
return (
<div>
<select value={language} onChange={e => setLanguage(e.target.value)}>
<option value="en">English</option>
<option value="hi">Hindi</option>
</select>
</div>
);
}
9. Rules of Hooks and Best Practices
React hooks have two hard rules. They exist because React relies on the order that hooks are called to associate each hook with its stored state. Break the rules and the association breaks.
Rule 1 — Only call hooks at the top level
Never call hooks inside conditions, loops, or nested functions. Hooks must be called unconditionally, in the same order, on every render.
// WRONG — hook inside a condition
function Profile({ userId, isLoggedIn }) {
if (isLoggedIn) {
const [user, setUser] = useState(null); // ← conditional hook — breaks rules
}
}
// CORRECT — hook always called, condition inside
function Profile({ userId, isLoggedIn }) {
const [user, setUser] = useState(null);
// use the isLoggedIn condition inside the effect or in the JSX
useEffect(() => {
if (isLoggedIn) {
fetch(`/api/users/${userId}`)...
}
}, [userId, isLoggedIn]);
}
Rule 2 — Only call hooks from React functions
Hooks can only be called from React function components or other custom hooks. Not from regular JavaScript functions, class components, or event handlers.
// WRONG — calling a hook from a regular function
function someUtilityFunction() {
const [count] = useState(0); // ← not a React function — error
}
// CORRECT — hooks only in components and custom hooks
function Counter() {
const [count, setCount] = useState(0); // ← inside a component — correct
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}
The eslint-plugin-react-hooks package enforces both rules automatically. Install it and let the linter catch violations rather than relying on memory.
Organising hooks in a component
Group related state together. Put all hooks at the top of the component, before any conditional logic or early returns. Use custom hooks to keep components readable.
function ProductDashboard({ productId }) {
// ── State ──────────────────────────────────────
const [activeTab, setActiveTab] = useState("overview");
// ── Data fetching ───────────────────────────────
const { data: product, loading, error } = useFetch(`/api/products/${productId}`);
const { data: reviews } = useFetch(`/api/products/${productId}/reviews`);
// ── Derived values ──────────────────────────────
const averageRating = reviews?.reduce((sum, r) => sum + r.rating, 0) / reviews?.length;
// ── Early returns ───────────────────────────────
if (loading) return <Spinner />;
if (error) return <ErrorMessage message={error} />;
// ── Render ──────────────────────────────────────
return (
<div>
<h1>{product.name}</h1>
<p>Average rating: {averageRating?.toFixed(1)} ★</p>
{/* tabs... */}
</div>
);
}
All hooks at the top. Derived values after hooks. Early returns after derived values. Render at the bottom. This structure is readable at a glance and never violates hook rules.
10. Thinking in Hooks
The deepest shift that hooks enable is in how you think about component logic. Instead of "what lifecycle event should I use for this?" you ask two questions:
"What data does this component need to display?" That's state and props, managed with useState and received from parents.
"What does this component need to stay synchronised with?" That's effects, managed with useEffect. Each effect maintains synchronisation between your component's state and something external — the document title, a remote API, a browser event listener, a WebSocket connection.
Component State (useState)
│
│ describes the current state of the world
▼
Render — UI = f(state, props)
│
│ after render, synchronise external things
▼
useEffect — keep external world in sync with component state
│
│ external events update state
▼
State updates → re-render → UI reflects new reality
Effects are synchronisation, not events
The common misconception is that useEffect is React's version of "do this once when the component mounts." Sometimes that's what it does — but that's a consequence, not a definition.
The correct mental model: useEffect keeps something external synchronised with your component's current state. When the component mounts, it synchronises for the first time. When dependencies change, it synchronises again. When the component unmounts, it cleans up the synchronisation.
// Thinking: "fetch when mounted and when userId changes"
// This is an events-based mental model — close, but incomplete
// Better thinking: "keep fetched data synchronised with userId"
// When userId is X, data should match what /api/users/X returns
// Effect ensures this is true after every render where userId changed
useEffect(() => {
fetch(`/api/users/${userId}`)...
}, [userId]);
The synchronisation framing makes the dependency array intuitive: "what does this synchronisation depend on?" The answer goes in the array.
Custom hooks are units of reusable logic
Think of custom hooks as the equivalent of utility functions — except they can use state and effects. Just as you extract a formatDate() utility when you use it in multiple places, extract a useUserData() hook when you use that data-fetching pattern in multiple components.
Custom hooks compose naturally:
// Each hook does one thing
function useUser(userId) {
return useFetch(`/api/users/${userId}`);
}
function useUserPosts(userId) {
return useFetch(`/api/users/${userId}/posts`);
}
// Compose them in a component
function UserPage({ userId }) {
const { data: user } = useUser(userId);
const { data: posts } = useUserPosts(userId);
const { width } = useWindowSize();
const { theme } = useTheme();
// Component body is clean — logic lives in hooks
}
The component is a thin layer that composes hooks and renders JSX. The logic is in the hooks, testable and reusable independently.
Quick Recap
Why hooks:
Class components split related logic across lifecycle methods
Logic couldn't be reused across components without copying
Hooks: stateful logic in function components, extractable and shareable
useState:
const [value, setValue] = useState(initialValue)
Initial value used once — on first render only
Calling setValue triggers a re-render
Use functional form when new state depends on old: setValue(prev => prev + 1)
Don't store derived values — compute them during render
useEffect:
Runs after render, not during
Three forms:
useEffect(fn) → runs after every render
useEffect(fn, []) → runs once, after first render
useEffect(fn, [dep]) → runs when dep changes
Return a cleanup function for subscriptions, timers, listeners
Every value from the component used inside the effect goes in the array
Common mistakes:
No dep array → infinite loop if effect updates state
Empty dep array + stale value → effect uses outdated data
Missing cleanup → memory leaks, duplicate subscriptions
Custom hooks:
Function starting with "use" that calls other hooks
Extracts and shares stateful logic across components
Each call creates independent state — shared logic, not shared data
Build for: data fetching, browser APIs, persisted preferences, timers
Rules of hooks:
Only call at the top level — never in conditions or loops
Only call from React functions — never from plain JS functions
Use eslint-plugin-react-hooks to enforce automatically
Mental model:
useState: what data does this component display?
useEffect: what does this component need to stay synchronised with?
Custom hooks: what logic do multiple components share?
Hooks are not a collection of APIs to memorise — they're a way of thinking about what a component does and what it depends on. useState is memory between renders. useEffect is synchronisation with the outside world. Custom hooks are reusable units of that logic. Once those three ideas are solid, every hook you encounter is just a variation of one of them.
Found this useful? Drop a reaction and share it with someone working through React hooks. Got a question about a specific hook pattern? Leave a comment — I read every one.

