
You know useState and useEffect. They're your daily drivers.
But there's a second tier of hooks that most devs treat like "advanced stuff" or "optimization tricks." They avoid them until they absolutely have to use them.
That's backwards.
useRef, useMemo, and useCallback aren't optimization hooks. They're correctness hooks that happen to also improve performance. And once you understand them, you'll wonder how you ever wrote React without them.
Let's fix that.
useRef: the escape hatch nobody uses enough
Most devs think useRef is for "accessing DOM elements." That's true, but it's only half the story.
useRef is a mutable container that survives re-renders without triggering them.
Read that again. It's the key to everything.
The DOM use case (the one you know)
function TextInput() {
const inputRef = useRef(null);
function handleClick() {
inputRef.current.focus();
}
return (
<>
<input ref={inputRef} />
<button onClick={handleClick}>Focus</button>
</>
);
}
You attach a ref to a DOM element, and inputRef.current gives you direct access to it. Standard stuff.
The use case nobody talks about
Here's where it gets interesting. Need to store a value that:
- Persists across renders
- Doesn't trigger a re-render when it changes
- Can be read synchronously (unlike state)
That's useRef.
function Stopwatch() {
const [time, setTime] = useState(0);
const intervalRef = useRef(null);
function start() {
intervalRef.current = setInterval(() => {
setTime(t => t + 1);
}, 1000);
}
function stop() {
clearInterval(intervalRef.current);
}
return (
<>
<p>{time}s</p>
<button onClick={start}>Start</button>
<button onClick={stop}>Stop</button>
</>
);
}
Why not store intervalId in state? Because changing it would trigger a re-render. And you don't want to re-render just because you stored a timer ID.
The "previous value" pattern
Ever needed to compare current props with previous props?
function useWhyDidYouUpdate(name, props) {
const previousProps = useRef(props);
useEffect(() => {
const changes = {};
Object.keys(props).forEach(key => {
if (previousProps.current[key] !== props[key]) {
changes[key] = { from: previousProps.current[key], to: props[key] };
}
});
if (Object.keys(changes).length > 0) {
console.log('[why-did-you-update]', name, changes);
}
previousProps.current = props;
});
}
This hook tells you exactly which props caused a re-render. Invaluable for debugging.
The mental model
Think of useRef as a box sitting next to your component.
The box doesn't care about renders. It just holds whatever you put in it.
useMemo: stop recalculating what hasn't changed
Here's a component that filters a huge list:
function UserList({ users, filter }) {
const filteredUsers = users.filter(user =>
user.name.toLowerCase().includes(filter.toLowerCase())
);
return (
<ul>
{filteredUsers.map(user => <li key={user.id}>{user.name}</li>)}
</ul>
);
}
Every render, you filter the entire list. Even if users and filter haven't changed.
With 10 users? Who cares. With 10,000 users? Your UI stutters.
The fix
function UserList({ users, filter }) {
const filteredUsers = useMemo(() => {
return users.filter(user =>
user.name.toLowerCase().includes(filter.toLowerCase())
);
}, [users, filter]);
return (
<ul>
{filteredUsers.map(user => <li key={user.id}>{user.name}</li>)}
</ul>
);
}
Now the filtering only runs when users or filter actually change. Any other re-render (parent state change, context update, etc.) skips the expensive work.
The real reason useMemo exists
Performance is nice. But there's a deeper reason: referential stability.
function Parent() {
const [count, setCount] = useState(0);
// New object every render
const config = { theme: 'dark', size: 'large' };
return <Child config={config} />;
}
const Child = memo(({ config }) => {
console.log('Child rendered');
return <div>{config.theme}</div>;
});
Child is wrapped in memo, but it still re-renders every time Parent renders. Why? Because config is a new object every time. Same values, different reference.
function Parent() {
const [count, setCount] = useState(0);
// Same object reference unless values change
const config = useMemo(() => ({ theme: 'dark', size: 'large' }), []);
return <Child config={config} />;
}
Now Child only re-renders when the config actually changes.
When NOT to use useMemo
Don't wrap everything in useMemo. It has overhead. Use it when:
- The calculation is actually expensive (measure first!)
- You're passing objects/arrays to memoized children
- The value is used as a dependency in other hooks
// ❌ Overkill
const doubled = useMemo(() => count * 2, [count]);
// ✅ Just compute it
const doubled = count * 2;
The mental model
Think of useMemo as a cache with automatic invalidation.
useCallback: useMemo for functions
Here's the thing: useCallback is literally just useMemo for functions.
// These are equivalent
const handleClick = useCallback(() => {
console.log('clicked');
}, []);
const handleClick = useMemo(() => {
return () => console.log('clicked');
}, []);
So why does useCallback exist? Convenience. Functions are so commonly passed as props that they got their own hook.
The problem it solves
function SearchPage() {
const [query, setQuery] = useState('');
function handleSearch(term) {
// fetch results...
}
return <SearchInput onSearch={handleSearch} />;
}
const SearchInput = memo(({ onSearch }) => {
console.log('SearchInput rendered');
return <input onChange={e => onSearch(e.target.value)} />;
});
SearchInput is memoized, but it re-renders every time SearchPage renders. Because handleSearch is a new function every render.
function SearchPage() {
const [query, setQuery] = useState('');
const handleSearch = useCallback((term) => {
// fetch results...
}, []);
return <SearchInput onSearch={handleSearch} />;
}
Now handleSearch keeps the same reference. SearchInput stays memoized.
The useEffect dependency trap
This is where useCallback really shines:
function ChatRoom({ roomId }) {
const [messages, setMessages] = useState([]);
function createConnection() {
return connectToRoom(roomId);
}
useEffect(() => {
const connection = createConnection();
connection.connect();
return () => connection.disconnect();
}, [createConnection]); // 🔴 New function every render = infinite loop!
}
The effect depends on createConnection, which is new every render. So the effect runs every render. Infinite connection/disconnection loop.
function ChatRoom({ roomId }) {
const [messages, setMessages] = useState([]);
const createConnection = useCallback(() => {
return connectToRoom(roomId);
}, [roomId]);
useEffect(() => {
const connection = createConnection();
connection.connect();
return () => connection.disconnect();
}, [createConnection]); // ✅ Only changes when roomId changes
}
Or even better, move the function inside the effect:
useEffect(() => {
function createConnection() {
return connectToRoom(roomId);
}
const connection = createConnection();
connection.connect();
return () => connection.disconnect();
}, [roomId]); // ✅ Clean and simple
The state updater trick
Need to update state inside a callback but don't want the callback to depend on state?
// ❌ handleAdd changes every time todos changes
const handleAdd = useCallback((text) => {
setTodos([...todos, { id: Date.now(), text }]);
}, [todos]);
// ✅ handleAdd never changes
const handleAdd = useCallback((text) => {
setTodos(prev => [...prev, { id: Date.now(), text }]);
}, []);
The functional update pattern lets you remove todos from dependencies entirely.
The mental model
The cheat sheet
| Hook | What it caches | Use when |
|---|---|---|
useRef | Mutable value (no re-render) | DOM access, timers, previous values |
useMemo | Computed value | Expensive calculations, stable references |
useCallback | Function reference | Passing callbacks to memoized children, effect dependencies |
The trio in action
Here's all three working together:
function ProductList({ products, onSelect }) {
const searchRef = useRef(null);
const [filter, setFilter] = useState('');
// Memoize expensive filtering
const filteredProducts = useMemo(() => {
return products.filter(p =>
p.name.toLowerCase().includes(filter.toLowerCase())
);
}, [products, filter]);
// Stable callback for child components
const handleSelect = useCallback((product) => {
onSelect(product);
searchRef.current?.focus();
}, [onSelect]);
return (
<div>
<input
ref={searchRef}
value={filter}
onChange={e => setFilter(e.target.value)}
placeholder="Search..."
/>
{filteredProducts.map(product => (
<ProductCard
key={product.id}
product={product}
onSelect={handleSelect}
/>
))}
</div>
);
}
const ProductCard = memo(({ product, onSelect }) => {
return (
<div onClick={() => onSelect(product)}>
{product.name}
</div>
);
});
useReffor DOM access without re-rendersuseMemofor expensive filteringuseCallbackfor stable function referencememoto complete the optimization chain
What's next?
You now have the core hooks that handle 95% of React development.
But there's a third tier: the hooks that unlock entirely new patterns. useReducer for complex state machines. useLayoutEffect for DOM measurements. useImperativeHandle for exposing custom APIs.
In the next article, we'll explore these advanced patterns and when to reach for them.
Continue to part 3: Hooks that unlock new patterns →
Got questions? Found a bug in my code? Hit me up on LinkedIn or check out more on my blog.