useState and useReducer
useState and useReducer
useState for Local State
useState is the most fundamental hook for managing component state. It returns a state value and a setter function. The setter can accept a new value or a function that receives the previous state and returns the new state.
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
const increment = () => setCount(prev => prev + 1);
const decrement = () => setCount(prev => prev - 1);
const reset = () => setCount(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={decrement}>-</button>
<button onClick={reset}>Reset</button>
<button onClick={increment}>+</button>
</div>
);
}
Lazy Initialization
When the initial state is expensive to compute, pass a function to useState. It runs only on the first render, avoiding recalculation on every re-render.
function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
try {
const stored = localStorage.getItem(key);
return stored !== null ? JSON.parse(stored) : initialValue;
} catch {
return initialValue;
}
});
return [value, setValue];
}
const [settings, setSettings] = useLocalStorage('settings', {
theme: 'dark',
fontSize: 14,
});
Object and Array State
Always spread the previous state when updating objects or arrays to avoid losing data. React state updates are shallow merges for objects and require immutable patterns for arrays.
function UserForm() {
const [user, setUser] = useState({
name: '', email: '', preferences: { theme: 'light' }
});
const updateName = (name) => {
setUser(prev => ({ ...prev, name }));
};
const updateTheme = (theme) => {
setUser(prev => ({
...prev,
preferences: { ...prev.preferences, theme }
}));
};
// Array operations
const [tags, setTags] = useState([]);
const addTag = (tag) => setTags(prev => [...prev, tag]);
const removeTag = (tag) => setTags(prev => prev.filter(t => t !== tag));
const updateTag = (old, New) => setTags(prev =>
prev.map(t => t === old ? New : t)
);
}
useReducer for Complex State Logic
useReducer is preferable when the next state depends on the previous state in complex ways, or when multiple state values are updated together. It follows the Redux pattern of dispatching actions.
import { useReducer } from 'react';
function todoReducer(state, action) {
switch (action.type) {
case 'ADD':
return [...state, {
id: Date.now(),
text: action.payload,
done: false
}];
case 'TOGGLE':
return state.map(todo =>
todo.id === action.payload
? { ...todo, done: !todo.done }
: todo
);
case 'DELETE':
return state.filter(todo => todo.id !== action.payload);
case 'EDIT':
return state.map(todo =>
todo.id === action.payload.id
? { ...todo, text: action.payload.text }
: todo
);
default:
throw new Error(`Unknown action type: ${action.type}`);
}
}
function TodoApp() {
const [todos, dispatch] = useReducer(todoReducer, []);
const [text, setText] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
if (text.trim()) {
dispatch({ type: 'ADD', payload: text.trim() });
setText('');
}
};
return (
<form onSubmit={handleSubmit}>
<input value={text} onChange={e => setText(e.target.value)} />
<button type="submit">Add Todo</button>
<ul>
{todos.map(todo => (
<li key={todo.id} style={{
textDecoration: todo.done ? 'line-through' : 'none'
}}>
<span onClick={() => dispatch({
type: 'TOGGLE', payload: todo.id
})}>
{todo.text}
</span>
<button onClick={() => dispatch({
type: 'DELETE', payload: todo.id
})}>
Delete
</button>
</li>
))}
</ul>
</form>
);
}
When to Use useState vs useReducer
- useState: Simple state values (booleans, strings, numbers), independent state updates, state that does not depend on the previous value
- useReducer: Complex state objects with multiple sub-values, state transitions that depend on previous state, when you want predictable state updates through actions
useReducer also helps avoid passing multiple setters as props, making component APIs cleaner and state transitions more explicit and testable.
useEffect and Cleanup
useEffect and Cleanup
Synchronization Patterns
useEffect runs side effects after render. The dependency array controls when the effect re-runs. An empty array means it runs only on mount. Returning a function performs cleanup before the next effect run or on unmount.
import { useState, useEffect } from 'react';
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
setLoading(true);
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => {
if (!cancelled) setUser(data);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [userId]);
useEffect(() => {
document.title = user ? `${user.name} - Profile` : 'Loading...';
}, [user]);
if (loading) return <Spinner />;
return <div>{user.name}</div>;
}
Cleanup Functions
Cleanup runs before the effect re-executes and when the component unmounts. This is essential for preventing memory leaks with subscriptions, timers, and event listeners.
function useWebSocket(url, onMessage) {
useEffect(() => {
const ws = new WebSocket(url);
ws.onmessage = (event) => onMessage(JSON.parse(event.data));
ws.onerror = (error) => console.error('WebSocket error:', error);
return () => ws.close();
}, [url, onMessage]);
}
function useDocumentTitle(title) {
useEffect(() => {
const prev = document.title;
document.title = title;
return () => { document.title = prev; };
}, [title]);
}
function useEventListener(target, event, handler) {
useEffect(() => {
target.addEventListener(event, handler);
return () => target.removeEventListener(event, handler);
}, [target, event, handler]);
}
Common Pitfalls
Stale closures occur when an effect captures a value that becomes outdated. Always include dependencies in the array or use functional updates.
// BUG: stale closure — count stays at 0
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setCount(count + 1); // Always sets to 1
}, 1000);
return () => clearInterval(interval);
}, []); // Empty deps captures initial count
}
// FIX: functional update always uses latest state
useEffect(() => {
const interval = setInterval(() => {
setCount(prev => prev + 1);
}, 1000);
return () => clearInterval(interval);
}, []);
// BUG: missing dependency
function Search({ query }) {
const [results, setResults] = useState([]);
useEffect(() => {
fetchResults(query).then(setResults);
}, []); // query is stale if prop changes
}
// FIX: include query in dependencies
useEffect(() => {
fetchResults(query).then(setResults);
}, [query]);
Rules for Dependencies
The exhaustive-deps ESLint rule helps catch missing dependencies. Treat the dependency array as a contract: every external value the effect reads should be listed. If you truly want an effect to run once, use useRef to hold the value instead of closing over it.
useContext and useReducer Together
useContext and useReducer Together
Global State Management
Combining useContext with useReducer provides a lightweight state management solution without external libraries. The reducer handles state transitions, while context distributes state and dispatch to any descendant component.
import { createContext, useContext, useReducer, useEffect } from 'react';
const AuthContext = createContext(null);
function authReducer(state, action) {
switch (action.type) {
case 'LOGIN_START':
return { ...state, loading: true, error: null };
case 'LOGIN_SUCCESS':
return { ...state, loading: false, user: action.payload };
case 'LOGIN_ERROR':
return { ...state, loading: false, error: action.payload };
case 'LOGOUT':
return { user: null, loading: false, error: null };
default:
return state;
}
}
function AuthProvider({ children }) {
const [state, dispatch] = useReducer(authReducer, {
user: null, loading: true, error: null,
});
useEffect(() => {
const token = localStorage.getItem('token');
if (token) {
fetchUser(token)
.then(user => dispatch({ type: 'LOGIN_SUCCESS', payload: user }))
.catch(() => dispatch({ type: 'LOGOUT' }));
} else {
dispatch({ type: 'LOGOUT' });
}
}, []);
const login = async (email, password) => {
dispatch({ type: 'LOGIN_START' });
try {
const { user, token } = await api.login(email, password);
localStorage.setItem('token', token);
dispatch({ type: 'LOGIN_SUCCESS', payload: user });
} catch (error) {
dispatch({ type: 'LOGIN_ERROR', payload: error.message });
throw error;
}
};
const logout = () => {
localStorage.removeItem('token');
dispatch({ type: 'LOGOUT' });
};
return (
<AuthContext.Provider value={{ ...state, login, logout }}>
{children}
</AuthContext.Provider>
);
}
function useAuth() {
const context = useContext(AuthContext);
if (!context) throw new Error('useAuth must be used within AuthProvider');
return context;
}
Consuming Context in Components
function Navbar() {
const { user, logout } = useAuth();
return (
<nav>
<span>Welcome, {user.name}</span>
<button onClick={logout}>Logout</button>
</nav>
);
}
function LoginPage() {
const { login, loading, error } = useAuth();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const handleSubmit = async (e) => {
e.preventDefault();
await login(email, password);
};
return (
<form onSubmit={handleSubmit}>
{error && <p className="error">{error}</p>}
<input value={email} onChange={e => setEmail(e.target.value)} />
<input type="password" value={password}
onChange={e => setPassword(e.target.value)} />
<button disabled={loading}>
{loading ? 'Signing in...' : 'Sign In'}
</button>
</form>
);
}
Splitting Context for Performance
Avoid unnecessary re-renders by splitting state and dispatch into separate contexts. Components that only dispatch will not re-render when state changes.
const AuthStateContext = createContext();
const AuthDispatchContext = createContext();
function AuthProvider({ children }) {
const [state, dispatch] = useReducer(authReducer, {
user: null, loading: true, error: null,
});
return (
<AuthStateContext.Provider value={state}>
<AuthDispatchContext.Provider value={dispatch}>
{children}
</AuthDispatchContext.Provider>
</AuthStateContext.Provider>
);
}
function useAuthState() {
const context = useContext(AuthStateContext);
if (!context) throw new Error('useAuthState must be used within AuthProvider');
return context;
}
function useAuthDispatch() {
const context = useContext(AuthDispatchContext);
if (!context) throw new Error('useAuthDispatch must be used within AuthProvider');
return context;
}
// Navbar only re-renders when state changes
function Navbar() {
const { user } = useAuthState();
const dispatch = useAuthDispatch();
return (
<nav>
<span>{user.name}</span>
<button onClick={() => dispatch({ type: 'LOGOUT' })}>Logout</button>
</nav>
);
}
useRef, useMemo, and useCallback
useRef, useMemo, and useCallback
useRef for DOM Access and Mutable Values
useRef returns a mutable object whose .current property persists across renders without causing re-renders when changed. This makes it ideal for accessing DOM elements directly and storing values that should not trigger re-renders.
import { useRef, useEffect } from 'react';
function TextInput({ initialValue, onSubmit }) {
const inputRef = useRef(null);
useEffect(() => {
inputRef.current.focus();
}, []);
const handleSubmit = (e) => {
e.preventDefault();
onSubmit(inputRef.current.value);
inputRef.current.value = '';
};
return (
<form onSubmit={handleSubmit}>
<input ref={inputRef} defaultValue={initialValue} />
<button type="submit">Submit</button>
</form>
);
}
useRef for Timers and Previous Values
function Stopwatch() {
const [time, setTime] = useState(0);
const intervalRef = useRef(null);
const start = () => {
intervalRef.current = setInterval(() => {
setTime(prev => prev + 1);
}, 1000);
};
const stop = () => clearInterval(intervalRef.current);
useEffect(() => () => clearInterval(intervalRef.current), []);
return (
<div>
<p>{time}s</p>
<button onClick={start}>Start</button>
<button onClick={stop}>Stop</button>
</div>
);
}
function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value;
}, [value]);
return ref.current;
}
function Counter() {
const [count, setCount] = useState(0);
const prevCount = usePrevious(count);
return <p>Now: {count}, Before: {prevCount}</p>;
}
useMemo for Expensive Computations
useMemo caches the result of a computation and only recalculates when dependencies change. Use it for expensive calculations, filtering, sorting, or transforming data that would otherwise be repeated on every render.
import { useMemo } from 'react';
function ProductList({ products, filter, sortBy }) {
const filteredProducts = useMemo(() => {
return products
.filter(p => p.category === filter)
.sort((a, b) => {
if (sortBy === 'price') return a.price - b.price;
if (sortBy === 'name') return a.name.localeCompare(b.name);
return 0;
});
}, [products, filter, sortBy]);
const totalValue = useMemo(() => {
return filteredProducts.reduce((sum, p) => sum + p.price * p.quantity, 0);
}, [filteredProducts]);
return (
<div>
<p>Total: ${totalValue.toFixed(2)}</p>
<ul>
{filteredProducts.map(p => (
<li key={p.id}>{p.name} - ${p.price}</li>
))}
</ul>
</div>
);
}
useCallback for Stable Function References
useCallback memoizes a function definition. Without it, a new function reference is created on every render, which can cause child components wrapped in React.memo to re-render unnecessarily.
import { useCallback } from 'react';
const TodoList = React.memo(function TodoList({ todos, onToggle }) {
console.log('TodoList rendered');
return (
<ul>
{todos.map(todo => (
<li key={todo.id} onClick={() => onToggle(todo.id)}>
{todo.text}
</li>
))}
</ul>
);
});
function TodoApp() {
const [todos, setTodos] = useState([]);
const [text, setText] = useState('');
const addTodo = useCallback(() => {
setTodos(prev => [...prev, {
id: Date.now(), text, done: false
}]);
setText('');
}, [text]);
const toggleTodo = useCallback((id) => {
setTodos(prev => prev.map(t =>
t.id === id ? { ...t, done: !t.done } : t
));
}, []);
return (
<div>
<input value={text} onChange={e => setText(e.target.value)} />
<button onClick={addTodo}>Add</button>
<TodoList todos={todos} onToggle={toggleTodo} />
</div>
);
}
When to Use Each
- useRef: DOM access, storing timer IDs, holding previous values, any mutable value that should not trigger re-renders
- useMemo: Expensive computations, derived data from props, creating stable object references for dependencies
- useCallback: Passing callbacks to memoized child components, stabilizing event handlers used as dependencies in other hooks
Custom Hooks and Rules of Hooks
Custom Hooks and Rules of Hooks
Building Custom Hooks
Custom hooks extract reusable stateful logic into standalone functions. They must start with the word use so React can enforce the rules of hooks. A custom hook can use other hooks internally.
function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
const controller = new AbortController();
setLoading(true);
fetch(url, { signal: controller.signal })
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(data => { if (!cancelled) setData(data); })
.catch(err => {
if (!cancelled && err.name !== 'AbortError')
setError(err.message);
})
.finally(() => { if (!cancelled) setLoading(false); });
return () => {
cancelled = true;
controller.abort();
};
}, [url]);
return { data, loading, error };
}
// Usage
function UserList() {
const { data: users, loading, error } = useFetch('/api/users');
if (loading) return <Spinner />;
if (error) return <p>Error: {error}</p>;
return (
<ul>
{users.map(u => <li key={u.id}>{u.name}</li>)}
</ul>
);
}
More Custom Hook Examples
function useDebounce(value, delay = 300) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}
function useMediaQuery(query) {
const [matches, setMatches] = useState(
() => window.matchMedia(query).matches
);
useEffect(() => {
const mq = window.matchMedia(query);
const handler = (e) => setMatches(e.matches);
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}, [query]);
return matches;
}
function useToggle(initial = false) {
const [value, setValue] = useState(initial);
const toggle = useCallback(() => setValue(v => !v), []);
const setTrue = useCallback(() => setValue(true), []);
const setFalse = useCallback(() => setValue(false), []);
return { value, toggle, setTrue, setFalse };
}
// Usage
function ResponsiveLayout() {
const isMobile = useMediaQuery('(max-width: 768px)');
const { value: sidebarOpen, toggle } = useToggle(false);
return isMobile ? <MobileLayout /> : <DesktopLayout />;
}
Rules of Hooks
React enforces two strict rules. Breaking them causes undefined behavior or runtime errors.
Rule 1: Only call hooks at the top level — Never call hooks inside loops, conditions, or nested functions. This ensures hooks are called in the same order on every render, which is how React tracks their state internally.
// WRONG — hook inside condition
function User({ showDetails }) {
const [name, setName] = useState('');
if (showDetails) {
const [details, setDetails] = useState(null); // Violation!
}
}
// CORRECT — always call hooks, conditionally use values
function User({ showDetails }) {
const [name, setName] = useState('');
const [details, setDetails] = useState(null);
// conditionally render based on showDetails
}
Rule 2: Only call hooks from React functions — Call hooks only from React components or custom hooks. Never call them from regular JavaScript functions, class components, or event handlers.
// WRONG — hook inside event handler
function Button() {
const handleClick = () => {
const [clicked, setClicked] = useState(false); // Violation!
};
return <button onClick={handleClick}>Click</button>;
}
// CORRECT — hook at top level
function Button() {
const [clicked, setClicked] = useState(false);
const handleClick = () => setClicked(true);
return <button onClick={handleClick}>Click</button>;
}
Composing Custom Hooks
Custom hooks can compose other custom hooks, creating powerful abstractions from simple building blocks.
function usePagination(initialPage = 1, perPage = 20) {
const [page, setPage] = useState(initialPage);
const nextPage = useCallback(() => setPage(p => p + 1), []);
const prevPage = useCallback(() => setPage(p => Math.max(1, p - 1)), []);
const goToPage = useCallback((p) => setPage(p), []);
return { page, nextPage, prevPage, goToPage };
}
function usePaginatedFetch(url, perPage = 20) {
const { page, nextPage, prevPage, goToPage } = usePagination(1, perPage);
const { data, loading, error } = useFetch(
`${url}?page=${page}&limit=${perPage}`
);
return { data, loading, error, page, nextPage, prevPage, goToPage };
}
// Usage — clean API
function ProductCatalog() {
const { data, loading, page, nextPage, prevPage } =
usePaginatedFetch('/api/products', 20);
return (
<div>
{loading ? <Spinner /> : <ProductGrid products={data} />}
<button onClick={prevPage} disabled={page === 1}>Prev</button>
<span>Page {page}</span>
<button onClick={nextPage}>Next</button>
</div>
);
}
Quiz
1. What is the correct way to update state based on the previous state value in useState?
2. Why must cleanup functions be returned from useEffect?
3. What happens if you call hooks inside a conditional or loop?
Flashcards
Question
What is the difference between useState and useReducer, and when should you choose one over the other?
Click to reveal answer
Answer
useState is ideal for simple, independent state values like booleans, strings, or numbers. useReducer is better for complex state objects with multiple sub-values or when the next state depends on the previous state in complex ways. useReducer also centralizes state logic into a reducer function, making it easier to test, debug, and maintain. It follows the action-dispatch pattern similar to Redux.
Question
How do useMemo and useCallback differ, and when should you use each?
Click to reveal answer
Answer
useMemo caches the result of a computation — it returns a memoized value. Use it for expensive calculations like sorting large arrays or computing derived data. useCallback caches a function reference — it returns a memoized function. Use it when passing callbacks to memoized child components or when a function is a dependency of another hook. Both avoid unnecessary work on re-renders but serve different purposes.
Question
What are the two rules of hooks and why do they exist?
Click to reveal answer
Answer
Rule 1: Only call hooks at the top level — never inside loops, conditions, or nested functions. Rule 2: Only call hooks from React functions — components or custom hooks. These rules exist because React tracks hook state by call order. If the order changes between renders, React assigns state to the wrong hooks, causing bugs. The `use` prefix in custom hook names helps React's linter enforce these rules.
Revision Notes
Key Takeaways
- 1. useState manages simple local state; useReducer handles complex state with action-based transitions and is preferred when next state depends on previous state
- 2. useEffect runs side effects after render with a dependency array controlling when it re-runs; always return a cleanup function to prevent memory leaks
- 3. useContext provides values to deeply nested components without prop drilling; combine with useReducer for lightweight global state management
- 4. useRef holds mutable values that persist across renders without triggering re-renders — ideal for DOM access, timers, and storing previous values
- 5. useMemo caches expensive computations; useCallback caches function references — both prevent unnecessary re-renders when used with React.memo
- 6. Custom hooks extract reusable stateful logic; they must start with `use` and follow the rules of hooks (top-level calls only, React functions only)
- 7. Never call hooks conditionally — React relies on consistent call order to map state to hook instances across renders
Interview Tips
- • Explain the difference between useState and useReducer with a concrete example showing when useReducer is the better choice
- • Describe the closure trap in useEffect and demonstrate how functional updates solve the stale state problem
- • Walk through how you would build a useFetch custom hook with loading, error, and abort controller cleanup
- • Discuss when to split context into separate state and dispatch providers to avoid unnecessary re-renders
- • Explain the rules of hooks and why they exist — emphasize that React tracks state by call order
Cheat Sheet
useState
const [val, setVal] = useState(init); setVal(prev => prev + 1); — lazy init with function
useEffect
useEffect(() => { /* side effect */ return cleanup; }, [deps]); — deps control re-runs
useContext
const value = useContext(MyContext); — read context value in any descendant
useReducer
const [state, dispatch] = useReducer(reducer, init); dispatch({ type: 'ACTION' });
useRef
const ref = useRef(init); ref.current persists across renders without re-rendering
useMemo
const val = useMemo(() => expensiveCalc(dep), [dep]); — caches computed value
useCallback
const fn = useCallback((args) => body, [deps]); — caches function reference
Custom Hooks
Function starting with use that calls other hooks; extracts reusable logic
Rules of Hooks
Top-level calls only; React functions only (components or custom hooks)