Skip to content

Fix: React.memo Not Preventing Re-renders

FixDevs · (Updated: )

Part of:  React & Frontend Errors

Quick Answer

How to fix React.memo not working — components still re-rendering despite being wrapped in memo, caused by new object/function references, missing useCallback, and incorrect comparison functions.

The Error

You wrap a component in React.memo() to prevent unnecessary re-renders, but the component still re-renders every time the parent renders. There is no error — the optimization simply does not work.

Common symptoms:

  • A component wrapped in React.memo re-renders on every parent render.
  • Adding React.memo has no measurable performance improvement.
  • The React DevTools Profiler shows the memoized component highlighted on every render.
  • A component re-renders even when its props appear unchanged.

Why This Happens

React.memo performs a shallow comparison of props between renders. It skips re-rendering only if all props pass Object.is() equality. The optimization fails when:

  • Props include new object or array literals on every render{} and [] create a new reference each time, so shallow comparison always finds them different.
  • Props include inline functions() => {} creates a new function reference on every render.
  • A parent’s context or state change causes a re-render that produces genuinely new prop values.
  • The component uses useContext — context changes bypass React.memo entirely.
  • The comparison function is wrong — a custom comparator passed to React.memo has a bug.
  • Children prop is passed — JSX elements (<div>...</div>) are objects and always create new references.

A more fundamental point that often gets missed: React.memo is a hint, not a guarantee. React reserves the right to re-render a memoized component anyway — for example, after a Concurrent rendering interruption, or when React’s internal heuristics decide it is faster to re-render than to remember the previous result. This means relying on React.memo for correctness (e.g., to avoid running a side effect) is a bug. It is only an optimization. The actual contract is “skip rendering if props are shallow-equal, when it is convenient.”

The shallow comparison itself is also stricter than most developers expect. Object.is({a: 1}, {a: 1}) is false because the two object literals occupy different memory locations. Even returning the same data from useMemo with a different dependency array gives a new reference. Every render in a function component is a fresh execution of the function body, and every literal in that body is a new allocation.

Platform and Environment Differences

React.memo behaves differently depending on which React renderer and integration you are running. Knowing your environment narrows the root cause significantly.

React 18+ Concurrent Renderer. In Concurrent mode, React can pause, abort, and restart renders. A memoized component may render more than once for what looks like a single update because React threw away the first attempt. The DevTools Profiler labels these “render aborted” — they are not bugs in your memo logic. Do not chase these.

Strict Mode double-invoke (development only). <React.StrictMode> intentionally invokes function components twice in development to surface side effects. This makes React.memo look broken because every render shows up doubled in DevTools. In production builds, the doubling stops. Always benchmark in a production build (vite build, next build, etc.) before concluding React.memo is failing — see React Strict Mode double render.

Next.js App Router (Server Components). In the App Router (app/ directory), Server Components do not re-render on the client. Wrapping a Server Component in React.memo is meaningless — the component is rendered once on the server and streamed as serialized output. React.memo only applies to Client Components (files marked with "use client"). If you put React.memo in a server file, the bundler will either silently strip it or throw a build error.

Next.js Pages Router. The Pages Router renders everything as a Client Component after hydration. React.memo works there the same way as a plain React app. However, the SSR-then-hydrate cycle means the first render is always “Props changed” because the server rendered different references than the client recreates on hydration. Profile after hydration, not during.

React Server Components in general. Memoization on the server is handled by React’s request-scoped cache (cache() from react), not React.memo. Putting memo() around a server component will work in the sense that it does not error, but it does not deduplicate server renders.

React Native. RN uses the same reconciler but renders to native views via the JS-to-native bridge. A re-render in a memoized component still costs a bridge message. The cost calculus is different: even cheap-looking re-renders (a <View> with one text child) can hurt scroll performance because each crosses the bridge. Memoize aggressively in RN list cells (FlatList renderItem).

React Compiler (experimental, RC May 2025). The React Compiler automatically inserts memoization for components and values. When the compiler is enabled, manual React.memo and useCallback often become redundant — the compiler analyzes your component and inserts equivalent memoization automatically. If you have the compiler enabled and a memoized component is still re-rendering, the compiler may have intentionally chosen not to memoize that prop because its analysis decided the comparison cost outweighed the saving. Read the compiler’s output annotations in the Babel plugin’s log.

Preact compat. If you are using preact/compat, memo is implemented but uses Preact’s reconciler, which has slightly different scheduling. Behavior is mostly identical but profiling tools may behave differently.

Hot Module Replacement (HMR). Vite, Next.js, and Create React App with Fast Refresh sometimes invalidate memo caches when a component file changes. After HMR, the first render is always a fresh render — this is not a bug in your code.

Fix 1: Memoize Object and Array Props with useMemo

If you pass an object or array as a prop, create it with useMemo so its reference only changes when the data changes:

Broken — new object on every render:

function Parent() {
  const [count, setCount] = useState(0);

  return (
    <MemoizedChild
      config={{ theme: "dark", size: "large" }} // New object every render
      items={[1, 2, 3]}                          // New array every render
    />
  );
}

const MemoizedChild = React.memo(function Child({ config, items }) {
  console.log("Child rendered"); // Runs every time Parent renders
  return <div>{config.theme}</div>;
});

Fixed — stable references with useMemo:

function Parent() {
  const [count, setCount] = useState(0);

  const config = useMemo(() => ({ theme: "dark", size: "large" }), []);
  const items = useMemo(() => [1, 2, 3], []);

  return (
    <MemoizedChild config={config} items={items} />
  );
}

Now config and items have the same reference between renders (as long as their dependencies don’t change), so React.memo’s shallow comparison finds them equal and skips re-rendering.

Why this matters: JavaScript creates a new object or array literal every time a function executes. { theme: "dark" } in a render creates a new object on every render — even though the content is identical, Object.is(obj1, obj2) returns false because they are different references. React.memo compares references, not deep values.

Fix 2: Memoize Function Props with useCallback

Functions passed as props also create new references on every render:

Broken — inline function as prop:

function Parent() {
  const [count, setCount] = useState(0);

  return (
    <MemoizedButton
      onClick={() => console.log("clicked")} // New function every render
    />
  );
}

Fixed — stable function with useCallback:

function Parent() {
  const [count, setCount] = useState(0);

  const handleClick = useCallback(() => {
    console.log("clicked");
  }, []); // Empty deps — function never changes

  return <MemoizedButton onClick={handleClick} />;
}

When the callback depends on state or props, include them in the dependency array:

const handleSubmit = useCallback(() => {
  submitForm(formData); // formData changes when user types
}, [formData]); // Re-create only when formData changes

Common Mistake: Wrapping every function in useCallback “just in case.” useCallback itself has overhead — only use it when the function is passed as a prop to a memoized component, used as a dependency in another useEffect or useMemo, or otherwise needs a stable reference.

Fix 3: React.memo Does Not Block Context Changes

React.memo only checks props. If a component consumes a context with useContext, it re-renders whenever the context value changes — regardless of React.memo:

const ThemeContext = createContext("light");

const MemoizedChild = React.memo(function Child() {
  const theme = useContext(ThemeContext); // Context bypasses memo
  return <div className={theme}>Content</div>;
});

function Parent() {
  const [theme, setTheme] = useState("light");

  return (
    <ThemeContext.Provider value={theme}>
      <MemoizedChild />  {/* Re-renders on every theme change — expected */}
    </ThemeContext.Provider>
  );
}

This is expected behavior. Solutions:

Split context into stable and frequently-changing parts (see Fix: React Context not updating for the splitting pattern).

Move context consumption up and pass the value as a prop — React.memo can then prevent re-renders when the context-derived prop does not change.

Fix 4: Memoize the children Prop

If you pass JSX as children, each render creates new React element objects:

Broken — children bypasses memo:

<MemoizedLayout>
  <HeavyComponent />  {/* New React element object every render */}
</MemoizedLayout>

Fixed — memoize children explicitly:

const children = useMemo(() => <HeavyComponent />, []);

<MemoizedLayout>{children}</MemoizedLayout>

Or restructure so the memoized component does not need to accept children — pass data props instead and let it render its own children.

Fix 5: Use a Custom Comparison Function

For deep comparison of complex props, pass a custom comparator as the second argument to React.memo:

function areEqual(prevProps, nextProps) {
  // Return true to skip re-render (props are equal)
  // Return false to re-render (props are different)
  return (
    prevProps.user.id === nextProps.user.id &&
    prevProps.user.name === nextProps.user.name &&
    prevProps.onUpdate === nextProps.onUpdate
  );
}

const UserCard = React.memo(function UserCard({ user, onUpdate }) {
  return (
    <div>
      <p>{user.name}</p>
      <button onClick={onUpdate}>Update</button>
    </div>
  );
}, areEqual);

Warning: Deep comparison has its own cost. For very large objects, the comparison itself may be slower than just re-rendering. Profile before using custom comparators.

Do not use JSON.stringify for comparison — it is slow, does not handle circular references, and ignores functions:

// Avoid this — slow and incorrect for functions/undefined
const areEqual = (prev, next) =>
  JSON.stringify(prev) === JSON.stringify(next);

Fix 6: Verify with React DevTools Profiler

Before optimizing, confirm which component is re-rendering and why:

  1. Install React Developer Tools.
  2. Open DevTools → Profiler tab.
  3. Click Record, interact with your app, click Stop.
  4. Click on any component bar in the flame chart.
  5. The panel shows why the component rendered: “Props changed”, “Context changed”, “Hooks changed”, or “Parent rendered”.

If the Profiler shows “Props changed”, click into it to see which specific prop changed. This points directly to which prop needs memoization.

Enable “Highlight updates” in React DevTools:

Settings → General → “Highlight updates when components render.” Components flash blue when they re-render — a component that flashes constantly despite being memoized has a reference stability problem.

Fix 7: When NOT to Use React.memo

React.memo adds complexity and has overhead. Skip it when:

  • The component renders quickly — memoization overhead may exceed the rendering cost.
  • Props always change — if every render produces genuinely new prop values (e.g., a timer updating every second), memoization never helps.
  • The component rarely re-renders anyway — only leaf components that render frequently are worth memoizing.
  • The component is small — a simple <Button> with minimal DOM output costs almost nothing to re-render.

Focus optimization effort on:

  • Components that render large lists (use React.memo + useCallback for list item components).
  • Components with expensive computations (use useMemo for the computation itself).
  • Components deep in the tree that are re-rendering due to unrelated parent state changes.

Real-world scenario: A dashboard with 50+ chart components all re-rendering when a single counter updates is a good candidate for React.memo. A simple navigation bar that re-renders when page state changes is probably not worth memoizing — it renders in microseconds regardless.

Still Not Working?

Check if the component is defined inside another component. A component defined inside a render function gets a new reference on every render, breaking memoization:

// Broken — new component type every render
function Parent() {
  const Child = React.memo(() => <div>Child</div>); // New type each render!
  return <Child />;
}

// Fixed — define outside
const Child = React.memo(() => <div>Child</div>);

function Parent() {
  return <Child />;
}

Check for key prop changes. If the key prop on a memoized component changes, React unmounts and remounts the component entirely — React.memo is irrelevant. A changing key is intentional when you want to reset component state.

Check React.StrictMode double renders. In development with <React.StrictMode>, React intentionally renders components twice to detect side effects. This double render appears in DevTools but does not happen in production. Memoization works correctly in production even if DevTools shows extra renders in development.

Check whether the React Compiler is double-memoizing. If you have the React Compiler enabled, it may already insert memoization automatically. Adding manual React.memo on top is harmless but can confuse the compiler’s heuristics. Disable manual memoization temporarily and check the compiler’s Babel output to confirm the component is being memoized automatically.

Check for the destructured-default-prop trap. If you destructure with a default value like function Child({ items = [] }), the default [] is created on every render inside the child after props pass shallow comparison — but React.memo compares incoming props, so the default does not affect memo behavior. However, if the parent passes items={items || []} instead, every render creates a new array reference. Move the fallback to a useMemo in the parent, or accept undefined and handle it inside the child.

Check for parent-level state thrashing. A common pattern is a parent that calls setState inside an effect that runs every render, causing the parent to re-render in a loop and forcing memoized children to be evaluated for shallow equality on every tick. If memoized children skip rendering but the parent never settles, the perceived “memo not working” is actually too many re-renders at the parent level.

For related performance issues, see Fix: React useEffect Infinite Loop.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles