The Best Patterns for React State Management in 2026

If you’re still picking a state management library because of a Twitter argument from 2019, you’ve missed the point. In 2026, React state management isn’t a library contest. It’s a discipline. The ecosystem has calmed down. Redux vs. Context vs. MobX debates feel like ancient history now. What actually matters is granular control, keeping state close to where it’s used, and knowing exactly when to reach for a server cache instead of a client store. I still see teams dumping everything into a single global object or slapping a state machine on a dropdown toggle. This article is a hard reset—patterns I rely on to ship things that don’t fall apart a month later.

Developer working on React code with state diagrams on a monitor

The Three-State Rule: Local, Page, and Global

Most React apps don’t need more than three buckets. The first mistake? Treating all state the same. The second? Prematurely dumping everything into a global store because it feels organized.

Local state stays inside a single component or a tiny subtree. useState or useReducer is the whole toolbox here. A toggle, a form input, a dropdown’s open/closed status—seriously, they don’t need a home in Zustand or a context provider. Keeping them local slashes re-renders and makes the component a self-contained unit you can test without a bunch of providers.

Page state is for a route or a feature screen. Think multi-step checkout or a dashboard filter panel. The pattern is a slice owned by a route-level provider. React Context plus useReducer works if you memoize the value and split context consumers carefully. But honestly, in 2026 a lot of us have moved to lightweight external stores like Zustand or Jotai for page-level stuff. They dodge the context-re-render tax completely. You spin up a store instance scoped to the page lifecycle and destroy it when the route unmounts. Clean and fast.

Global state is the stuff genuinely shared across unrelated parts of the app—authenticated user, theme prefs, feature flags. This is where one well-typed store earns its keep. Zustand is still the go-to for its tiny API and React 19 compatibility. Jotai’s atomic model is also solid if you like building state from small composable units. The trick is keeping the global store thin. If it starts hoarding UI state for a specific page, yank it out.

Abstract diagram of state flow between React components

Server State vs. Client State: Stop Merging Them

The biggest anti-pattern I still stumble over in 2026 is treating server-fetched data as client state. You fetch a list of users, shove it into a Redux slice, write reducers to update, delete, and sort—congratulations, you’re now manually maintaining a cache. Please don’t.

Server state is anything born from an API where the source of truth lives on the server. React Query (TanStack Query) and SWR handle caching, background refetching, optimistic updates, and cache invalidation better than any hand-rolled solution I’ve ever seen. They also plug into React 19’s streaming and Suspense boundaries. The pattern: use React Query for all GET requests and cache interactions. A lightweight client store handles only UI state that isn’t chained to server data.

For mutations, the same libraries give you useMutation hooks that track loading, error, and success states. There’s zero reason to dispatch a Redux action that calls an API and then manually updates three slices. Let the server-state library invalidate caches and trigger re-fetches automatically. It’s faster and you write fewer bugs.

A practical rule: if the data has a URL endpoint, it’s server state. If it’s ephemeral and only exists in the browser tab—like a modal’s visibility or a temporary draft—it’s client state. No exceptions unless you enjoy pain.

Derived State and Selectors: Compute, Don’t Duplicate

Storing derived values in state is a bug factory. If fullName is always ${firstName} ${lastName}, don’t put it in the store. Derive it in a selector or right in the component with useMemo if the computation actually costs something.

In Zustand, selectors let you subscribe to a computed slice. The component only re-renders when the output of that selector changes. This kills the need for useEffect chains that sync state A to state B. Jotai’s derived atoms do the same thing with a different syntax. The mindset shift: treat state as a directed acyclic graph of dependencies, not a flat object you manually synchronize until your brain melts.

This also covers filtering and sorting. Got a list of items and an active filter? Derive the filtered list instead of storing it separately. React Query’s select option lets you transform server data before it reaches the component, so the cache stays normalized and the UI layer stays simple.

Immutable Updates and the Mutable Trap

React’s rendering model lives and dies by referential equality. Mutating state directly is still the number-one cause of stale UI and missed re-renders. In 2026, Immer is the standard fix—it lets you write straightforward mutable-style logic inside immutable update functions. It’s baked into Redux Toolkit and available as a standalone produce function for Zustand or useReducer.

The pattern: use Immer whenever an update touches nested objects or arrays. For flat state, the spread operator is fine. But the moment you’re updating users[3].profile.settings.theme, grab produce. It’s quicker to write and way easier to review than multi-level spreads that look like someone played Twister with the dot operator.

TypeScript makes this safer. Define your state shape with strict types, and Immer enforces immutability while letting you write draft.user.name = 'new name'. The combo of TypeScript and Immer catches accidental mutations at build time instead of runtime, which means you fix them before your coffee gets cold.

Close-up of code editor with TypeScript types and React state

URL as State: The Forgotten Store

The URL is a state container that survives page reloads, supports browser navigation, and is shareable by default. In 2026, more teams treat URL parameters as the primary source of truth for search queries, pagination, and filter selections. React Router’s search params hooks or Next.js’s useSearchParams make this feel almost too easy.

The pattern: lift transient UI state that defines what the user is viewing into the URL. A search input’s value can stay in local state, but the applied search term belongs in the query string. Page numbers, sort order, tab selection—all of it goes into the URL. This eliminates the dance of syncing a Redux store with browser history and makes deep linking free.

When you combine this with React Query, you can derive the query key directly from URL parameters. Change the URL, React Query refetches automatically if the data isn’t already cached. You get a single flow: user action → URL update → derived query key → server-state fetch → UI render. No intermediate client store required. It’s elegant, and it frustrates me that more codebases don’t do it.

Performance Patterns That Actually Matter

State management performance in React comes down to one thing: stopping unnecessary re-renders. The tools give you the knobs, but you have to know which ones turn and which ones are just decoration.

  • Split context providers vertically. Instead of one giant context with twenty values, break it into smaller contexts that each own a single responsibility. A component consuming only CurrentUserContext won’t re-render when ThemeContext updates. It’s borderline therapeutic.
  • Use atomic state for high-frequency updates. Jotai’s atoms subscribe at the individual value level. If you’re updating a counter 60 times per second, an atomic model prevents the entire component tree from reconciling. Zustand’s selectors get you similar granularity.
  • Memoize selectors with useShallow. Zustand’s useShallow hook does a shallow comparison on the returned object, so a component that selects { user, theme } won’t re-render if only notifications changed. This is simpler and less error-prone than wrapping everything in useCallback and useMemo by hand.
  • Lazy load state slices. For large apps, code-split the state initialization. Load the admin dashboard state only when that route mounts. Zustand’s create function works at module scope, but you can dynamically create stores inside useEffect and clean them up on unmount.

The overarching principle: measure before you optimize. React DevTools Profiler shows exactly which components re-render and why. Fix the hotspots, not the entire app. Your future self will thank you when you’re not untangling a web of React.memo wrappers.

Putting It Together: A 2026 State Architecture

Here’s a concrete architecture that fits the patterns above. It’s not hypothetical—this is what I ship in production React apps today.

  • TanStack Query for all server state. Configure a query client with sensible defaults: stale time of 30 seconds, retry 1, garbage collection of 10 minutes. Use queryOptions objects to share query definitions across components. It keeps things tidy and predictable.
  • Zustand for global client state. Keep it under 10 keys. Auth token, user preferences, feature flags. Use persist middleware for anything that needs to survive a page refresh.
  • React Context + useReducer for page-level state that multiple components on a route share. Wrap the route segment, not the entire app. It’s a focused scope, not a blanket.
  • URL search params for any state that defines the view: filters, pagination, sort. Sync them with React Query’s query keys using a custom hook.
  • Local state for everything else. Don’t overthink it. A useState in the component is the most performant option you have.

This stack strips out the middleware layers, the boilerplate, and the mental overhead that made earlier React state management feel like a part-time job. You write less code, and the data flow is explicit enough that a new developer can trace it in a single reading. That’s the goal—clarity that doesn’t vanish when the sprint pressure hits.

Frequently Asked Questions

Is Redux still relevant in 2026?

Redux Toolkit is still maintained and works fine, but it’s not the default pick for new projects anymore. Its strength is in large, established codebases with complex reducer logic and middleware needs. For most new apps, Zustand or Jotai paired with React Query covers the same ground with less ceremony. If you’re on an existing Redux codebase, migrating away isn’t a high priority—Redux Toolkit’s patterns are solid enough. But for greenfield work, start lighter and don’t look back.

How do I handle form state without a library?

React Hook Form is still the standard, but for simple forms, React’s built-in useActionState (stable in React 19) handles server actions and validation without extra dependencies. The pattern: use useActionState for forms that submit to a server action, and fall back to React Hook Form for complex client-side validation with dynamic fields. Keep form state local to the form component unless multiple pages share the same form instance, which almost never happens in practice.

When should I use useReducer instead of useState?

Reach for useReducer when the next state depends on the previous state in a non-trivial way, or when one action updates multiple state values at once. A toggle is useState. A multi-step wizard where “next” increments the step, saves the current step’s data, and resets validation errors is useReducer. It also shines when you need to pass a dispatch function down to deeply nested components instead of a cascade of setter callbacks.

Can I mix Zustand and React Query in the same project?

Absolutely. They solve different problems. React Query manages server cache; Zustand manages client-side state that isn’t tied to a specific API endpoint. A common pattern is to use Zustand for the authenticated user object (fetched once and needed everywhere) and React Query for all other API data. They don’t step on each other’s toes, and you can even reference a Zustand store inside a React Query queryFn if you need an auth token for a request. It’s a clean division of labor.

React State Patterns That Actually Hold Up in 2026

Where We Are with State in 2026

React state management finally grew up. The old Redux boilerplate and those sprawling useEffect chains are mostly dead in serious codebases. What’s taken their place is a fairly quiet, practical consensus around a few patterns that don’t buckle when your app gets big. Nobody’s arguing about which library is best anymore. The real question is where you put the state and why you put it there. In 2026, solid React teams treat state like a design constraint, not a shopping trip for tools.

I’m Suki Watanabe. I’ve spent the last few years pulling apart state logic in production systems—fintech dashboards, real-time collaboration canvases, content-heavy consumer apps. The patterns here are the ones I reach for first. No fluff. No ceremony. Just what ships and stays shipped.

Developer working on a React state management architecture on a dual-monitor setup
Modern React state work is mostly about where data lives, not which library you pick.

Pattern 1: Server State Stays on the Server

Most teams can recite the difference between server state and client state by now. Actually sticking the landing? That still slips. In 2026, the pattern that keeps things sane is treat the server as the source of truth, and treat the cache as a replica. TanStack Query (what we used to call React Query) and SWR both handle this well, but the pattern is bigger than any single library.

The rule in practice: if the data came from an API, don’t copy it into useState or a global store. Let the fetching library own the lifecycle—stale-time policies, background refetching, optimistic mutations. For a dashboard pulling portfolio data, I stick to one useQuery call per entity, pull the UI state right off the cache, and let the library revalidate on window focus or network reconnect. The component tree stays skinny, and I dodge the “dual source of truth” bugs that hand-rolled stores breed.

Here’s the shape it takes, concretely:

// Instead of useState + useEffect + global store:
function Portfolio({ userId }) {
  const { data, isLoading, error } = useQuery({
    queryKey: ['portfolio', userId],
    queryFn: () => fetchPortfolio(userId),
    staleTime: 5 * 60 * 1000,
  });

  if (isLoading) return <Skeleton />;
  if (error) return <ErrorBanner />;
  return <PortfolioChart data={data} />;
}

The decision that really matters here is staleTime. For fast-moving data like live pricing, I drop it to zero but keep the cache as a fallback. For user profiles, I set it to Infinity and manually invalidate on profile updates. The pattern scales because the cache becomes the single layer between the component and the network—no duplication, no drift.

Pattern 2: Colocate Client State, Then Lift It Only When You Must

Client state—form inputs, modal visibility, the selected tab—has the opposite rule: keep it as close to the consumer as you can. By 2026, the React world has mostly quit dumping UI state into global stores because, well, that’s what everyone did five years ago. The payoff is components that are easier to test and a lot easier to delete.

Start with useState inside the leaf component. If a sibling needs access, lift the state to the nearest common parent. Only grab context or a tiny Zustand slice when the consumer tree is deep or spread all over the place. I’ve walked into too many codebases where a Redux store carries an `isModalOpen` boolean that exactly one component and its direct child care about. That’s a tax you pay on every render and every refactor.

Close-up of code on a screen showing React component structure with state lifted carefully
Client state should live right where it’s used—lifting it is a last resort, not a default.

For shared UI state that actually needs to cross routes or worm through deeply nested trees, I use React Context with a small reducer, or a single Zustand store split into slices. The hard constraint: the store holds only serializable, UI-specific values. Business entities stay in the server cache. A real example from a multi-step onboarding flow: a Zustand slice holds the current step index, a draft object for form fields, and an `isSubmitting` flag. Everything else—validated data, available options—comes from TanStack Query. The boundary is clean, and the store is under 40 lines.

Pattern 3: Derived State Shouldn’t Be Stored

One of the sharpest cuts you can make in a React codebase is stripping out stored derived values. If you can compute a piece of data from existing state, compute it at render time—with useMemo when the computation is heavy—and never write it to state. In 2026, this is table stakes, but I still audit codebases where someone stored `fullName` separately from `firstName` and `lastName`.

The pattern I enforce: every piece of state in a component or store must be a source, not a result. For a shopping cart, the stored state is an array of item IDs and quantities. Total price, item count, discount eligibility—those are derived in a useMemo or, better, in a pure function outside the component that takes the cart array as input. This wipes out an entire class of synchronization bugs and makes the code easier to reason about immediately.

When the derivation gets expensive, I lean on useMemo with a clear dependency array. But I also trust the React compiler work that’s landed by 2026: automatic memoization is real in many setups, and I don’t scatter useMemo around defensively anymore. The pattern is to write clean, pure derivations and let the runtime do its job.

Pattern 4: URL State Is a First-Class Store

In 2026, the URL bar is the most underrated state container in React. For any state that should survive a refresh, be shareable, or drive server data fetching—put it in the URL. Search filters, pagination offsets, selected item IDs: these belong in query parameters or path segments, not in a Redux store or a context that evaporates on navigation.

The tooling here is solid. React Router v7 (or whichever routing library you’re on) makes it simple to read and write URL search params with hooks like `useSearchParams`. On a recent product catalog rebuild, I moved all filter state—category, price range, sort order—into the URL. The component tree reads the params on mount, plugs them into the server state query key, and updates the URL on user interaction via `setSearchParams`. The result: deep-linkable pages, zero client-side state for filters, and a back button that actually works.

Browser URL bar with query parameters highlighted, representing state management through routing
The URL is a persistent, shareable store—treat it as such and watch your client-state surface shrink.

The pattern pairs cleanly with server state management. The query key for TanStack Query becomes `[‘products’, searchParams.toString()]`, and the library handles data fetching off URL changes. When the user hits back, the URL changes, the query refires (or serves from cache), and the UI updates—no synchronization code needed.

Pattern 5: Immutable Updates with a Light Touch

Immutability isn’t a library choice in 2026; it’s a language habit. With the spread operator, optional chaining, and tools like Immer around, the pattern is: never mutate state in place, but don’t over-engineer it. In most production code I write, I use plain JavaScript spread for shallow updates and reach for Immer only when the state shape is deeply nested and the update logic would turn into a mess with spread alone.

For a complex form with nested sections, Immer’s `produce` function lets me write what looks like mutation while keeping the state tree intact. But I keep its use localized—a reducer inside a single component or a small Zustand slice. The wider application never needs to know Immer exists. That keeps the bundle impact low and the mental model simple.

The real pattern here is discipline: if you see a direct assignment like `state.items[3].name = ‘new’`, fix it right now. The cost of that bug in concurrent rendering and strict mode is too high to ignore.

Choosing Your Tools: A Quick 2026 Map

The libraries have settled into clear roles. Here’s the lineup I recommend based on what’s held up in production:

  • Server state: TanStack Query v6. Mature, well-documented, handles caching, pagination, and optimistic updates out of the box.
  • Client state (small to medium): React Context + useReducer. Zero dependencies, great for theme, auth status, or a small wizard flow. Keep the value stable with useMemo to avoid unnecessary renders.
  • Client state (larger or cross-route): Zustand v5. Minimal API, no providers, supports slices and middleware like `persist` for localStorage. My default for anything that outgrows Context.
  • Form state: React Hook Form with Zod validation. Handles complex fields, arrays, and validation schemas without re-rendering the whole form on every keystroke.
  • URL state: React Router’s `useSearchParams` and `useParams`. No extra library needed.

Notice what’s missing: Redux, MobX, Recoil. They all still work, but the combination above covers 95% of use cases with less ceremony. If you’re on a legacy Redux codebase, the migration path is to carve out server state into TanStack Query first, then move UI slices to Zustand or Context piece by piece.

FAQ

When should I use a global store instead of React Context?

Use a global store (like Zustand) when the state needs to be accessed by many unrelated components across different parts of the tree, and when performance matters—Context can cause widespread re-renders if the value changes frequently. If the state is consumed by a single subtree and changes rarely (e.g., theme), Context is fine.

Is it okay to mix server state and client state in the same store?

No. This is a common source of bugs. Server state has a lifecycle (loading, error, stale) that client state doesn’t. Keep them separate: server data stays in TanStack Query or SWR, client UI state stays in a small store or local useState. If you need to combine them, do it in a hook that reads from both sources and derives the final shape.

How many state management libraries should a mid-size app use?

Two, maybe three: one for server state (TanStack Query), one for client state (Zustand or Context), and possibly a form library (React Hook Form). Adding more than that usually means you’re solving organizational problems with tools, or you’ve inherited a legacy stack you’re actively shrinking.

What’s the best way to handle optimistic updates in 2026?

TanStack Query’s `onMutate` callback is still the cleanest pattern. You snapshot the current cache, apply the expected change immediately, and roll back on error. Keep the optimistic update function pure and close to the mutation definition so the logic is easy to audit. Skip optimistic updates for operations that fail often or have complex side effects—the flicker isn’t worth it.

State management in React has finally become boring, and that’s a good thing. The patterns above aren’t flashy, but they’ve kept my teams moving fast and shipping with confidence. Start with server state, colocate the rest, compute what you can, and let the URL do the heavy lifting. The rest is details.

React State in 2026: The Patterns That Actually Hold Up

React doesn’t hand you a single way to manage state. That’s part of the charm, and also why the conversation never really ends. But 2026 is different. We aren’t fighting the hooks-vs-Redux war anymore. The hard questions now are more practical: how do you stop server state and UI state from leaking into each other? When does a plain useReducer beat dragging in a store? And what patterns actually survive a 50 KB bundle budget and a remote team spread across three time zones?

Over the past year I’ve refactored three mid-sized React apps that had turned into state spaghetti. The patterns that work right now aren’t the ones from 2020 blog posts. They’re leaner, more composable, and honestly, kind of boring—in the best way. Here’s what I reach for today.

React component diagram on whiteboard with sticky notes

Server State vs. UI State: Draw the Line or Suffer

Where you split data that lives on the server from data that only exists in the browser is the single biggest call you’ll make in a React app. Get it fuzzy and every feature costs twice as much to debug. By 2026, the community has mostly settled on a clean cut: server state goes to a dedicated cache like TanStack Query or SWR; UI state stays inside React or a tiny external store.

The rule I enforce on every project: if the data comes from a fetch call, it never, ever touches useState or a global store. TanStack Query owns the fetch, the cache, the retries, the staleness. You consume it through useQuery and your component re-renders only when the cache actually flips. This isn’t a performance hack—it’s a correctness guarantee. Roll your own useEffect with a manual cache and refetch flags and I promise you’ll ship duplicate requests or stale data eventually. I’ve seen it in every codebase that tried to be clever.

The side effect? Your global store shrinks to almost nothing. I’ve deleted whole Redux slices that were just a badly cached API response dressed up in boilerplate. Zulip’s frontend team wrote about a similar move away from custom fetch layers toward TanStack Query—hundreds of lines of boilerplate gone, and race conditions that had been lurking for years finally squashed.

Zustand Over Context for Anything Shared

React Context is not a state management tool. It’s a dependency injection mechanism. Stick a value that changes often into context and every consumer under that provider re-renders, even the ones that don’t read the value. In 2019 we could plead ignorance. In 2026, we just look negligent.

For shared UI state—sidebar open, theme, the active modal ID—I grab Zustand. The API is tiny; you can learn it while your coffee cools. A store is just an object with a set function. You subscribe with a selector, and Zustand only triggers a re-render when that specific slice changes. No providers, no context wrapping, no React.memo incantations.

A habit I’ve leaned into harder this year: colocating Zustand stores with features. Instead of one monolithic store, I create a feature/dashboard/store.ts that exports a useDashboardStore hook. That store owns panel visibility, selected widget IDs, drag state. Nothing else in the app knows it exists. When the dashboard feature gets the axe, the store vanishes with it. No global state archaeology required.

Software engineer typing Zustand store code on dual monitors

URL as the Source of Truth for Page State

I still see teams stuffing search query strings and pagination offsets into a Zustand or Redux store. That breaks the back button and kills shareable links. The URL is a perfectly fine state container, and the tools for syncing it with React are genuinely good now.

TanStack Router and React Router v7 both give you type-safe access to search params, path params, and hash state. My default now: filter state, sort order, current page—straight into the URL. The component reads the params with a hook, feeds them to a server-state query, and the UI stays in lockstep with the browser’s native navigation. User refreshes the page? They land exactly where they were. No useEffect trying to rehydrate a store from localStorage.

The trade-off is you need a schema for your search params. I use Zod. A small searchParamsSchema that parses and validates the query string means you never deal with NaN page numbers or missing sort keys again. It’s a bit of upfront code, but it wipes out a whole category of bugs that come from manually syncing state between the URL bar and your store.

useReducer for Local Complexity

Not everything needs a library. When a single component or a tightly scoped subtree has tangled state transitions, useReducer is still the cleanest tool. I’m talking multi-step forms, wizards, interactive canvases—places where state updates depend on previous state in non-trivial ways.

The 2026 version: pair useReducer with a typed action union and a pure reducer function exported from a separate file. That reducer gets unit tested without React ever entering the room. The component just dispatches actions and reads the state. I resist the urge to wrap this in a context unless more than two deeply nested children need it. Usually, passing the dispatch function as a prop is plenty.

One trap I keep seeing: people defaulting to Immer inside reducers. Immer is great for deeply nested state, but most UI reducers are flipping a few boolean flags on shallow objects. The proxy overhead is measurable on low-end mobile, especially when the reducer fires on every keystroke in an input. Write the spread syntax yourself for simple cases—it’s three extra characters and zero magic.

Signals Are Here, but Don’t Overdo It

Preact Signals and the newer React bindings have been picking up steam. The pitch is tempting: fine-grained reactivity where only the DOM nodes that depend on a changed value update. In benchmarks, signals-based React components can skip virtual DOM diffing entirely for leaf nodes.

In practice, I only pull in signals for high-frequency updates: real-time dashboards, drawing tools, live text editors. For your average CRUD app, the mental overhead of wrapping values in signal() and remembering to access them with .value isn’t worth it. React’s batching and the React Compiler’s automatic memoization handle most re-render performance problems on their own. I keep signals in the toolbox for the 5% of components that actually need them, not as the default.

The React Compiler Shifts the Math

The React Compiler shipped stable in React 19 and has been refined through 2026. It now automatically wraps components and hooks with the equivalent of React.memo and useMemo where it can prove the optimizations are safe. A lot of the manual memoization we used to scatter everywhere is dead weight.

I’ve deleted thousands of lines of useCallback and React.memo this year. The compiler catches cases I would have missed, and it also catches cases where my manual memoization was actually wrecking referential transparency. The compiler’s lint rules are strict about mutating state and passing unstable references, which pushes you toward cleaner code even before the optimization kicks in.

The practical effect on state management: you can be more aggressive about deriving state inside render functions without wrapping everything in useMemo. A filtered list derived from a parent prop can just be a plain const in the component body. The compiler figures out when to recompute it. This cuts the temptation to push derived state into a store just to get memoization benefits.

React code on screen showing compiler optimizations

Concrete Pattern: Feature Folders with Hard State Boundaries

Here’s a pattern I’ve standardized across all my 2026 projects. Every feature gets a folder. Inside that folder, there’s a state.ts file that exports exactly what the rest of the app is allowed to touch. The structure looks like this:

features/
  dashboard/
    components/
    hooks/
    state.ts
    index.ts
  user-management/
    components/
    hooks/
    state.ts
    index.ts

Each state.ts decides what mechanism to use. For the dashboard, maybe a Zustand store. For user management, custom hooks wrapping TanStack Query. For a settings page, just a plain useReducer. The hard rule: no other feature ever imports from another feature’s internal state file directly. They only import from the public index.ts barrel, which exposes components and a narrow set of composable hooks.

This means you can swap the state mechanism for a feature without touching a single consumer. I migrated a notification feature from a hand-rolled context to Zustand in an afternoon because the boundary was already there. The rest of the app never noticed.

What I Stopped Using

I no longer start new projects with Redux. The boilerplate tax is too high for what it gives you in 2026. RTK Query is solid, but TanStack Query handles non-REST APIs like GraphQL and tRPC with less friction. If you’re deep in a Redux codebase, stay put—but don’t begin a new one there.

I also dropped Recoil. The project is effectively unmaintained, and the atom-family model creates dependency graphs that are a pain to debug visually. Jotai fills a similar niche and has a more active community, but honestly, Zustand covers 90% of the cases where I would have reached for atoms.

Finally, I stopped pulling in external form libraries except for genuinely complex scenarios. React Server Actions plus the native form element and Zod validation cover most form submissions without a single megabyte of Formik or React Hook Form. The browser’s built-in validation and the action prop on forms are good enough now. I default to them and only reach for a library when I need dynamic field arrays or cross-field validation rules that exceed what the platform gives me.

Practical Decision Tree

When I open a ticket that touches state, I run through a quick mental checklist:

  • Does this data come from the server? TanStack Query. Done.
  • Does this state need to survive a page refresh? URL search params, maybe backed by localStorage through a tiny Zustand middleware.
  • Is this state shared across multiple unrelated components? Zustand store, scoped to the feature.
  • Is the update logic complex but local to one tree? useReducer with a pure, testable reducer.
  • Is this a high-frequency animation or real-time stream? Consider signals, or a ref with direct DOM manipulation.
  • Everything else? Plain useState in the nearest common parent.

That checklist covers 98% of the state decisions I make. The leftover 2% are genuinely odd cases that deserve a design doc and a team conversation. If you find yourself reaching for an exotic state solution more often than that, the problem probably isn’t the state—it’s the architecture.

FAQ

Is Redux completely dead in 2026?

Not dead, just no longer the default. Large legacy apps with heavy investment in Redux Toolkit and RTK Query are still well served. For greenfield work, Zustand plus TanStack Query gives you the same power with less code and fewer concepts. Even the Redux team’s own docs now acknowledge this and offer migration guides toward lighter alternatives.

When should I use React Context for state?

Almost never for state that changes often. Context fits static or near-static values: a theme object, a locale string, an authenticated user object that updates once per session. If the value changes more than a few times during a visit, context will trigger pointless re-renders. Grab an external store like Zustand instead, even for tiny bits of global state.

Do I still need to worry about memoization with the React Compiler?

Less, but not zero. The compiler can only optimize what it can statically analyze. If you’re dynamically building objects or functions inside render and passing them to components that aren’t compiled, you still need to pay attention. But most of the useMemo and useCallback calls you wrote in 2023 can safely go. The compiler’s lint plugin will tell you when you’re breaking the rules it depends on.

What about state machines like XState?

State machines shine for workflows with explicit states and transitions: multi-step checkouts, auth flows, media players. For those, XState or a lightweight reducer with a state-transition table works well. For most UI state, though, formally defining every possible transition isn’t worth the ceremony. I use state machines for about one feature per project, not as a blanket pattern.

The state management picture in 2026 is simpler than it’s been in years. We’re finally peeling off the layers of abstraction that piled up during the Redux era. The tools are smaller, the boundaries are sharper, and the compiler shoulders more of the work. The best pattern is the one that fades into the background. That’s what I aim for on every project.

Why useCallback and useMemo Are Not Silver Bullets

Developer debugging code on laptop

If you’ve spent any real time in React, you know the reflex. You spot a chunky computation, and your fingers type useMemo before your brain finishes the thought. A callback heads to a child component, and useCallback wraps it like shrink-wrap. The docs and a hundred Medium posts told you these hooks stop pointless re-renders. But after profiling real, living applications for years, I’ve hit a less comfortable truth: slapping useMemo and useCallback everywhere often backfires. Hard.

I’m Suki Watanabe. My days are spent inside a large-scale SaaS, hunting actual performance regressions. I used to reach for these hooks like salt at dinner—automatic, no tasting first. The outcome? Codebases that got harder to read, trickier to maintain, and sometimes measurably slower than before. This isn’t a theory piece. It’s a field report on when these hooks earn their keep, and when you should step back and let React breathe.

The Premature Optimization Trap

React’s own docs warn against optimizing too early. Still, a kind of folklore has spread that useCallback and useMemo are always a good idea. They aren’t. These hooks were built for two specific headaches: referential equality and genuinely expensive calculations. If your component doesn’t ache from either one, you’re just adding clutter.

Take a plain presentational component that renders a list. That list array gets defined inside the parent and passed down. A newer developer might wrap it in useMemo, convinced they’re dodging a re-render. Trouble is, if the array changes on every render anyway—because it’s built from props that update constantly—the memoization adds cost with zero benefit. React now has to check the dependency array, diff old and new values, and hold onto the previous result. That work isn’t free.

“The overhead of useMemo and useCallback is real. You’re swapping plain JavaScript computation for memory and comparison work. Be sure you’re coming out ahead.”

I’ve walked into codebases where every single function and object wore a memoization jacket. The team felt productive. The app felt swampy. Why? They skipped the cardinal rule: measure first, optimize later.

Understanding Referential Equality

To use these hooks well, you need a working feel for referential equality in JavaScript. In React, a component re-renders when state or props change. Props get compared with a shallow check. Two objects that look the same but sit at different memory addresses will still spark a re-render. That’s where useMemo and useCallback earn their reputation—they keep the same reference across renders, provided the dependencies hold steady.

Code comparison on screen showing JavaScript objects

But that reference stability only matters if the child is wrapped in React.memo. Without it, the child blithely re-renders no matter what. I’ve debugged too many components where useCallback was applied like sunscreen, yet the child wasn’t memoized. The hooks just sat there, burning memory for nothing.

Here’s a real scenario. Picture a SearchBar that takes an onChange prop. If the parent defines that handler inline, every render of the parent spawns a new function reference. If SearchBar is wrapped in React.memo, it’ll still re-render because that reference changed. useCallback fixes that—provided SearchBar is memoized. Miss that combo, and you’re just adding brackets for exercise.

The Hidden Cost of Dependencies

Every useCallback and useMemo carries a dependency array. Managing those arrays breeds subtle bugs. Omit a dependency and you get a stale closure; pile in too many and the cache invalidates constantly, erasing the point. ESLint’s react-hooks/exhaustive-deps rule nudges you in the right direction, but it’s not a cure-all. I’ve watched developers silence the rule with a comment because they “know better.” They usually don’t.

A stale closure can be nasty. Your callback clings to an old value, and the UI drifts into weird, unreproducible states. This gets especially dangerous inside event handlers or effects that depend on fresh state. The hours you burn chasing stale closures can easily swamp any performance win you thought you were getting.

When useMemo Actually Bites Back

Let’s talk about useMemo for “expensive” calculations. The textbook example is filtering a big list. If the list and filter terms rarely change, memoizing the filtered result makes sense. But what if that list churns on every render? Now the memoization piles on overhead each time, and the cache gets tossed almost instantly. Net loss.

I once profiled a dashboard that ate real-time data. A developer had memoized a derived data structure that leaned on five different state variables. The dependency array was a monster, and the computation itself wasn’t heavy—some sorting, a few maps. Stripping the useMemo shrunk the component’s memory footprint and made interactions snappier. The lesson: don’t guess a calculation is slow. Measure it.

Performance profiling chart on monitor

Another trap is wrapping inline styles or class name strings in useMemo. Building a tiny object or concatenating a few strings is dirt cheap. useMemo just muddies the code and forces the next reader to ask, “Why is this cached?” Usually the answer is: “Because someone thought it looked faster.” If the object’s values come from props, React’s reconciliation already has your back. Trust the framework until it lets you down.

Server-Side Rendering and Hydration

In server-rendered React apps, useMemo and useCallback can tangle hydration. The server renders once, so memoization doesn’t help there. During hydration, React has to match the server output exactly. If your hooks have side effects or touch browser-only APIs, mismatches creep in. I’ve lost evenings to hydration errors that traced back to a useMemo that calculated something differently on the client.

This doesn’t mean you should rip them out of SSR apps. It means you need precision. Understand what runs where. A pure calculation that only touches props is fine. Anything that peeks at window or localStorage belongs in an effect, not a memo hook.

The Readability Factor

Code gets read way more than it gets written. Overdosing on useMemo and useCallback chews up readability. A component laced with these hooks forces you to mentally chase dependencies and guess what’s cached and why. Often the reason is just “the last dev thought it would be faster.”

I stick to a straightforward rule: useCallback only when you pass the function to a memoized child or into a dependency array of an effect. useMemo only when the computation is demonstrably slow (you’ve profiled it) and its inputs change rarely. For everything else, let React’s defaults run. The framework is fast enough for the vast majority of what we build.

Look at this snippet:

const handleClick = useCallback(() => {
  setCount(count + 1);
}, [count]);

If handleClick just sits on a plain button, the useCallback is dead weight. Even when the parent re-renders, creating a fresh function is cheaper than the bookkeeping useCallback adds. The React team has said this out loud, more than once. Yet the pattern still shows up everywhere.

Practical Guidelines from the Trenches

After years of pulling React apps apart and putting them back together, I lean on a few heuristics that keep things fast and sane.

  • Profile before wrapping. Fire up the React DevTools Profiler and find real choke points. Look for components that re-render often and take a long time per render. Then think about memoization.
  • Memoize children, not every prop. If a component is genuinely heavy to render, wrap it in React.memo. Then use useCallback and useMemo for the props that need referential stability.
  • Avoid memoizing primitives. Strings, numbers, booleans—these compare by value. useMemo on them adds zero value.
  • Keep dependency arrays small. If your useCallback depends on five state variables, it’s probably doing too much. Pull out a smaller function or reshape the component.
  • Consider alternative patterns. Sometimes moving state down or lifting content up removes the need for memoization entirely. The upcoming React compiler aims to automate a lot of this, making manual hooks less necessary.

These aren’t rigid laws. They’re starting points. Every app has its own performance fingerprint. Stay curious, stay skeptical. When someone tells you to “just use useMemo everywhere,” ask to see the flame chart.

FAQ: Common Doubts About useCallback and useMemo

Should I wrap every function in useCallback if I use it in a useEffect?

Not automatically. If the function lives inside the component and is used only in that effect, try moving the function into the effect itself. That cuts out the need for memoization and makes the dependency chain obvious. Reach for useCallback only when the function is needed elsewhere—like in a child component or another hook.

Isn’t it always safer to memoize just in case?

No, it’s not safer. Unnecessary memoization adds mental noise, nudges bundle size up slightly, and can introduce stale closure bugs when dependencies slip. React’s default rendering is fast. The “safety” you feel is mostly an illusion that leads to knottier, harder-to-debug code.

How do I know if a calculation is “expensive” enough for useMemo?

Profile it. Wrap the calculation with console.time, or better, use the React Profiler. If it consistently runs above 1ms and the component renders often, useMemo might earn its keep. For most sorting, filtering, or object creation on small datasets, it won’t matter. Always test with realistic data sizes under production-like conditions.

In the end, useCallback and useMemo are tools, not rituals. They fix specific problems in specific spots. The strongest React developers I know deploy them sparingly, deliberately. They trust React’s internals and only step in when the profiler leaves them no choice. Next time you’re about to wrap that function, pause and ask: what am I actually gaining? Your future self—and your teammates—will be glad you did.

How to Use React Context Without Destroying Performance

React Context is a double-edged sword. It solves prop drilling elegantly, but one misplaced useContext call can trigger a cascade of re-renders that tanks your app’s responsiveness. I’ve seen teams rip Context out of production code because they didn’t know how to tame it. This guide shows you the patterns that keep your components fast while still using Context for state that truly belongs there.

Code editor showing React component structure

Why Context Causes Performance Problems

The default behavior of Context is deceiving. When a context value changes, every consumer of that context re-renders—even if the consumer only uses a small slice of the data that hasn’t changed. This isn’t a bug; it’s how React compares context values by reference. If the provider passes a new object literal on every render, all consumers update, regardless of whether their specific piece of state changed.

Consider a typical authentication context. You might store user, token, and logout together. A component that only displays the user’s avatar will re-render when the token refreshes, because the entire context value is a new object. Multiply this across dozens of components, and you’ll see frames drop.

Split Contexts by Update Frequency

The simplest fix is to stop putting unrelated state into one big context. Separate values that change at different rates into distinct contexts.

Theme vs. User State

A theme toggle rarely changes. A user object might update after every API call. By separating these into ThemeContext and UserContext, a profile component that consumes only UserContext won’t re-render when someone switches to dark mode.

State vs. Dispatch

If you’re using useReducer, the dispatch function is stable—it never changes between renders. Yet many developers pass { state, dispatch } as the context value. This forces every consumer to re-render when any state changes, even components that only call dispatch. Instead, create two contexts: StateContext and DispatchContext. The dispatch provider value stays constant, so components that only dispatch actions never re-render due to context changes.

const StateContext = createContext();
const DispatchContext = createContext();

function AppProvider({ children }) {
  const [state, dispatch] = useReducer(reducer, initialState);
  return (
    
      
        {children}
      
    
  );
}

Now a button component that only dispatches an action can use useContext(DispatchContext) without subscribing to state updates.

Split React component tree with separate contexts

Memoize Context Values Correctly

Passing object literals or inline functions directly to the value prop creates a new reference on every render of the provider. This means all consumers re-render on every provider render, even if the underlying data hasn’t changed.

Wrap your context value in useMemo with explicit dependencies. This ensures the object reference only updates when the actual data changes.

const value = useMemo(() => ({ user, logout }), [user, logout]);

return (
  
    {children}
  
);

For useReducer, the state object itself is already stable between renders if the reducer doesn’t return new objects unnecessarily. But the combination { state, dispatch } still needs memoization unless you split the contexts as described above.

Component Composition Over Context Prop Drilling

Sometimes the best way to avoid context-induced re-renders is to not use context at all for components that are expensive to render. Instead, use component composition to pass data via props or children.

If you have a heavy chart component that needs a color palette, passing the palette as a prop isolates the chart’s re-render scope. The parent might subscribe to context, but it re-renders cheaply because it’s just a wrapper. The chart itself stays memoized with React.memo.

const ExpensiveChart = React.memo(({ palette }) => {
  // heavy rendering logic
});

function ChartWrapper() {
  const { palette } = useContext(ThemeContext);
  return ;
}

Use Selectors with External Libraries (or Build Your Own)

Context doesn’t natively support selectors—the ability to subscribe to only part of the context value. Redux and Zustand do. If your state is complex and updates frequently, consider a library like Zustand for that specific slice of state. Zustand’s hooks accept a selector function, so components re-render only when the selected value changes.

You can achieve a similar pattern with Context by combining useSyncExternalStore or by splitting contexts down to the individual value level. For a small number of values, this manual approach works well. For larger state trees, an external store is a practical choice.

Context + useMemo: A Fine-Grained Control Pattern

When you can’t avoid a large context, you can wrap consumers in a component that selects the needed value and passes it to a memoized child. This technique is sometimes called the “context selector” pattern.

function Avatar() {
  const { user } = useContext(AppContext);
  return useMemo(() => , [user]);
}

Here, ExpensiveAvatar only re-renders when user changes. Other context updates—like a new notification count—won’t trigger a re-render of the avatar because the Avatar component’s useMemo prevents it. This pattern pushes the performance concern to the leaf component level and keeps the provider simple.

Performance dashboard showing React render times

When Context Isn’t the Right Tool

Context is a dependency injection mechanism, not a state management solution. If you find yourself fighting re-renders, ask whether the data truly needs to be global or if it can be lifted to a common parent and passed as props. Often, the simplest solution is to keep state local and pass it explicitly.

For data that changes every frame (like scroll position or mouse coordinates), context is a poor choice. Use a ref with a subscription model, or a library built for high-frequency updates. Context’s immutability model works against you here—every update creates a new object, triggering re-renders across the entire subtree.

FAQ

Does React.memo prevent re-renders from context changes?

No. React.memo only prevents re-renders when props haven’t changed. Context changes bypass React.memo entirely. A memoized component will still re-render if it consumes a context whose value has changed, regardless of whether the props are identical.

How many context providers is too many?

There’s no hard limit, but nesting dozens of providers becomes a readability and maintenance headache. As a rule of thumb, group related state into a provider only when the values update at similar frequencies. If you have more than five or six providers wrapping your app, consider consolidating rarely-changing ones or moving fast-changing state to a solution with native selector support.

Can I use Context with server-side rendering without performance hits?

Yes. The same rules apply: split by update frequency, memoize values, and avoid unnecessary re-renders. On the server, re-renders are less of a concern because they don’t block the main thread, but excessive work still increases Time to First Byte. Keep context values stable between requests to let React’s streaming SSR do its job efficiently.

Why does my component re-render even though the context value looks the same?

React compares context values using Object.is. If the provider creates a new object or array literal on every render—even with identical contents—the reference changes, and all consumers re-render. Always memoize objects and arrays passed to the value prop, or use separate contexts for primitives that don’t require reference stability.

Beyond these patterns, profile your app with React DevTools. The “Highlight updates when components render” option reveals exactly where context is causing unnecessary work. Fix the hotspots, leave the rest alone. Performance optimization is about removing bottlenecks, not chasing theoretical purity. Context can be fast—you just need to know which levers to pull.

For further reading, check the React docs on useContext and the patterns Kent C. Dodds outlines. If you’re dealing with server components, our guide on mixing Context with RSC covers the sharp edges.

The Complete Guide to React.memo and When It Actually Helps

Stop Wrapping Everything in React.memo

If you’ve worked on a React codebase of any size, you’ve seen it: components wrapped in React.memo like it’s some kind of performance insurance policy. Developers sprinkle it everywhere, hoping it will make their app faster. It rarely does what they think it does. Most of the time, it adds overhead instead of removing it.

Here’s the uncomfortable truth: React.memo only helps in specific, measurable situations. Everywhere else, it’s dead weight that makes your code harder to read and your bundle slightly larger. This guide walks through exactly how React.memo works, when it genuinely improves performance, when it actively hurts, and how to make the call in your own codebase.

Developer working on React performance optimization at a desk

What React.memo Actually Does

React.memo is a higher-order component. It takes your component and returns a new component that skips re-renders when its props haven’t changed. The comparison it uses by default is shallow equality — it checks each prop with Object.is().

That means it behaves identically to shouldComponentUpdate from class components, but for function components. When a parent re-renders, the memoized child checks: did any prop change? If no, skip the render. If yes, render as usual.

const ExpensiveList = React.memo(function List({ items, filter }) {
  // Only re-renders if items or filter change
  return items.filter(filter).map(item => 
  • {item.name}
  • ); });

    The key word is props. React.memo does nothing for state changes, context changes, or forced re-renders inside the component itself. It only protects against re-renders caused by a parent component re-rendering and passing down the same props.

    When React.memo Actually Helps

    1. Pure Components with Expensive Renders

    If a component does heavy computation — sorting large arrays, running canvas calculations, rendering complex SVGs — and its props rarely change, React.memo can save meaningful time. The cost of the shallow comparison is trivial compared to the cost of the render it avoids.

    const DataGrid = React.memo(function DataGrid({ rows, columns, sortConfig }) {
      // Expensive: sorts 10,000 rows, computes column widths
      const sorted = useMemo(() => heavySort(rows, sortConfig), [rows, sortConfig]);
      return ...
    ; });

    2. Components in Lists That Re-render Frequently

    List items are a classic case. When a parent manages list state and one item changes, the parent re-renders, which re-renders every child. With React.memo, only the item whose props actually changed will re-render.

    This matters most when your list has hundreds or thousands of items. A todo app with 20 items? Don’t bother. A data table with 500 rows? Worth considering.

    3. Components with Stable Props That Sit Under Frequent Re-renders

    Sometimes a component’s parent re-renders constantly — a timer, an animation frame, a live data feed — but a particular child underneath receives the same props every time. React.memo shields that child from the parent’s rendering churn.

    Team collaborating on React component architecture

    When React.memo Hurts

    Props That Change Every Render

    This is the most common mistake. If you pass inline objects, arrays, or functions as props, they get a new reference on every parent render. React.memo does its shallow equality check, sees different references, and re-renders the child every single time. The memoization does nothing except add the cost of the comparison.

    // This memo does NOTHING — onClick is a new function every render
    const Button = React.memo(function Button({ label, onClick }) {
      return ;
    });
    
    function Toolbar() {
      // New reference every render
      return 

    The fix: use useCallback for the function, or move the handler definition outside the render path. But don’t reach for useCallback reflexively either — only use it when passing callbacks to memoized children.

    Lightweight Components

    If a component renders a few DOM nodes and does minimal work, skipping its re-render saves almost nothing. The shallow comparison itself might cost more than the render it prevented. React’s reconciliation is fast for simple components. Trust it.

    Components That Rely on Context

    When a component consumes context, it re-renders when the context value changes regardless of React.memo on props. If you wrap a context-consuming component in React.memo, the memo only helps when the parent re-renders but context hasn’t changed. That’s a narrow window. Measure before you assume it matters.

    The Shallow Comparison Trap

    Shallow equality catches primitive prop changes cleanly: strings, numbers, booleans. It fails the moment you pass objects or arrays as props, because two objects with identical properties are not the same reference.

    const item = { id: 1, name: 'Widget' };
    const item2 = { id: 1, name: 'Widget' };
    Object.is(item, item2); // false

    This means if a parent creates a new object literal in its render function and passes it as a prop, React.memo will always see a “changed” prop and never skip. You need to keep object references stable — via useMemo, state, or module-level constants — or write a custom comparison function.

    Code on screen showing React component structure

    Custom Comparison Functions

    React.memo accepts a second argument: a custom comparison function. It receives prevProps and nextProps, and returns true to skip the re-render or false to allow it. This is the opposite of shouldComponentUpdate, which returns true to render — a detail that has tripped up many developers.

    const Chart = React.memo(
      function Chart({ data, options }) {
        return ...;
      },
      (prev, next) => {
        return prev.data.id === next.data.id
          && prev.options.width === next.options.width;
      }
    );

    Custom comparisons are powerful but dangerous. If your comparison is wrong — if it returns true when props actually changed — your component shows stale data with no warning. Bugs like this are hard to trace. Use custom comparisons only when you have a clear reason and a test covering the behavior.

    React.memo vs useMemo vs useCallback

    These three tools serve different purposes, and mixing them up is a source of constant confusion.

    • React.memo — memoizes a component, preventing re-renders when props are the same.
    • useMemo — memoizes a value inside a component, preventing expensive recalculations.
    • useCallback — memoizes a function reference, keeping it stable across renders so it doesn’t break React.memo on child components.

    They work together, not as alternatives. useCallback keeps function props stable so React.memo on the child actually works. useMemo keeps object props stable for the same reason. React.memo uses those stable references to skip renders.

    A Practical Decision Framework

    Instead of memorizing rules, run through this checklist when you consider adding React.memo:

    1. Is the component expensive to render? If it’s a few divs and a text node, don’t bother.
    2. Does the parent re-render often with the same props for this child? If the parent barely re-renders, memoization on the child has no opportunity to help.
    3. Are the props stable? Primitives and stable references work. Inline objects and functions defeat the purpose.
    4. Did you measure? Use React DevTools Profiler. Look at the actual render times. If the component isn’t in your top render-cost offenders, optimizing it is premature.

    The React documentation on memo is explicit about this: use it as a performance optimization, not as a default. The React team themselves advise reaching for it only when profiling shows a real problem.

    FAQ

    Does React.memo shallow-compare props, or does it do a deep comparison?

    It does a shallow comparison by default, using Object.is() on each prop value. It does not deeply compare objects or arrays. If you need deep comparison logic, you must provide a custom comparison function as the second argument — but be cautious, as incorrect comparisons cause stale renders.

    Should I wrap every component in React.memo just in case?

    No. Adding React.memo everywhere adds comparison overhead to every render, makes code harder to read, and can mask real performance issues by making you think you’ve optimized when you haven’t. Use it only where profiling shows a measurable benefit.

    What happens if I use React.memo with a custom comparison that returns true when props change?

    Your component will not re-render even though it should. It will display stale data. This is a silent bug — React won’t warn you. Always test custom comparison functions thoroughly, and consider whether a custom comparison is truly necessary instead of keeping prop references stable.

    Final Word

    React.memo is a scalpel, not a shotgun. It solves a specific problem: unnecessary re-renders of components whose props haven’t changed. When your component is expensive, sits under a frequently re-rendering parent, and receives stable props, it’s the right tool. In every other case, it’s noise. Profile first. Measure the actual cost. Then decide. The best optimization is the one you can justify with data, not the one you added because it felt safe.

    The Complete Guide to React.memo and When It Actually Helps

    If you’ve spent any time in React performance discussions, you’ve seen the pattern: someone mentions slow renders, and someone else immediately suggests wrapping components in React.memo. Problem solved, right? Not even close. Most of the time, slapping React.memo on a component does nothing. Sometimes it makes things worse. Let’s walk through what this API actually does, when it genuinely improves performance, and when you’re just adding noise to your codebase.

    Developer working on React code at a desk with multiple monitors

    What React.memo Actually Does

    React.memo is a higher-order component. It takes your component and returns a new component that skips re-renders when its props haven’t changed. That’s the entire mechanism. React compares the previous props to the next props using shallow equality. If every prop is the same reference as before, React reuses the last rendered output. If any prop reference changed, React re-renders the component.

    Notice I said “reference,” not “value.” This distinction is where most misuse originates. {'{{ count: 5 }}'} and {'{{ count: 5 }}'} are deeply equal but referentially different. Shallow comparison sees two different objects and triggers a re-render. The same applies to functions and arrays.

    Here’s the basic syntax:

    const MyComponent = React.memo(function MyComponent({ name, onClick }) {
      return <div onClick={onClick}>{name}</div>
    })

    That’s it. No magic. No deep diffing of your entire component tree. Just a shallow prop check before deciding whether to render.

    When React.memo Actually Helps

    There are specific, identifiable situations where React.memo provides measurable benefit. Let’s be precise about them.

    1. Heavy List Items Re-rendering from Parent State Changes

    This is the textbook case. You have a parent component that holds state unrelated to most of its children, but every time that state updates, all children re-render. Consider a product list with a search filter in the parent:

    function ProductList({ products }) {
      const [searchTerm, setSearchTerm] = useState('')
      const filtered = products.filter(p => p.name.includes(searchTerm))
    
      return (
        <>
          <SearchBar value={searchTerm} onChange={setSearchTerm} />
          {filtered.map(product => (
            <ProductCard key={product.id} product={product} />
          ))}
        </>
      )
    }

    Every keystroke in SearchBar updates searchTerm, causing ProductList to re-render. Every ProductCard re-renders, even if its product prop hasn’t changed. If each card is expensive—maybe it calculates a discount, formats currency, or renders an image—those wasted renders add up. Wrapping ProductCard in React.memo prevents re-renders for cards whose product data stayed the same.

    2. Preventing Cascading Re-renders in Component Trees

    When a component deep in the tree receives a stable prop, React.memo acts as a firewall. Without it, a state change at the top can trigger renders all the way down. With it, you cut the cascade short. This matters when intermediate components pass callback props that they create inline, which would otherwise bust memoization in children.

    Code on a computer screen showing React component structure

    3. Expensive Components with Stable Props

    Some components do real work: SVG rendering, canvas drawing, complex layout calculations. If the props driving that work don’t change often, React.memo avoids redoing the expensive computation. The key phrase there is “don’t change often.” If the props change every render anyway, you’ve added comparison overhead for zero benefit.

    When React.memo Hurts or Does Nothing

    Here’s where most developers waste their time. These are the scenarios where React.memo is either useless or actively harmful.

    Props That Change Every Render

    If you pass an inline function, a new object, or a new array as a prop, shallow equality will fail every single time. Your memoized component re-renders just as often, but now React also spends time running the comparison. You’ve made things slightly slower and harder to read.

    // This memo does nothing useful
    const Parent = () => {
      const [count, setCount] = useState(0)
      return (
        <MemoizedChild
          items={[1, 2, 3]}          // new array every render
          onClick={() => {}}        // new function every render
          config={{ theme: 'dark' }} // new object every render
        />
      )
    }

    I see this pattern constantly. Someone memoizes the child but doesn’t stabilize the props. The memo check runs, fails, and the child re-renders anyway. You’ve added overhead and complexity for nothing.

    Components That Are Cheap to Render

    Not every component needs memoization. A <span> with some text, a simple form input, a basic card—these render in microseconds. The shallow comparison itself might take as long as the render you’re trying to skip. Always measure before optimizing. Use React DevTools Profiler to identify actual bottlenecks, don’t guess.

    Children Props and React.cloneElement

    If your component receives children as a prop and the parent re-renders, the children reference changes. React.memo won’t help here unless you also memoize the JSX being passed as children, which is rarely worth the mental overhead.

    Stabilizing Props: The Missing Half of the Equation

    Using React.memo effectively means making sure the props you pass are referentially stable. You have tools for this.

    • useMemo for objects and arrays that depend on specific values
    • useCallback for functions passed as props
    • Moving state down so the parent doesn’t re-render as often
    const Parent = () => {
      const [count, setCount] = useState(0)
      const items = useMemo(() => [1, 2, 3], [])
      const handleClick = useCallback(() => {
        // handle click
      }, [])
    
      return <MemoizedChild items={items} onClick={handleClick} />
    }

    Now the memoization works. But notice what happened: you added three hooks and a wrapper just to skip a render. Is that render actually expensive enough to justify the complexity? If you can’t answer that question with profiler data, you’re optimizing blind.

    Software engineer analyzing performance metrics on screen

    Custom Comparison Functions

    Sometimes shallow equality isn’t enough. React.memo accepts a second argument: a custom comparison function.

    const MemoChild = React.memo(Child, (prevProps, nextProps) => {
      return prevProps.id === nextProps.id && prevProps.status === nextProps.status
    })

    Return true to skip the re-render (props are equal), false to allow it. This looks handy, but tread carefully. A custom comparison function runs every render. If it’s doing deep equality on large objects, you might spend more time comparing than rendering. Keep custom comparisons narrow and cheap.

    There’s also a subtle trap here: the comparison function uses the opposite return convention from what most developers expect. Returning true means “props are the same, skip the render.” Returning false means “props differ, render.” I’ve seen bugs from developers getting this backwards. The React docs cover this explicitly—read them carefully if you go this route.

    Common Mistakes I See Repeatedly

    Memoizing Everything by Default

    Some teams wrap every component in React.memo as a convention. This is a performance anti-pattern. You’re adding comparison overhead to every component, including the ones that re-render on every prop change anyway. Memoization is a targeted solution, not a blanket policy.

    Forgetting Context Consumers

    If a component consumes React Context, it re-renders when the context value changes, regardless of React.memo. Memoizing the component won’t prevent that re-render. If you want to prevent context-driven re-renders, you need to either split your context or use a selector pattern. The React team’s RFCs have discussed built-in context selectors, but for now, check out libraries like use-context-selector if this is a real bottleneck.

    Measuring Wrong

    Console logging inside a component body to check if it re-renders tells you that it rendered, not how long it took. A component re-rendering isn’t inherently a problem. A component re-rendering and taking 50ms to produce output—that’s a problem. Use the Profiler. Look at committed render times. Focus on components that actually appear in the flame chart as bottlenecks.

    A Practical Decision Framework

    Instead of memorizing rules, work through these questions when considering React.memo:

    1. Is the component expensive to render? Profile it. If it commits in under a millisecond, stop here. Don’t memoize.
    2. Does it re-render frequently with the same props? Check with React DevTools. If props change every parent render, memo won’t help unless you stabilize them.
    3. Can you stabilize the props cheaply? If stabilizing props requires wrapping everything in useMemo and useCallback, consider whether moving state down or restructuring the component tree would be simpler.
    4. Is the memoization measurable? Add React.memo, profile again, and check the difference. If you can’t measure the improvement, remove the memo.

    FAQ

    Does React.memo do deep comparison of props?

    No. By default, React.memo uses shallow equality comparison. It checks if each prop is the same reference as before, not whether objects or arrays have the same contents. This is why passing inline objects or arrays defeats memoization. You can provide a custom comparison function as a second argument, but that comes with its own performance cost.

    Is React.memo the same as shouldComponentUpdate?

    Conceptually similar, but React.memo is for function components and shouldComponentUpdate was for class components. They both let you control whether a component re-renders based on prop changes. The key difference is that React.memo uses shallow comparison by default, while shouldComponentUpdate required you to write the comparison logic yourself every time.

    Should I wrap every component in React.memo as a best practice?

    Absolutely not. Memoization has a cost: the shallow comparison runs on every render, and wrapping components adds cognitive overhead for anyone reading the code. Apply React.memo when you have profiler evidence that a specific component is a bottleneck, not as a default. Treating it as a blanket optimization is one of the most common mistakes I see in React codebases.

    The Bottom Line

    React.memo is a scalpel, not a sledgehammer. It works when you have expensive components receiving stable props while their parent re-renders for unrelated reasons. It fails when you apply it without stabilizing props, when you use it on cheap components, or when you treat it as a default optimization. Profile first. Identify real bottlenecks. Stabilize props. Then memoize. Skip the ceremony, measure the result, and remove anything that doesn’t produce a measurable improvement.

    Why Your React App Re-renders More Than You Think

    You wrote a simple component. A list, a filter, a form. Nothing fancy. But when you open React DevTools and hit the profiler, your innocent little component re-renders forty-seven times on a single interaction. Where did things go wrong?

    Re-renders are React’s mechanism for keeping the DOM in sync with state. In theory, this is elegant. In practice, it’s a performance swamp that sneaks up on you. Most developers underestimate how often their components re-render because the visual output doesn’t change — React’s diffing saves you from unnecessary DOM writes, but the JavaScript execution still happens. Every. Single. Time.

    Developer analyzing React component re-renders in profiler

    The Three Sources of Re-renders

    Every re-render in React traces back to one of three triggers:

    1. State changessetState or useState setter is called.
    2. Parent re-renders — A parent component re-renders and passes new props (or the same props, but React doesn’t know that yet).
    3. Context changes — A consumed context value updates.

    State changes are usually intentional. You expect a re-render when you call setCount. The real problems are the other two.

    Parent Re-renders Cascade Like a Bad Cold

    Here’s the fundamental rule most developers learn the hard way: when a parent re-renders, every child re-renders too. Regardless of props. Regardless of whether anything actually changed.

    function Parent() {
      const [count, setCount] = useState(0);
      return (
        <div>
          <button onClick={() => setCount(count + 1)}>{count}</button>
          <ExpensiveChild />
        </div>
      );
    }

    Every click on that button re-renders ExpensiveChild. It receives no props at all, yet React calls its function again because it has no way to know the output will be identical. This is the single most common source of wasted renders in production React code.

    The fix: React.memo

    Wrap the child in React.memo:

    const ExpensiveChild = React.memo(function ExpensiveChild() {
      return <div>I only re-render when my props change</div>;
    });

    Now React skips this component when the parent re-renders and the props haven’t changed. Since ExpensiveChild receives no props, it will never re-render from a parent update. Use React.memo liberally on components that are expensive to render or appear deep in the tree. Don’t bother memoing tiny leaf components — the comparison cost can exceed the render cost.

    Inline Objects and Functions: Silent Prop Killers

    You added React.memo but the component still re-renders every time. Why? Because you’re passing a new object or function as a prop on every render:

    <UserCard
      user={user}
      style={{ margin: 16 }}
      onClick={() => navigate('/profile')}
    />

    Code editor showing inline object and function props

    Every render creates a new object for style and a new function for onClick. React.memo does a shallow comparison, and {} !== {}. Your memo is useless.

    The fix: useMemo and useCallback

    const cardStyle = useMemo(() => ({ margin: 16 }), []);
    const handleClick = useCallback(() => navigate('/profile'), []);
    
    return (
      <UserCard user={user} style={cardStyle} onClick={handleClick} />
    );

    Yes, these hooks have their own cost. Don’t wrap everything — only values that get passed as props to memoized components or are used as dependencies in other hooks. See the official React docs on useMemo for the specific guidance on when it helps.

    Context: The Sneaky Re-render Bomb

    Context feels like a clean solution for shared state until you realize that every consumer re-renders when any part of the context value changes.

    const AuthContext = createContext();
    
    function AuthProvider({ children }) {
      const [user, setUser] = useState(null);
      const [theme, setTheme] = useState('dark');
    
      return (
        <AuthContext.Provider value={{ user, theme, setUser, setTheme }}>
          {children}
        </AuthContext.Provider>
      );
    }

    Every time theme changes, every component consuming AuthContext re-renders — even if it only reads user. This is a well-documented problem. The context value object is recreated on every render, so even consumers that don’t care about the change will see a “new” value.

    The fix: Split contexts or memo the value

    Option one: split into separate contexts for separate concerns:

    const UserContext = createContext();
    const ThemeContext = createContext();

    Option two: stabilize the context value object:

    const value = useMemo(
      () => ({ user, theme, setUser, setTheme }),
      [user, theme]
    );

    Developer reviewing React component tree and context usage

    Splitting contexts is the more scalable solution. A single monolithic context guaranteed to re-render your entire app on any state change will haunt you as your application grows.

    State Updates in Render: The Infinite Loop Trap

    Sometimes re-renders aren’t just wasteful — they’re infinite. This happens when you trigger a state update during the render phase:

    function BadComponent({ items }) {
      const [count, setCount] = useState(0);
    
      // Don't do this
      setCount(items.length);
    
      return <div>{count}</div>;
    }

    React renders the component, sees setCount, updates state, re-renders, sees setCount again — and now you’re in an infinite loop. The correct approach is to compute derived state without storing it:

    function GoodComponent({ items }) {
      const count = items.length;
      return <div>{count}</div>;
    }

    Or, if you genuinely need to sync state with props occasionally, use useEffect:

    useEffect(() => {
      setCount(items.length);
    }, [items.length]);

    This fires after render, breaking the cycle.

    How to Actually Find Wasted Renders

    Stop guessing. Open React DevTools, go to the Profiler tab, click the gear icon, and enable “Highlight updates when components render”. Now interact with your app. Components that flash are re-rendering. If something far from the interaction flashes, you have a cascade problem.

    For deeper analysis, the React DevTools profiler documentation explains how to record a profile and read the flamegraph. Look for wide, flat sections — those represent components rendering many children at once. Look for repeated colors at the same depth — those are components re-rendering without cause.

    You can also add a quick console log to suspect components:

    function SuspectComponent(props) {
      console.trace('SuspectComponent rendered');
      return <div>...</div>;
    }

    The stack trace tells you exactly what triggered the render. Remove these logs before shipping — they’ll tank performance in production.

    A Practical Checklist

    Before you start memoizing everything in sight, work through this list:

    • Profile first. Don’t optimize what isn’t slow. Use the profiler to identify actual bottlenecks.
    • Lift state down. If only part of a component needs state, extract that part into its own component so the parent doesn’t re-render.
    • Memo expensive children. Wrap heavy components in React.memo when their parents re-render often.
    • Stabilize props. Use useCallback and useMemo for props passed to memoized components.
    • Split contexts. Don’t put unrelated data in the same context.
    • Avoid derived state. Compute values directly instead of syncing them to state.

    Re-renders aren’t evil. React is designed to re-render frequently. The problem is unnecessary re-renders that create perceptible lag. Fix those, leave the rest alone, and your app will run well without turning your codebase into a memoization museum.

    FAQ

    Does React.memo shallow-compare all props, including functions?

    Yes. React.memo does a shallow comparison on every prop. For functions, fn === fn is only true if it’s the same function reference. That’s why inline arrow functions always break memoization — they create a new reference on every render. Use useCallback to stabilize function references passed as props to memoized components.

    Should I wrap every component in React.memo?

    No. React.memo adds comparison overhead. For small, fast-rendering components, the cost of comparing props exceeds the cost of just re-rendering. Reserve memoization for components that are computationally expensive, render large subtrees, or receive stable props from frequently-updating parents. Profile first, memo second.

    Why does useEffect cause extra renders?

    useEffect runs after the render cycle completes. If a useEffect callback calls a state setter, it triggers another render. This pattern — render, effect, setState, re-render — is common for data fetching and synchronization. It’s not inherently wrong, but it means your component renders twice on mount. If this becomes a performance issue, consider using useSyncExternalStore or restructuring your data flow so the state is available on the first render.