Core Hooks — useState, useEffect, useRef, useMemo, useCallback
What is a Hook?
A hook is a function that lets you use React features inside function components. Introduced in React 16.8, hooks make it possible to manage state and handle side effects without class components.
Rules of Hooks
- Only call at the top level: Do not call hooks inside loops, conditions, or nested functions.
- Only call from React function components or custom hooks: Do not call hooks from regular JS functions.
// ❌ Rule violation
function Counter({ show }) {
if (show) {
const [count, setCount] = useState(0); // Inside a condition — forbidden!
}
}
// ✅ Correct usage
function Counter({ show }) {
const [count, setCount] = useState(0); // Called at the top level
if (!show) return null;
return <p>{count}</p>;
}
useState
Declare and manage state.
import { useState } from 'react';
function Counter() {
// [currentValue, updaterFunction] = useState(initialValue)
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>+1</button>
<button onClick={() => setCount(count - 1)}>-1</button>
<button onClick={() => setCount(0)}>Reset</button>
</div>
);
}
Functional Update
Use a functional update when computing new state based on the previous state.
// ❌ Stale closure problem possible
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1); // References count from closure
setCount(count + 1); // Same count referenced twice → only increments by 1
}
}
// ✅ Safe with functional update
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(prev => prev + 1); // Guaranteed latest state
setCount(prev => prev + 1); // Increments by 2
}
}
Updating Object/Array State
State must be managed immutably.
function UserForm() {
const [user, setUser] = useState({
name: '',
email: '',
age: 0,
});
// ❌ Direct mutation — will not re-render
function badUpdate() {
user.name = 'John'; // Never do this
setUser(user); // React cannot detect the change (same reference)
}
// ✅ Create a new object with spread
function handleNameChange(e) {
setUser(prev => ({ ...prev, name: e.target.value }));
}
// Array state
const [items, setItems] = useState([]);
function addItem(newItem) {
setItems(prev => [...prev, newItem]); // New array
}
function removeItem(id) {
setItems(prev => prev.filter(item => item.id !== id));
}
function updateItem(id, changes) {
setItems(prev => prev.map(item =>
item.id === id ? { ...item, ...changes } : item
));
}
}
Lazy Initialization
Pass a function as the initial value when the calculation is expensive — it runs only on the first render.
// ❌ Runs parseLocalStorage on every render
const [todos, setTodos] = useState(
JSON.parse(localStorage.getItem('todos') || '[]')
);
// ✅ Runs only on initial mount
const [todos, setTodos] = useState(() =>
JSON.parse(localStorage.getItem('todos') || '[]')
);
useEffect
Handle side effects (data fetching, subscriptions, DOM manipulation, timers, etc.).
import { useState, useEffect } from 'react';
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Runs whenever values in the dependency array change
async function fetchUser() {
setLoading(true);
const res = await fetch(`/api/users/${userId}`);
const data = await res.json();
setUser(data);
setLoading(false);
}
fetchUser();
}, [userId]); // Re-runs whenever userId changes
if (loading) return <p>Loading...</p>;
return <h1>{user?.name}</h1>;
}
Dependency Array Rules
useEffect(() => {
// Runs only on mount/unmount
}, []);
useEffect(() => {
// Runs on every render (no dependency array)
});
useEffect(() => {
// Runs whenever count changes
}, [count]);
Cleanup Function
function Timer() {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setSeconds(prev => prev + 1);
}, 1000);
// Cleanup: called on unmount or before the next effect runs
return () => clearInterval(interval);
}, []);
return <p>Elapsed time: {seconds}s</p>;
}
// Event subscription example
function ResizeTracker() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
const handler = () => setWidth(window.innerWidth);
window.addEventListener('resize', handler);
return () => window.removeEventListener('resize', handler);
}, []);
return <p>Window width: {width}px</p>;
}
Preventing Race Conditions
function SearchResults({ query }) {
const [results, setResults] = useState([]);
useEffect(() => {
let cancelled = false; // Cleanup flag
async function search() {
const data = await fetchSearch(query);
if (!cancelled) { // Only update if the component is still mounted
setResults(data);
}
}
search();
return () => { cancelled = true; };
}, [query]);
return <ResultList items={results} />;
}
useRef
Maintain a value across renders without triggering re-renders, or directly access DOM elements.
import { useRef, useEffect } from 'react';
// DOM reference
function TextInput() {
const inputRef = useRef(null);
function focusInput() {
inputRef.current.focus();
}
return (
<div>
<input ref={inputRef} type="text" />
<button onClick={focusInput}>Focus</button>
</div>
);
}
// Persisting values across renders (no re-render on change)
function StopWatch() {
const [time, setTime] = useState(0);
const intervalRef = useRef(null);
function start() {
intervalRef.current = setInterval(() => {
setTime(prev => prev + 1);
}, 1000);
}
function stop() {
clearInterval(intervalRef.current);
}
return (
<div>
<p>{time}s</p>
<button onClick={start}>Start</button>
<button onClick={stop}>Stop</button>
</div>
);
}
Remembering the Previous Value
function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value;
}, [value]);
return ref.current; // Value from the previous render
}
function Counter() {
const [count, setCount] = useState(0);
const prevCount = usePrevious(count);
return (
<p>Current: {count}, Previous: {prevCount}</p>
);
}
useMemo
Recompute a computationally expensive value only when its dependencies change.
import { useMemo, useState } from 'react';
function ProductFilter({ products, query, category }) {
// Recomputes only when products, query, or category changes
const filteredProducts = useMemo(() => {
console.log('Filtering...');
return products
.filter(p => p.category === category)
.filter(p => p.name.toLowerCase().includes(query.toLowerCase()))
.sort((a, b) => a.price - b.price);
}, [products, query, category]);
return (
<ul>
{filteredProducts.map(p => (
<li key={p.id}>{p.name}: ${p.price}</li>
))}
</ul>
);
}
Caution: useMemo is not a cure-all. Overusing it for simple calculations can actually hurt performance. Measure with the profiler before applying.
useCallback
Recreate a function only when its dependencies change. Primarily used when passing functions to child components.
import { useState, useCallback, memo } from 'react';
// Child component wrapped with memo
const ChildButton = memo(function ChildButton({ onClick, label }) {
console.log(`${label} rendered`);
return <button onClick={onClick}>{label}</button>;
});
function Parent() {
const [count, setCount] = useState(0);
const [text, setText] = useState('');
// ❌ Creates a new function on every render → ChildButton always re-renders
const handleIncrement = () => setCount(prev => prev + 1);
// ✅ No dependencies, so always the same function reference
const handleIncrementMemo = useCallback(() => {
setCount(prev => prev + 1);
}, []);
return (
<div>
<p>Count: {count}</p>
<input value={text} onChange={e => setText(e.target.value)} />
<ChildButton onClick={handleIncrementMemo} label="Increment" />
</div>
);
}
Practical Example: Search Component
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
function SearchBox({ data, onSelect }) {
const [query, setQuery] = useState('');
const [isOpen, setIsOpen] = useState(false);
const inputRef = useRef(null);
const listRef = useRef(null);
// Memoize search results
const filtered = useMemo(() => {
if (!query.trim()) return [];
return data.filter(item =>
item.label.toLowerCase().includes(query.toLowerCase())
).slice(0, 10);
}, [data, query]);
// Memoize item selection handler
const handleSelect = useCallback((item) => {
setQuery(item.label);
setIsOpen(false);
onSelect(item);
}, [onSelect]);
// Close dropdown on outside click
useEffect(() => {
function handleClickOutside(event) {
if (listRef.current && !listRef.current.contains(event.target)) {
setIsOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Open dropdown when query changes
useEffect(() => {
setIsOpen(query.trim().length > 0 && filtered.length > 0);
}, [query, filtered.length]);
return (
<div ref={listRef} className="search-box">
<input
ref={inputRef}
type="text"
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search..."
/>
{isOpen && (
<ul className="dropdown">
{filtered.map(item => (
<li key={item.id} onClick={() => handleSelect(item)}>
{item.label}
</li>
))}
</ul>
)}
</div>
);
}
Pro Tips
1. useEffect Dependency Analysis Tool
Enable the exhaustive-deps rule from eslint-plugin-react-hooks to get warnings about missing dependencies.
2. Prefer Event Handlers over useEffect for Synchronization
// ❌ Synchronizing state with useEffect
useEffect(() => {
if (formData.price && formData.quantity) {
setTotal(formData.price * formData.quantity);
}
}, [formData.price, formData.quantity]);
// ✅ Calculate directly in the event handler
function handlePriceChange(price) {
setFormData(prev => ({
...prev,
price,
total: price * prev.quantity,
}));
}
3. Remembering useMemo vs useCallback
useMemo: caches a value→useMemo(() => computeValue(), [deps])useCallback: caches a function→useCallback(() => doSomething(), [deps])useCallback(fn, deps)is equivalent touseMemo(() => fn, deps).