React Hooks Explained: From useState to useEffect and Beyond
React hooks tutorial covering useState, useEffect, useContext, useRef, useMemo, useCallback, and custom hooks — with real examples for every hook.
Get more content like this on Telegram!
Daily AI tips, notes & resources — free
React Hooks Explained: From useState to useEffect and Beyond
When React Hooks landed in 2019, I spent a week convinced they were making things more complicated. Then I tried to add a data-fetching pattern to a class component for the first time in months and remembered exactly why hooks existed.
Hooks are simpler than class components once you understand the mental model. They let you think about your component in terms of what it needs to do, not when in the lifecycle it should do it.
This guide covers every hook you'll actually use in real React applications, with honest notes about the gotchas.
useState — Managing Component State
You already know useState. Let me show the patterns you might not.
// Basic
const [count, setCount] = useState(0);
// Object state — never mutate directly
const [user, setUser] = useState({ name: '', email: '' });
const updateName = (name: string) => setUser(prev => ({ ...prev, name }));
// Functional update — use when new state depends on old state
const increment = () => setCount(prev => prev + 1);
// Lazy initialization — function runs only on first render
const [data, setData] = useState(() => {
const saved = localStorage.getItem('data');
return saved ? JSON.parse(saved) : [];
});
The functional update form (prev => prev + 1) is important when you're updating state inside event handlers or effects. It avoids stale closure issues where count might be out of date.
useEffect — Side Effects
useEffect is where most React confusion comes from. Here's the mental model that cleared it up for me: effects run after render, not during.
// Runs after every render — usually not what you want
useEffect(() => {
document.title = `${count} tasks`;
});
// Runs once after mount — fetch initial data, subscriptions
useEffect(() => {
fetchUser().then(setUser);
}, []);
// Runs when count changes
useEffect(() => {
document.title = `${count} tasks`;
}, [count]);
// Cleanup — runs before the next effect and on unmount
useEffect(() => {
const interval = setInterval(() => setTick(t => t + 1), 1000);
return () => clearInterval(interval); // Cleanup
}, []);
The Data Fetching Pattern
function UserProfile({ userId }: { userId: number }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false; // Prevents state updates after unmount
setLoading(true);
setError(null);
fetch(`/api/users/${userId}`)
.then(res => {
if (!res.ok) throw new Error('Failed to fetch');
return res.json();
})
.then(data => {
if (!cancelled) setUser(data);
})
.catch(err => {
if (!cancelled) setError(err.message);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [userId]);
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
if (!user) return null;
return <div>{user.name}</div>;
}
The cancelled flag is important — it prevents setting state on an unmounted component (which React warns about).
useContext — Sharing State Across Components
// 1. Create context
const ThemeContext = createContext<{
theme: 'light' | 'dark';
toggleTheme: () => void;
} | null>(null);
// 2. Create provider
function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<'light' | 'dark'>('dark');
const toggleTheme = () => setTheme(t => t === 'light' ? 'dark' : 'light');
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
// 3. Custom hook for safe consumption
function useTheme() {
const context = useContext(ThemeContext);
if (!context) throw new Error('useTheme must be used inside ThemeProvider');
return context;
}
// 4. Use anywhere inside the tree
function DarkModeToggle() {
const { theme, toggleTheme } = useTheme();
return (
<button onClick={toggleTheme}>
{theme === 'dark' ? '☀️ Light' : '🌙 Dark'}
</button>
);
}
useRef — DOM Access and Mutable Values
useRef has two distinct use cases:
// Use case 1: DOM element access
function AutoFocusInput() {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus(); // Focus on mount
}, []);
return <input ref={inputRef} placeholder="Auto-focused" />;
}
// Use case 2: Mutable value that doesn't trigger re-render
function StopWatch() {
const [time, setTime] = useState(0);
const intervalRef = useRef<number | null>(null);
function start() {
intervalRef.current = setInterval(() => setTime(t => t + 1), 1000);
}
function stop() {
if (intervalRef.current) clearInterval(intervalRef.current);
}
return (
<div>
<p>{time}s</p>
<button onClick={start}>Start</button>
<button onClick={stop}>Stop</button>
</div>
);
}
The key: changing ref.current does not trigger a re-render. Use refs for values you need to persist across renders but don't need in the rendered output.
useReducer — Complex State Logic
When state has multiple related pieces that change together, useReducer is cleaner than multiple useState calls:
type State = {
items: Item[];
loading: boolean;
error: string | null;
};
type Action =
| { type: 'FETCH_START' }
| { type: 'FETCH_SUCCESS'; payload: Item[] }
| { type: 'FETCH_ERROR'; payload: string }
| { type: 'DELETE_ITEM'; payload: number };
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'FETCH_START':
return { ...state, loading: true, error: null };
case 'FETCH_SUCCESS':
return { ...state, loading: false, items: action.payload };
case 'FETCH_ERROR':
return { ...state, loading: false, error: action.payload };
case 'DELETE_ITEM':
return { ...state, items: state.items.filter(i => i.id !== action.payload) };
default:
return state;
}
}
function ItemList() {
const [state, dispatch] = useReducer(reducer, { items: [], loading: false, error: null });
useEffect(() => {
dispatch({ type: 'FETCH_START' });
fetchItems()
.then(items => dispatch({ type: 'FETCH_SUCCESS', payload: items }))
.catch(err => dispatch({ type: 'FETCH_ERROR', payload: err.message }));
}, []);
// ...
}
useMemo and useCallback — Performance
Use these sparingly — only when you've measured a performance problem.
// useMemo — cache expensive calculation
const sortedItems = useMemo(() => {
return [...items].sort((a, b) => a.name.localeCompare(b.name));
}, [items]); // Only re-sorts when items changes
// useCallback — stable function reference for child components
const handleDelete = useCallback((id: number) => {
setItems(prev => prev.filter(item => item.id !== id));
}, []); // Stable reference — doesn't change on every render
One thing I learned the hard way: wrapping everything in useMemo and useCallback doesn't make your app faster. Each one has its own overhead. Profile first, optimize second.
Custom Hooks — Reusable Logic
The real power of hooks: extracting logic into reusable functions.
// useLocalStorage — persists state to localStorage
function useLocalStorage<T>(key: string, initialValue: T) {
const [value, setValue] = useState<T>(() => {
try {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch {
return initialValue;
}
});
const setStoredValue = (newValue: T) => {
setValue(newValue);
localStorage.setItem(key, JSON.stringify(newValue));
};
return [value, setStoredValue] as const;
}
// Usage
const [theme, setTheme] = useLocalStorage('theme', 'dark');
// useDebounce — debounce a value
function useDebounce<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const handler = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(handler);
}, [value, delay]);
return debounced;
}
// Usage — search that doesn't fire on every keystroke
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 300);
useEffect(() => {
if (debouncedQuery) fetchResults(debouncedQuery);
}, [debouncedQuery]);
Custom hooks are just functions. Any logic that uses hooks and can be reused is a candidate for extraction.
Hooks Quick Reference
| Hook | Purpose | Key gotcha |
|---|---|---|
useState | Component state | Use functional updates when new state depends on old |
useEffect | Side effects | Don't forget cleanup; include all dependencies |
useContext | Consume context | Triggers re-render on any context value change |
useRef | DOM refs, mutable values | Changing ref.current doesn't trigger re-render |
useReducer | Complex state logic | Good replacement for multiple related useState |
useMemo | Cache expensive values | Only use when measured perf problem exists |
useCallback | Stable function refs | Required for memoized children |
For building on top of hooks in a complete app, our React tutorial for beginners shows them in context. When your state management needs grow beyond hooks, our React state management 2025 guide covers Zustand and Jotai. And for React performance optimization beyond useMemo/useCallback, see our React performance guide.
Frequently Asked Questions
What are React Hooks?
Functions that let you use state and lifecycle features in functional components. Introduced in React 16.8, they replaced class components as the standard.
When should I use useMemo vs useCallback?
useMemo caches computed values. useCallback caches function references. Use both only when you have a measured performance problem.
What is the useEffect dependency array?
Controls when the effect runs. Empty: once on mount. No array: every render. [a, b]: when a or b changes.
What is a custom React Hook?
A function starting with 'use' that uses other hooks. Lets you extract and reuse stateful logic across components.
Frequently Asked Questions
AiTechWorlds Team
✓ Verified WriterThe AiTechWorlds team is passionate about AI, technology, and education. We create high-quality, research-backed content to help you learn, grow, and succeed in the modern digital world.
Related Articles
How to Deploy a React App to Vercel in 10 Minutes
Deploy a React app to Vercel in 10 minutes: from npm create vite to live URL, custom domain setup, environment variables, and preview deployments.
GraphQL vs REST: Which API Style Should You Learn in 2025?
GraphQL vs REST API compared honestly for 2025: when each makes sense, real code examples, and which API style to learn first as a developer.
JavaScript Promises and Async/Await: Finally Understand Them
JavaScript async await and Promises explained clearly: the event loop, Promise chains, async/await patterns, error handling, and common mistakes to avoid.
How to Pass a JavaScript Interview at Google, Meta, or Amazon
How to pass a JavaScript interview at top tech companies: closures, event loop, promises, DOM questions, system design, and real interview questions answered.