Skip to main content

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

  1. Only call at the top level: Do not call hooks inside loops, conditions, or nested functions.
  2. 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 valueuseMemo(() => computeValue(), [deps])
  • useCallback: caches a functionuseCallback(() => doSomething(), [deps])
  • useCallback(fn, deps) is equivalent to useMemo(() => fn, deps).