Image with words what react actually is and what it is not.

Differences Between useMemo and useCallback

Part 3 of 10 in React Lunch & Learn

Do I Actually Need an Effect for This? Understanding the React Render Cycle

Part of the React Lunch & Learn series. This is the foundational one—referential identity and the render cycle are the bedrock that custom hooks, state management, and everything else in the series is built on. If a later post assumes you know why a new object breaks a dependency array, this is where that gets explained.

A confession to open with: I once took a backend server to its knees with a React component. No load test, no clever exploit—just a useEffect that didn’t clean up after itself. The component re-rendered, re-subscribed to a WebSocket, re-subscribed again, and again, until we were opening hundreds of connections and the server begged for mercy. We spent an embarrassing amount of time asking “why is the app so slow after clicking around for a while?” before anyone thought to look at useEffects.

TL;DR: useEffect is for syncing your component with something outside React’s render—the network, a browser API, a DOM listener, storage. React decides whether something “changed” by comparing references, not contents. A lot of what ends up in state can be calculated during render instead of synced with an effect. useMemo remembers a value, useCallback remembers a function, and the new React Compiler handles a lot of that memoization for you—but the compiler can’t rescue a misused effect. The sections below are the how and the why.

That server-crippling useEffect, and most of the React performance bugs I’ve chased since, come down to two things: not understanding what useEffect is for, and not understanding how the render cycle decides whether something “changed.” Both have the same antidote—one question you ask before you reach for any hook at all:

Do I actually need this?

Ask it before each effect, memo, and callback you’re about to write. Most of the time the answer is no, and the code you don’t write is the code that never breaks. The rest of this post is really just that question, applied to each hook in turn—starting with the one that caused my outage.

What useEffect Is Actually For

Let’s define useEffect by what it is not. An effect isn’t a place to copy one piece of state into another, or a general-purpose “run this code sometime” box, or a hot-potato machine for passing values between state variables.

useEffect is for synchronizing your component with an external system—something React’s render doesn’t control. An effect is the loading dock on the back of the building: the one door where deliveries from the outside world are supposed to come in and go out. The React docs are blunt about this: Effects are “an escape hatch from the React paradigm” that let you “step outside of React.” External means things like:

  • A network request (REST API, GraphQL)
  • A WebSocket subscription
  • A browser API: localStorage, sessionStorage, IndexedDB, a resize listener, a ResizeObserver
  • Direct DOM manipulation or a non-React third-party widget

If you’re not reaching outside React, you probably don’t need an Effect. Hold onto that—we’ll come back to it.

When you do have a legitimate effect, the second half of the equation is when the effect runs, and the dependency array controls that. There are three “flavors,” and the three of them cause a lot of confusion:

// 1. Empty array → runs once, after the component mounts.
// Cleanup runs once, when it unmounts.
useEffect(() => {
const controller = new AbortController();
fetchMovies({ signal: controller.signal }).then(setMovies);
return () => controller.abort(); // cleanup
}, []);
// 2. No array at all → runs after EVERY render.
// Almost always a bug. Usually a missing comma + dependency array.
useEffect(() => {
console.log("this fires constantly");
});
// 3. Array with dependencies → runs after mount, and again whenever
// a listed dependency changes (compared with Object.is).
useEffect(() => {
localStorage.setItem("selectedGenre", selectedGenre);
}, [selectedGenre]);

That second flavor—no array—is the one I tell people to treat as a code smell. In years of writing React I’ve never had a legitimate need for the no-array form. When you see a component glitching out or pinning the CPU, check whether someone dropped the dependency array—you’ll find that’s the culprit more often than you’d expect.

But notice what flavor three assumed: that React can tell when a dependency “changed.” That comparison—Object.is—is the gear all three of these hooks turn on, and the comparison is where most of the surprises come from. So before we go any further, let’s open it up.

The Bug Factory: Referential Identity

The single most important thing to internalize—it powers all three hooks—is that React compares dependencies by reference, using Object.is. React doesn’t deep-compare your objects or walk your arrays checking keys and values, and it shouldn’t—React has no idea what shape your data is, and has no business caring.

Picture an object as a house and its reference as the street address. React never goes inside; it only checks the address. Renovate the kitchen—mutate the object in place—and the address stays the same, so React drives past and sees no change. Build an identical house on a new lot—spread into a new object—and the address changed, so React notices immediately, even if the rooms inside are identical.

For primitives the address check is pretty intuitive: 5 is 5, "horror" is "horror". For objects, arrays, and functions, however, the house rule is the source of half your bugs:

const a = { rating: 1 };
a.rating = 2; // mutating in place—SAME reference. React sees no change.
const b = { rating: 1 };
const c = { ...b, rating: 2 }; // NEW object—new reference. React sees a change.

Mutating in place is invisible to React’s change detection. That’s why you spread into a new object or array when you update state, instead of poking at the old one:

// ❌ React won't notice
movies[0].watched = true;
setMovies(movies);
// ✅ New array, new references where they changed
setMovies(movies.map((m, i) => (i === 0 ? { ...m, watched: true } : m)));

The flip side is where it bites: an object literal, array literal, or function defined inside your component body is a brand-new house at a brand-new address on each render. So this, for example:

<MovieContext.Provider value={{ providerId, providerType }}>

creates a new value object on each render, so each consumer will re-render along with it—even when providerId and providerType haven’t changed. No hypothetical there; that’s the default behavior you have to actively prevent. Memoization—remembering a previous answer instead of rebuilding it from scratch—is how you prevent the churn, and memoization is coming up next.

You Don’t Need an Effect for This

With referential identity in hand, let’s return to that question from the top—do I actually need this?—and apply it to the effect I see misused most. I call the anti-pattern hot-potatoing state—the docs call it cascading effects—and once the pattern gets into a codebase it spreads like a virus, because each new feature copies whatever’s sitting next to it.

// ❌ The effect sandwich
function GenreFilter() {
const [count, setCount] = useState(0);
const [hasEnough, setHasEnough] = useState(false);
useEffect(() => {
if (count > 5) setHasEnough(true);
}, [count]);
useEffect(() => {
if (hasEnough) console.log("enough movies selected");
}, [hasEnough]);
// ...
}

Two state variables, two effects, and a value (hasEnough) that gets hot-potatoed from count through an effect into more state. The sandwich is slow (each set triggers another render pass), hard to follow, and React’s linter will actively tell you not to do it.

hasEnough doesn’t need to be state at all. The value carries no independent information; it’s derived from count. So derive it, during render:

// ✅ Derived state—no effects, no extra state
function GenreFilter() {
const [count, setCount] = useState(0);
const hasEnough = count > 5;
// ...
}

That line re-evaluates on each render, which is what you want—it behaves like the no-array effect flavor, except the work happens in the render body as one readable line instead of an effect sandwich. The official guidance is identical: when something can be calculated from existing props or state, calculate it during rendering. You avoid the cascading updates, you delete code, and you remove an entire class of “these two state variables drifted out of sync” bugs.

Deriving inline is also good architecture. The pattern I push for is dumb components fed by smart hooks: tuck the external stuff (the WebSocket, the subscription) behind a custom hook like useChatRoom(serverUrl, roomId), so the component just calls the hook and doesn’t care how the connection works. Whatever can be computed from props and state, derive inline.

Cleanup, or How I DDoS’d My Own WebSocket Server

Back to my opening confession. When an Effect does talk to an external system, the effect almost always needs to clean up after itself, because effects are additive. Each run of a subscribe-style effect, for example, adds another listener, like signing up for a new gym membership each time you walk in the door. The old memberships don’t get cancelled, so the dues stack up, and one day your bank account—or in my case, the server—falls over:

// ❌ Adds a new listener every render. After 10 minutes the app crawls.
useEffect(() => {
document.addEventListener("blur", () => doStuff());
});
// ✅ Add on setup, remove the SAME reference on cleanup
useEffect(() => {
const handleBlur = () => doStuff();
document.addEventListener("blur", handleBlur);
return () => document.removeEventListener("blur", handleBlur);
}, []);

The reference detail is what makes or breaks the cleanup. removeEventListener only cancels a membership if you hand it the same card you signed up with—the same function reference you added. My WebSocket bug was exactly this: the cleanup passed a freshly-created function (a curried handler—a function that builds and returns another function—so a brand-new reference each time), removeEventListener found no match, the old subscription stayed alive, and we kept stacking connections until the server tipped over. After ten minutes of re-renders, the symptom looked like a mysterious backend problem. The cause was one mismatched function reference.

The takeaway: watch your references, and clean up your effects. Those two—stale or mismatched references and missing cleanup—are the biggest families of bugs I’ve run into in React.

That’s useEffect covered: what it’s for, when it runs, and how to derive your way out of needing it. The remaining two hooks aren’t about effects at all; they exist to tame the “new house, new address, each render” problem we met in the bug factory, starting with useMemo.

useMemo: Remember the Answer, Not the Work

useMemo caches the result of a calculation between renders. You give it a function and a dependency array; the hook runs the function, stores the value, and on later renders returns the stored value unless a dependency changed (by Object.is, same as the other hooks). useMemo is the sticky note on your monitor with the answer to a long calculation already written on it—you only redo the math when one of the inputs changes.

I think there are two legitimate reasons to reach for useMemo:

1. A calculation that’s really expensive. Filtering and sorting a large movie list, for example:

function MovieList({ movies, query, sortBy }) {
const visibleMovies = useMemo(
() => movies
.filter((m) => m.title.toLowerCase().includes(query.toLowerCase()))
.sort((a, b) => (sortBy === "rating" ? b.rating - a.rating : a.title.localeCompare(b.title))),
[movies, query, sortBy]
);
return visibleMovies.map((m) => <MovieCard key={m.id} movie={m} />);
}

The filter-and-sort will recompute only when movies, query, or sortBy change—not on unrelated re-renders.

2. Stabilizing a reference you pass down. Remember the context provider that built a new value object every render? Fix it with useMemo:

const value = useMemo(
() => ({ providerId, providerType }),
[providerId, providerType]
);
return <MovieContext.Provider value={value}>{children}</MovieContext.Provider>;

Now value keeps the same address until providerId or providerType change, and the consumers will stop re-rendering for no reason. The stable value matters most for a provider that sits below the root, where a busy parent can otherwise trigger rerender-after-rerender.

A word of restraint: useMemo is a performance optimization, not a correctness tool. If your code only works with the memo, you have a bug hiding underneath—find that first. And don’t sprinkle useMemo everywhere; the docs note that over-memoizing makes code harder to read, and a single “always new” value upstream can break memoization for a whole component anyway.

useCallback: The Same Idea, Aimed at Functions

useCallback is useMemo with a function-shaped hole. In fact that’s literally how the React docs describe the hook—useCallback(fn, deps) is equivalent to useMemo(() => fn, deps). useMemo remembers the value your function returns; useCallback remembers the function itself.

Why would you care about a function’s reference? A function declared in your component body gets a new phone number each render. A memo-wrapped child is screening its calls—same number as last time, don’t pick up, skip the re-render. When the number changes with each render, the call looks like it’s from a stranger, so the child picks up every time. An unstable function reference breaks two things in practice:

const MovieCard = memo(function MovieCard({ movie, onSelect }) {
// expensive enough that we wrapped it in memo()
return <button onClick={() => onSelect(movie.id)}>{movie.title}</button>;
});
function MovieList({ movies }) {
// ❌ new function every render → MovieCard re-renders even though memo'd
// const handleSelect = (id) => openModal(id);
// ✅ stable reference → memo'd children actually skip re-rendering
const handleSelect = useCallback((id) => openModal(id), []);
return movies.map((m) => (
<MovieCard key={m.id} movie={m} onSelect={handleSelect} />
));
}

Without useCallback, handleSelect is a new reference on each render, so all the MovieCards re-render even though you wrapped them in memo specifically to prevent that. The two canonical uses are this one—passing a stable callback to a memo-wrapped child—and feeding a stable function into another hook’s dependency array (a useEffect that depends on it, for example).

The same restraint applies, harder: don’t wrap every function in useCallback. The hook isn’t free—React does extra bookkeeping to store and compare each one. Reserve useCallback for the cases above, plus the occasional stale-closure bug where you really need a function’s identity to hold still.

A Decision Rule, and What the Compiler Changes

When you’re staring at a value wondering which tool to grab, ask in order:

  1. Does this need to be state at all, or can I derive it during render? Derive if you can. Deriving kills most effects outright.
  2. Does it need to sync with something outside React’s render—the network, a browser API, a DOM listener, storage? That’s the useEffect job.
  3. Is it a value I want to remember? useMemo.
  4. Is it a function whose reference needs to stay stable? useCallback.

The one-liner I leave people with: useEffect is for synchronization, useMemo is for memoization, and useCallback is useMemo for functions.

Now the modern asterisk. As of React 19 and React Compiler v1.0 (stable since October 2025), the compiler automatically memoizes values and functions for you, which “eliminates the need for manual useMemo, useCallback, and React.memo in many cases.” That’s a big shift since I first gave this talk: manual memoization is moving from a daily default toward a deliberate exception. But—and this is the important part—the compiler does not save you from misusing useEffect. The compiler won’t untangle an effect sandwich or add your missing cleanup function, and it won’t derive state you should have derived yourself. The mental model in this post is the part the compiler can’t automate, which is why the model is worth carrying even into a compiler-enabled codebase.

Where This Sits in the Series

This is the groundwork. Referential identity, the render cycle, and “derive, don’t sync” are the assumptions the other posts in the series lean on. Once those three click, the rest is mostly application.

Related in this series:

See you in the next one.