React Hooks Masterclass: useState, useEffect, and Custom Hooks Explained

Search for a command to run...

No comments yet. Be the first to comment.
How React Works Internally: Virtual DOM, Reconciliation, and the Rendering Pipeline TL;DR: When you call setState, React doesn't immediately touch the browser DOM. Instead, it re-executes your compon

How React Works Internally: Virtual DOM, Reconciliation, and the Rendering Pipeline TL;DR: When you call setState, React doesn't immediately touch the browser DOM. Instead, it re-executes your compon

Mastering TypeScript: Interfaces, Generics, and Union Types Explained TL;DR: TypeScript adds a compile-time type system on top of JavaScript that catches entire classes of bugs before your code ships

Mastering TypeScript: Interfaces, Generics, and Union Types Explained TL;DR: TypeScript adds a compile-time type system on top of JavaScript that catches entire classes of bugs before your code ships

TL;DR: React Hooks replaced class components by giving functional components the ability to manage state, run side effects, and share logic. This article builds a complete mental model — from how React remembers values between renders, to writing production-quality custom hooks — with runnable code every step of the way.
Before Hooks, writing a React component that fetched data on mount, subscribed to a browser event, and cleaned up after itself required a class component with componentDidMount, componentDidUpdate, and componentWillUnmount — three separate places to express what is fundamentally one idea. Logic that belonged together was scattered. Logic that didn't belong together was crammed into the same lifecycle method.
Hooks changed that. Introduced in React 16.8, they let you use state and other React features from plain functions. But Hooks aren't just a syntax preference. They are a different way of thinking about components — one that centers on what your component is doing rather than when the framework calls it.
This article is for developers who know React basics but want to understand Hooks deeply enough to use them confidently in production. We'll build every concept from first principles, starting with the most fundamental question:
How does React remember information between renders?
A React functional component is just a function. Every time React needs to display an updated UI, it calls that function again. Variables declared inside the function are recreated from scratch on each call. So how does useState remember its value?
React maintains a per-component, per-render fiber node — an internal data structure that lives outside the component function itself. When you call useState, React reads from a slot in that fiber's memoized state array, keyed by the order in which hooks are called. This is why the Rules of Hooks exist: call hooks unconditionally, and always in the same order, so React can reliably match each hook call to its internal slot.
flowchart TD
A["Component Function Called"] --> B["Hook 1: useState — reads slot 0"]
B --> C["Hook 2: useState — reads slot 1"]
C --> D["Hook 3: useEffect — reads slot 2"]
D --> E["Returns JSX"]
E --> F["React reconciles & commits to DOM"]
F --> G{"State update triggered?"}
G -- Yes --> A
G -- No --> H["Idle — waiting"]
This slot-based model has a concrete implication: you cannot call hooks inside if statements or loops. If you did, the slot positions would shift between renders and React would read wrong values.

useState is a hook that lets a component own a piece of data that persists across renders and, when changed, triggers a re-render to display the updated UI.
The API is intentionally minimal:
const [value, setValue] = useState(initialValue);
value is the current state. setValue is a stable function that schedules an update. When you call setValue, React queues a re-render, the component function runs again, and the new value reflects your update.
Let's start with a counter, but build it in a way that shows the mechanics:
import { useState } from 'react';
export function ArticleVoteCounter({ articleId }: { articleId: string }) {
const [upvotes, setUpvotes] = useState(0);
const [downvotes, setDownvotes] = useState(0);
const score = upvotes - downvotes;
return (
<div className="vote-counter">
<h3>Article Score: {score}</h3>
<button onClick={() => setUpvotes(prev => prev + 1)}>👍 {upvotes}</button>
<button onClick={() => setDownvotes(prev => prev + 1)}>👎 {downvotes}</button>
</div>
);
}
A few things worth noting here:
upvotes and downvotes each occupy their own slot. They don't need to be grouped into one object unless their updates are always coordinated.prev => prev + 1): When the new state depends on the old state, always use the functional form. This avoids stale closures in async scenarios.score is computed from existing state. Storing it separately would create a source of truth problem.When state is an object, remember that setState replaces the entire value — it doesn't merge like this.setState in class components:
import { useState } from 'react';
interface UserProfile {
name: string;
bio: string;
avatarUrl: string;
}
export function ProfileEditor() {
const [profile, setProfile] = useState<UserProfile>({
name: 'Ada Lovelace',
bio: 'Mathematician and writer.',
avatarUrl: '/avatars/ada.jpg',
});
function updateBio(newBio: string) {
// Spread existing fields — otherwise name and avatarUrl are lost
setProfile(prev => ({ ...prev, bio: newBio }));
}
return (
<div>
<img src={profile.avatarUrl} alt={profile.name} />
<h2>{profile.name}</h2>
<textarea
value={profile.bio}
onChange={e => updateBio(e.target.value)}
/>
</div>
);
}
Warning: Forgetting to spread
...prevwhen updating object state is one of the most common bugs in React. Always ensure you're carrying forward the fields you're not explicitly changing.
Every call to a state setter causes React to schedule a re-render of that component and its descendants. Understanding what triggers a re-render — and what doesn't — is essential.
| Trigger | Re-render? | Notes |
|---|---|---|
setState(newValue) |
✅ Yes | Even if newValue === currentValue for objects/arrays — use same reference to bail out |
setState(sameValue) (primitive) |
❌ No | React bails out if the new primitive value is the same via Object.is |
| Parent re-renders | ✅ Yes | Unless wrapped in React.memo |
useContext value change |
✅ Yes | All consumers re-render |
useRef value change |
❌ No | Refs are mutable but don't trigger re-renders |
This table reveals an important design decision: don't put something in state if a change to it shouldn't update the UI. For values you need to persist across renders without causing re-renders — like a timer ID, a WebSocket instance, or a previous value — use useRef instead.
import { useRef, useState } from 'react';
export function Stopwatch() {
const [elapsedMs, setElapsedMs] = useState(0);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
function start() {
if (intervalRef.current !== null) return;
const startTime = Date.now() - elapsedMs;
intervalRef.current = setInterval(() => {
setElapsedMs(Date.now() - startTime);
}, 10);
}
function stop() {
if (intervalRef.current === null) return;
clearInterval(intervalRef.current);
intervalRef.current = null;
}
return (
<div>
<p>{(elapsedMs / 1000).toFixed(2)}s</p>
<button onClick={start}>Start</button>
<button onClick={stop}>Stop</button>
</div>
);
}
The interval ID is stored in a ref — mutating it doesn't cause a render, which is exactly what we want. Only elapsedMs (which drives the display) lives in state.
Most developers initially think of useEffect as lifecycle methods ported to functions: "run on mount", "run on update", "run on unmount". This mental model leads to bugs.
The better model: useEffect synchronizes your component with something outside React — a server, a browser API, a third-party library. The question to ask isn't when does this run? but what external system does this synchronize with, and what does it need to stay in sync?
Side effects are anything that reaches outside the React render cycle: fetching data, subscribing to events, setting document titles, starting timers.
import { useEffect, useState } from 'react';
interface Post {
id: number;
title: string;
body: string;
}
export function PostDetail({ postId }: { postId: number }) {
const [post, setPost] = useState<Post | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
async function fetchPost() {
setLoading(true);
setError(null);
try {
const res = await fetch(`https://jsonplaceholder.typicode.com/posts/${postId}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data: Post = await res.json();
if (!cancelled) {
setPost(data);
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'Unknown error');
}
} finally {
if (!cancelled) setLoading(false);
}
}
fetchPost();
return () => {
cancelled = true;
};
}, [postId]);
if (loading) return <p>Loading post {postId}…</p>;
if (error) return <p>Error: {error}</p>;
if (!post) return null;
return (
<article>
<h2>{post.title}</h2>
<p>{post.body}</p>
</article>
);
}
This example demonstrates several critical patterns:
cancelled flag: When postId changes quickly (e.g., user clicks through posts), multiple fetches can be in flight. The flag prevents a slow response from an earlier request overwriting a later one. This is called a race condition guard.useEffect runs before the next effect fires and when the component unmounts. This is where you cancel requests, clear timers, and unsubscribe from events.[postId]: The effect re-runs whenever postId changes.The dependency array is the mechanism React uses to know when to re-synchronize. Think of it as declaring: "This effect depends on these values. Re-run it when any of them change."
// Runs once after mount (stable empty dependency set)
useEffect(() => {
document.title = 'My App';
}, []);
// Runs after every render (no dependency array at all)
useEffect(() => {
console.log('Component rendered');
});
// Runs when `searchQuery` changes
useEffect(() => {
const controller = new AbortController();
fetch(`/api/search?q=${searchQuery}`, { signal: controller.signal })
.then(r => r.json())
.then(setResults)
.catch(() => {});
return () => controller.signal.abort();
}, [searchQuery]);
Warning: The most common mistake is leaving dependencies out of the array. If your effect uses a value from the component (state, props, or a function), it must be in the dependency array — or you'll read stale values. Use the
eslint-plugin-react-hooksexhaustive-deps rule to catch this automatically.
The table below summarizes dependency array behaviors:
| Dependency Array | When Effect Runs |
|---|---|
| Omitted (no array) | After every render |
Empty [] |
Once, after initial mount |
[a] |
After mount, and whenever a changes |
[a, b] |
After mount, and whenever a or b changes |
import { useEffect, useState } from 'react';
export function MouseTracker() {
const [position, setPosition] = useState({ x: 0, y: 0 });
useEffect(() => {
function handleMouseMove(event: MouseEvent) {
setPosition({ x: event.clientX, y: event.clientY });
}
window.addEventListener('mousemove', handleMouseMove);
// Cleanup: remove listener when component unmounts or before re-running
return () => {
window.removeEventListener('mousemove', handleMouseMove);
};
}, []); // Only subscribe once
return (
<p>
Mouse: ({position.x}, {position.y})
</p>
);
}
Without the cleanup, every time this component mounts it adds another listener. If it renders in a modal that opens and closes repeatedly, you'll accumulate hundreds of listeners — a classic memory leak.

A custom hook is a function whose name starts with use and that calls other hooks internally. That's it. No special API, no registration. The use prefix is a signal to React's linter rules that this function follows hook rules — and a signal to your team that this function owns stateful logic.
The power of custom hooks is extracting behavior without extracting markup. Unlike a component, a custom hook doesn't render anything. It returns data and functions that a component can use however it likes.
useFetchThe data-fetching pattern from the PostDetail example above appears in every application. Let's extract it:
import { useEffect, useReducer } from 'react';
interface FetchState<T> {
data: T | null;
loading: boolean;
error: string | null;
}
type FetchAction<T> =
| { type: 'LOADING' }
| { type: 'SUCCESS'; payload: T }
| { type: 'ERROR'; payload: string };
function fetchReducer<T>(state: FetchState<T>, action: FetchAction<T>): FetchState<T> {
switch (action.type) {
case 'LOADING': return { data: null, loading: true, error: null };
case 'SUCCESS': return { data: action.payload, loading: false, error: null };
case 'ERROR': return { data: null, loading: false, error: action.payload };
}
}
export function useFetch<T>(url: string): FetchState<T> {
const [state, dispatch] = useReducer(fetchReducer<T>, {
data: null,
loading: true,
error: null,
});
useEffect(() => {
if (!url) return;
let cancelled = false;
const controller = new AbortController();
dispatch({ type: 'LOADING' });
fetch(url, { signal: controller.signal })
.then(res => {
if (!res.ok) throw new Error(`HTTP \({res.status}: \){res.statusText}`);
return res.json() as Promise<T>;
})
.then(data => {
if (!cancelled) dispatch({ type: 'SUCCESS', payload: data });
})
.catch(err => {
if (!cancelled && err.name !== 'AbortError') {
dispatch({ type: 'ERROR', payload: err.message });
}
});
return () => {
cancelled = true;
controller.abort();
};
}, [url]);
return state;
}
Now any component that needs remote data can consume this:
interface User {
id: number;
name: string;
email: string;
}
export function UserCard({ userId }: { userId: number }) {
const { data: user, loading, error } = useFetch<User>(
`https://jsonplaceholder.typicode.com/users/${userId}`
);
if (loading) return <div className="skeleton" />;
if (error) return <p className="error-text">{error}</p>;
if (!user) return null;
return (
<div className="user-card">
<h3>{user.name}</h3>
<p>{user.email}</p>
</div>
);
}
The component is now only responsible for rendering. All the fetch mechanics live in useFetch.
useLocalStorageimport { useState, useEffect } from 'react';
export function useLocalStorage<T>(key: string, initialValue: T) {
const [storedValue, setStoredValue] = useState<T>(() => {
// Lazy initializer: only runs on first render
try {
const item = window.localStorage.getItem(key);
return item ? (JSON.parse(item) as T) : initialValue;
} catch {
return initialValue;
}
});
function setValue(value: T | ((prev: T) => T)) {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (err) {
console.warn(`useLocalStorage: could not write key "${key}"`, err);
}
}
return [storedValue, setValue] as const;
}
Usage is identical to useState, but the value persists across page refreshes:
export function ThemeSwitcher() {
const [theme, setTheme] = useLocalStorage<'light' | 'dark'>('app-theme', 'light');
return (
<button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>
Current theme: {theme}
</button>
);
}
useWindowSizeDevice and viewport utilities are another perfect use case:
import { useState, useEffect } from 'react';
interface WindowSize {
width: number;
height: number;
}
export function useWindowSize(): WindowSize {
const [size, setSize] = useState<WindowSize>({
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
export function ResponsiveLayout() {
const { width } = useWindowSize();
const isMobile = width < 768;
return (
<div className={isMobile ? 'layout-mobile' : 'layout-desktop'}>
{isMobile ? <MobileNav /> : <DesktopNav />}
</div>
);
}

| Pattern | Risk | Mitigation |
|---|---|---|
Missing deps in useEffect |
Stale closures reading old values | Use eslint-plugin-react-hooks exhaustive-deps |
| Object/array in deps array | New reference every render → infinite loop | Memoize with useMemo/useCallback, or restructure |
| Too much state in one object | Complex merging, hard to read | Split into multiple useState calls |
| Effects for derived data | Unnecessary renders, complexity | Compute directly during render instead |
| No cleanup in subscriptions | Memory leaks, stale listeners | Always return cleanup function |
| Custom hooks that do too much | Hard to test and reuse | Single-responsibility principle: one behavior per hook |
The most dangerous pitfall is accidentally creating an infinite loop:
// ❌ BROKEN: `options` is a new object on every render
useEffect(() => {
fetchData(options);
}, [options]); // options changes every render → infinite loop
const options = { method: 'GET', headers: { Accept: 'application/json' } };
// ✅ FIXED: Move the object outside the component or memoize it
const options = useMemo(() => ({
method: 'GET',
headers: { Accept: 'application/json' },
}), []); // stable reference now
Tip: If an effect runs more times than you expect, log the dependency values with
console.logon each render to identify which one is changing reference on every call.
The Rules of Hooks are enforced by the React linter for a reason — break them and you'll get silent, intermittent bugs that are extremely hard to trace:
Beyond the rules, here are production-tested practices:
useFetch is fine for demos; useUserProfile(userId) is what your codebase needs at scale.useEffect per concern. Don't combine data fetching and event subscription in one block — split them so cleanup is clear.useReducer over multiple useState calls when state values change together. It makes state transitions explicit and testable.@testing-library/react-hooks (or the renderHook utility from React Testing Library v13+). Since hooks are plain functions, they're straightforward to test in isolation.import { renderHook, act } from '@testing-library/react';
import { useLocalStorage } from './useLocalStorage';
beforeEach(() => localStorage.clear());
test('persists value to localStorage', () => {
const { result } = renderHook(() => useLocalStorage('color', 'blue'));
act(() => {
result.current[1]('red');
});
expect(result.current[0]).toBe('red');
expect(localStorage.getItem('color')).toBe('"red"');
});
Hooks push you toward a specific way of thinking about components:
State is a snapshot. Each render has its own snapshot of state. Closures capture the snapshot from when they were created — this is why stale closures are a problem, and why functional updates (prev => prev + 1) exist.
Effects synchronize, not execute. Ask: "What external system does this effect keep in sync?" The answer tells you what goes in the dependency array, what the cleanup should do, and whether the effect belongs in a custom hook.
Custom hooks are the unit of reuse. Higher-order components and render props solved logic reuse before Hooks, but both added wrapper components that cluttered the tree. Custom hooks share logic with zero extra nodes in the component tree.
Derive before you store. Before adding a new useState, ask whether the value can be computed from existing state on every render. Derived state is one less thing to synchronize.
Think in dependency graphs, not lifecycles. useEffect doesn't care whether your component is mounting or updating. It only cares whether its dependencies have changed. That shift in thinking eliminates entire categories of bugs.
React Hooks didn't just change syntax — they changed the architecture of React applications. Functional components with hooks are more predictable, easier to test, and far easier to share logic across than class-based equivalents.
The three concepts covered here form the entire foundation:
useState gives your component memory that persists across renders and drives UI updates.useEffect synchronizes your component with external systems, with an explicit model for cleanup.Your immediate next step: look at your existing components and find one useEffect block that's more than 10 lines long. Extract it into a custom hook with a name that describes what it does. You'll find the component becomes dramatically easier to read, and the hook becomes trivially easy to test.
useTransition and useDeferredValue.useEffect, effects, and how to think about synchronization correctly.exhaustive-deps rule prevents an entire class of stale-closure bugs automatically.