I remember the first time I tried to refactor a class component to use hooks. I stared at useState for way too long, thinking “so this is just… state? That’s it?” And yeah, that’s basically it. But the subtleties of when and why to use each hook, that took me a lot longer to figure out.
Hooks aren’t hard individually. The tricky part is knowing which combination to reach for and when to stop optimizing. I’ve seen codebases where every single function is wrapped in useCallback “just in case,” and I’ve seen production apps with zero cleanup functions that leak event listeners everywhere. Both are mistakes I’ve made myself.
Here’s what I’ve learned about the five hooks that matter most.
useState: The One You’ll Use Constantly
State management in functional components. If you’ve written any modern React at all, you know this one.
import { useState } from 'react';
const [state, setState] = useState(initialValue);
function Counter() {
const [count, setCount] = useState(0);
const [name, setName] = useState('');
const [todos, setTodos] = useState([]);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter your name"
/>
<button onClick={() => setTodos([...todos, `Task ${todos.length + 1}`])}>
Add Todo
</button>
</div>
);
}
The Stale Closure Trap
This one bit me hard early on. If your new state depends on the previous state, always use the functional update form:
// Not recommended: can give you stale values
setCount(count + 1);
// Recommended: always gets the latest state
setCount(prevCount => prevCount + 1);
I had a counter in a polling component that would randomly skip numbers. Took me an hour to realize the closure was capturing a stale count value.
Split Your State
// Avoid combining unrelated state
const [user, setUser] = useState({ name: '', age: 0, email: '' });
// Better: separate state for independent values
const [name, setName] = useState('');
const [age, setAge] = useState(0);
const [email, setEmail] = useState('');
There’s a common instinct to dump everything into one object. Don’t. When values change independently, keep them separate, it’ll save you from unnecessary re-renders and make your code way easier to reason about.
Lazy Initialization
This one’s subtle but matters if you’re doing expensive work:
// Expensive computation runs on every render
const [value, setValue] = useState(expensiveComputation());
// Lazy initialization - computation runs only once
const [value, setValue] = useState(() => expensiveComputation());
Without the function form, that computation reruns every time the component re-renders, even though the result gets thrown away. I’ve seen this cause noticeable jank on complex forms.
useEffect: Where Things Get Confusing
useEffect is the hook everyone reaches for first and everyone struggles with. It handles side effects, data fetching, subscriptions, DOM manipulation, timers, all that stuff.
import { useEffect } from 'react';
useEffect(() => {
// Side effect logic
return () => {
// Cleanup (optional)
};
}, [dependencies]);
The Dependency Array Is Everything
// Runs on every single render: almost never what you want
useEffect(() => {
console.log('No dependencies');
});
// Runs once on mount
useEffect(() => {
console.log('Empty dependency array');
}, []);
// Runs only when count changes
useEffect(() => {
console.log('Count changed:', count);
}, [count]);
The number of bugs I’ve traced back to a wrong dependency array is… significant. Missing a dependency gives you stale data. Including too many gives you unnecessary re-fetches. There’s no trick to it, you just have to think about what your effect actually depends on.
Data Fetching
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchUser = async () => {
try {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
setUser(data);
} catch (error) {
console.error('Failed to fetch user:', error);
} finally {
setLoading(false);
}
};
fetchUser();
}, [userId]);
if (loading) return <div>Loading...</div>;
return <div>{user?.name}</div>;
}
Quick note: for serious data fetching, you’ll eventually want TanStack Query or SWR instead of raw useEffect. The race condition handling alone is worth it. But for simple cases, this pattern works fine.
Event Listeners and Cleanup
function WindowTracker() {
const [windowSize, setWindowSize] = useState({
width: window.innerWidth,
height: window.innerHeight
});
useEffect(() => {
const handleResize = () => {
setWindowSize({
width: window.innerWidth,
height: window.innerHeight
});
};
window.addEventListener('resize', handleResize);
// Cleanup on unmount
return () => window.removeEventListener('resize', handleResize);
}, []);
return <div>Window: {windowSize.width} x {windowSize.height}</div>;
}
I once shipped a component that added a scroll listener in useEffect without cleanup. It leaked memory on every navigation. The app got slower and slower until someone noticed the tab was using 2GB of RAM. Clean up your listeners. Every time.
Subscriptions
function ChatRoom({ roomId }) {
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
return () => connection.disconnect();
}, [roomId]);
return <ChatWindow />;
}
useCallback: Memoizing Functions
useCallback returns a memoized version of a function. It doesn’t run the function, it just ensures the same function reference is returned between renders, which matters when you’re passing callbacks to optimized children.
import { useCallback } from 'react';
const memoizedCallback = useCallback(() => {
doSomething(a, b);
}, [a, b]);
When It Actually Matters
function Parent() {
const [count, setCount] = useState(0);
const [other, setOther] = useState(0);
// Without useCallback, this function is recreated on every render
const handleClick = useCallback(() => {
console.log('Count:', count);
}, [count]);
return (
<>
<MemoizedButton onClick={handleClick} />
<button onClick={() => setOther(other + 1)}>Other: {other}</button>
</>
);
}
If MemoizedButton is wrapped in React.memo, it’ll re-render every time the parent renders, unless the callback reference stays stable. That’s what useCallback gives you.
It also matters when you’re using a callback inside useEffect:
function SearchComponent({ searchQuery }) {
const handleSearch = useCallback((query) => {
console.log('Searching:', query);
}, []);
useEffect(() => {
handleSearch(searchQuery);
}, [handleSearch, searchQuery]);
return <SearchInput onSearch={handleSearch} />;
}
The Overuse Trap
Here’s the thing nobody tells you: most components don’t need useCallback. If the child isn’t memoized, or if the function isn’t a dependency of some effect, wrapping it in useCallback is just adding overhead for zero benefit.
// Don't wrap everything in useCallback
const handleClick = useCallback(() => {
doSomething();
}, []);
// This is fine in most cases
const handleClick = () => {
doSomething();
};
Profile first. Optimize second.
useRef: The Swiss Army Knife
useRef serves two purposes: accessing DOM elements directly, and storing mutable values that persist across renders without triggering re-renders.
import { useRef } from 'react';
const refContainer = useRef(initialValue);
Focusing Inputs
function FocusInput() {
const inputRef = useRef(null);
const focusInput = () => {
inputRef.current?.focus();
};
return (
<>
<input ref={inputRef} type="text" placeholder="Click button to focus" />
<button onClick={focusInput}>Focus Input</button>
</>
);
}
Storing Intervals and Timers
This is the use case I reach for useRef for most often:
function Timer() {
const [count, setCount] = useState(0);
const intervalRef = useRef(null);
useEffect(() => {
intervalRef.current = setInterval(() => {
setCount(c => c + 1);
}, 1000);
return () => clearInterval(intervalRef.current);
}, []);
const stopTimer = () => {
clearInterval(intervalRef.current);
};
return (
<div>
<p>Timer: {count}s</p>
<button onClick={stopTimer}>Stop</button>
</div>
);
}
If you stored the interval ID in useState, stopping it would trigger a re-render. useRef keeps it around without the render overhead.
Tracking Previous Values
function Counter() {
const [count, setCount] = useState(0);
const prevCountRef = useRef();
useEffect(() => {
prevCountRef.current = count;
}, [count]);
const prevCount = prevCountRef.current;
return (
<div>
<p>Current: {count}, Previous: {prevCount}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}
I use this pattern more than I expected to. Comparing previous and current values is surprisingly common in UI logic.
useMemo: Caching Expensive Work
useMemo memoizes the result of a computation. If the dependencies haven’t changed, it returns the cached result instead of recalculating.
import { useMemo } from 'react';
const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);
When It’s Worth It
function Fibonacci({ n }) {
const fib = useMemo(() => {
const calculateFib = (num) => {
if (num <= 1) return num;
return calculateFib(num - 1) + calculateFib(num - 2);
};
return calculateFib(n);
}, [n]);
return <div>Fibonacci({n}) = {fib}</div>;
}
Filtering and sorting large lists is another good candidate:
function ProductList({ products, filter, sortBy }) {
const filteredAndSortedProducts = useMemo(() => {
let result = [...products];
if (filter) {
result = result.filter(p => p.category === filter);
}
if (sortBy) {
result.sort((a, b) => a[sortBy] - b[sortBy]);
}
return result;
}, [products, filter, sortBy]);
return (
<ul>
{filteredAndSortedProducts.map(product => (
<li key={product.id}>{product.name}</li>
))}
</ul>
);
}
Object Reference Stability
This one’s less obvious but useful:
function UserSettings({ userId }) {
const defaultSettings = useMemo(() => ({
theme: 'dark',
notifications: true,
language: 'en'
}), []);
const [settings, setSettings] = useState(defaultSettings);
// defaultSettings won't change on re-renders
return <SettingsForm settings={settings} />;
}
Without useMemo, that object gets recreated every render, which could trigger unnecessary re-renders in child components.
Putting It All Together
Here’s a dashboard that uses all five hooks:
function Dashboard({ userId }) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState('all');
const searchInputRef = useRef(null);
const renderCount = useRef(0);
// Track render count (debugging)
renderCount.current += 1;
// Fetch data when userId changes
useEffect(() => {
const fetchData = async () => {
setLoading(true);
try {
const response = await fetch(`/api/users/${userId}/data`);
const result = await response.json();
setData(result);
} catch (error) {
console.error('Fetch failed:', error);
} finally {
setLoading(false);
}
};
fetchData();
}, [userId]);
// Memoize filtered data
const filteredData = useMemo(() => {
if (!data) return [];
return data.items.filter(item => {
if (filter === 'all') return true;
return item.category === filter;
});
}, [data, filter]);
// Memoize filter handler
const handleFilterChange = useCallback((newFilter) => {
setFilter(newFilter);
}, []);
// Focus search input
const focusSearch = () => {
searchInputRef.current?.focus();
};
if (loading) return <div>Loading...</div>;
return (
<div>
<input ref={searchInputRef} placeholder="Search..." />
<button onClick={focusSearch}>Focus Search</button>
<FilterSelector value={filter} onChange={handleFilterChange} />
<DataList items={filteredData} />
<small>Render count: {renderCount.current}</small>
</div>
);
}
Quick Reference
| Hook | Purpose | Re-renders Component? | Use When |
|---|---|---|---|
| useState | State management | Yes | You need reactive state |
| useEffect | Side effects | No (but can trigger state updates) | Data fetching, subscriptions, DOM manipulation |
| useCallback | Memoize functions | No | Passing stable callbacks to optimized children |
| useRef | DOM access / mutable values | No | Direct DOM access, storing mutable values |
| useMemo | Memoize values | No | Expensive computations, stable object references |
The Honest Performance Advice
Don’t optimize prematurely. I’ve seen developers wrap everything in useMemo and useCallback “just to be safe,” and the result is code that’s harder to read with zero measurable performance gain.
Only reach for useMemo and useCallback when you have an actual performance issue or genuinely need reference stability (like for memoized children or effect dependencies). Use the React DevTools Profiler to find real bottlenecks. Keep your dependency arrays accurate, missing dependencies cause bugs, extra ones cause unnecessary work.
Install eslint-plugin-react-hooks. It’ll catch most of the common mistakes before they reach production.
The best optimization is usually not the one you think it is. Sometimes it’s just splitting a component into smaller pieces so React can skip rendering the parts that didn’t change.
Member discussion
0 commentsStart the conversation
Become a member of >hacksubset_ to start commenting.
Already a member? Sign in