DEV Community

Cover image for How to Avoid Unnecessary Re-renders in React Without Memoizing Everything
Janarthanan Soundararajan (Jana)
Janarthanan Soundararajan (Jana)

Posted on AI-assisted

How to Avoid Unnecessary Re-renders in React Without Memoizing Everything

Add a console.log() inside a React component and you may see it run more often than expected. The first reaction is often to add React.memo, useMemo, and useCallback everywhere.

That usually treats the symptom before finding the cause.

A re-render is also not the same as a DOM update. During rendering, React calls components to calculate the next UI. During the commit phase, it updates only the DOM nodes that changed. A component running again can therefore be harmless. The optimization matters when repeated work is expensive or makes an interaction feel slow.

Before changing the code, identify why the component rendered and how much work that render performs.

Why does a React component re-render?

A component can render again for several reasons:

  1. Its own state changed.
  2. One of its ancestors rendered, causing the normal render cascade through descendants.
  3. A context it reads received a different value.
  4. An external store subscription returned an updated value.

Props need a small clarification. Changed props do not independently schedule a normal child component to render. The parent renders first and supplies those props. Without memoization, the child normally renders as part of that parent render whether its props changed or not.

Prop equality becomes important when the child is wrapped in React.memo. React can then compare each prop with its previous value using Object.is and skip the child when they are equal.

React's render and commit documentation explains this sequence in more detail.

First, decide whether the render needs fixing

A render is unnecessary from a performance perspective only when it repeats work that has a measurable cost without producing a useful change. A small component rendering after its parent is normal React behavior. If the interaction remains responsive, adding memoization may give the code more complexity than speed.

The same caution applies to inline values. An inline callback, object, or array is not automatically a performance problem. Its changing identity matters when another part of the program observes that identity, such as a memoized child, an Effect dependency, or a store subscription.

The techniques below are options to apply after identifying a specific source of wasted work. They are not rules that every component should follow.

1. Move state closer to where it is used

Start with component structure. If an input owns the only state that changes while someone types, the page does not need to own that state.

// Typing causes Dashboard and its descendants to render.
function Dashboard() {
  const [query, setQuery] = useState("");

  return (
    <div>
      <input
        value={query}
        onChange={(event) => setQuery(event.target.value)}
      />
      <VeryExpensiveTree />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Move the state into a smaller component:

function SearchInput() {
  const [query, setQuery] = useState("");

  return (
    <input
      value={query}
      onChange={(event) => setQuery(event.target.value)}
    />
  );
}

function Dashboard() {
  return (
    <div>
      <SearchInput />
      <VeryExpensiveTree />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Now the state update starts inside SearchInput. React does not need to render Dashboard or VeryExpensiveTree for every keystroke.

This applies to other temporary UI state too. If only a modal uses a form value, it can usually own that state. A tooltip can usually own its open state. Moving either value upward is still correct when another part of the tree needs to read or control it.

Yes. The idea is useful, but the explanation can be shorter. I would replace it with this:

2. Keep expensive content outside the stateful wrapper

If a wrapper has local state, pass large child content through children instead of creating it inside the wrapper.

import { type ReactNode, useState } from "react";

type InteractivePanelProps = {
  children: ReactNode;
};

function InteractivePanel({ children }: InteractivePanelProps) {
  const [highlighted, setHighlighted] = useState(false);

  return (
    <section className={highlighted ? "highlighted" : ""}>
      <button onClick={() => setHighlighted((value) => !value)}>
        Toggle highlight
      </button>

      {children}
    </section>
  );
}

function Dashboard() {
  return (
    <InteractivePanel>
      <VeryExpensiveTree />
    </InteractivePanel>
  );
}
Enter fullscreen mode Exit fullscreen mode

When highlighted changes, InteractivePanel re-renders. Dashboard does not, so React can reuse the same children element without rendering VeryExpensiveTree again.

This only isolates updates caused by the wrapper's local state. VeryExpensiveTree can still render when:

  • Dashboard re-renders.
  • Its own state or context changes.
  • It is removed and mounted again through conditional rendering.
{isOpen && children}
Enter fullscreen mode Exit fullscreen mode

Use composition to limit how far a local state update travels. It is a structural optimization, not a replacement for React.memo in every case.

The main improvement is the heading: “Keep expensive content outside the stateful wrapper” explains the technique more directly than “Pass stable content through composition.”

3. Skip expensive cascade renders with React.memo

When a parent renders, its child components normally render too. React.memo can skip a child when all its props remain equal.

import { memo } from "react";

type ItemListProps = {
  items: readonly string[];
};

export const ItemList = memo(function ItemList({ items }: ItemListProps) {
  return (
    <ul>
      {items.map((item) => (
        <li key={item}>{item}</li>
      ))}
    </ul>
  );
});
Enter fullscreen mode Exit fullscreen mode

React.memo is a reasonable option when these conditions are true:

  • The parent renders frequently.
  • The child often receives the same props.
  • Rendering the child performs enough work to matter.

It does not block every render. The component still renders when its own state changes or when a context it consumes changes. React also describes memoization as a performance optimization rather than a guarantee in the memo documentation.

Wrapping every component in memo adds prop comparisons and can make the code harder to follow without improving the user experience. In a component with cheap rendering or frequently changing props, skipping memoization may be the better choice.

4. Keep function props stable with useCallback

JavaScript creates a new function whenever the parent executes this line:

<MemoizedItem onDelete={(id) => deleteItem(id)} />
Enter fullscreen mode Exit fullscreen mode

The function reference is different on every parent render. If MemoizedItem is wrapped in React.memo, that new reference prevents React from skipping it.

useCallback can keep the reference stable until one of its dependencies changes:

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

  const handleDelete = useCallback((id: string) => {
    deleteItem(id);
  }, []);

  return (
    <>
      <button onClick={() => setCount((value) => value + 1)}>
        Count: {count}
      </button>

      <MemoizedItem onDelete={handleDelete} />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

The empty dependency array is correct only because this example uses a stable deleteItem function declared outside the component. If the callback reads props or state, include those reactive values in its dependency list.

useCallback does not stop Parent from rendering, and caching a callback has little value when it is passed to a non-memoized child. Its common use is keeping a function prop stable for a memoized component or satisfying another Hook's dependency requirements. See the useCallback reference for the full behavior.

5. Use useMemo for calculations and reference stability

Arrays and objects have the same reference problem as functions:

const visibleItems = items.filter((item) => item.isActive);
Enter fullscreen mode Exit fullscreen mode

filter() returns a new array on every render. That new array breaks the prop comparison of a memoized list even if items did not change.

type Item = {
  id: string;
  isActive: boolean;
};

function Parent({ items }: { items: Item[] }) {
  const visibleItems = useMemo(() => {
    return items.filter((item) => item.isActive);
  }, [items]);

  return <MemoizedList items={visibleItems} />;
}
Enter fullscreen mode Exit fullscreen mode

This has two possible benefits. It avoids repeating a costly calculation, and it preserves the array reference so MemoizedList can be skipped.

Do not use useMemo for every small calculation. The cache also has a cost, and React may discard cached values in some situations. Use it for performance, not correctness. The useMemo documentation recommends measuring whether the calculation is expensive enough to justify caching.

Sometimes the cleaner fix is to pass smaller props. If a child only needs user.name, pass the name instead of the full user object. Primitive props are often easier to keep stable.

6. Split contexts to reduce subscriptions

React.memo cannot protect a component from updates to a context that it reads. When the provider receives a different value, consumers of that context render again.

One large context makes unrelated consumers depend on the same update:

<AppContext.Provider value={{ user, theme, notifications, dispatch }}>
  {children}
</AppContext.Provider>
Enter fullscreen mode Exit fullscreen mode

Changing notifications can cause a component that only reads theme to render. Splitting unrelated values into separate contexts narrows the update.

State and dispatch can also be separated:

import {
  createContext,
  type Dispatch,
  type ReactNode,
  useContext,
  useReducer,
} from "react";

type AppState = {
  count: number;
};

type AppAction = {
  type: "RESET";
};

const StateContext = createContext<AppState | null>(null);
const DispatchContext = createContext<Dispatch<AppAction> | null>(null);

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

  return (
    <DispatchContext.Provider value={dispatch}>
      <StateContext.Provider value={state}>
        {children}
      </StateContext.Provider>
    </DispatchContext.Provider>
  );
}

function ActionButton() {
  const dispatch = useContext(DispatchContext);

  if (!dispatch) {
    throw new Error("ActionButton must be used inside AppProvider");
  }

  return (
    <button onClick={() => dispatch({ type: "RESET" })}>
      Reset
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

The dispatch function returned by useReducer has a stable identity. ActionButton can therefore dispatch actions without subscribing to the state context.

This split does not solve every context render. A component reading StateContext still renders whenever the state object changes. For a large shared state, split contexts by concern or use a store that supports selecting a smaller state slice. React's guide on scaling with reducer and context uses the same state-and-dispatch separation.

7. Use useRef for data that does not affect the UI

State should represent data used during rendering. If changing a value should not change the JSX, a ref may be a better fit.

function Tracker() {
  const clickCount = useRef(0);

  const handleClick = () => {
    clickCount.current += 1;
  };

  return <button onClick={handleClick}>Track click</button>;
}
Enter fullscreen mode Exit fullscreen mode

Updating clickCount.current does not schedule a render. Refs are useful for timer IDs, DOM nodes, previous values, and mutable tracking data.

Do not move visible data into a ref just to avoid rendering. If the count must appear on screen, it belongs in state. React will not update the UI when only ref.current changes. The useRef documentation makes this distinction explicit.

8. Remove Effects that only derive state

An Effect that calculates one piece of state from another often creates an avoidable second render.

function Profile({ firstName, lastName }: ProfileProps) {
  const [fullName, setFullName] = useState("");

  useEffect(() => {
    setFullName(`${firstName} ${lastName}`);
  }, [firstName, lastName]);

  return <h2>{fullName}</h2>;
}
Enter fullscreen mode Exit fullscreen mode

The component first renders with the old fullName. React commits that result, runs the Effect, updates state, and renders again.

Calculate the value during rendering instead:

function Profile({ firstName, lastName }: ProfileProps) {
  const fullName = `${firstName} ${lastName}`;

  return <h2>{fullName}</h2>;
}
Enter fullscreen mode Exit fullscreen mode

Use useMemo only if the derived calculation is expensive. React's You Might Not Need an Effect guide covers this pattern and other unnecessary Effect chains.

9. Subscribe to the smallest store value you need

The same principle applies to Redux, Zustand, and other external stores. A component that subscribes to a large object may render for changes it never displays.

// Broad subscription
const user = useSelector((state: RootState) => state.user);

// Narrow subscription
const userName = useSelector((state: RootState) => state.user.name);
Enter fullscreen mode Exit fullscreen mode

The second component depends only on user.name. Whether a render is skipped depends on the store library's selector and equality behavior, so check that library's documentation before adding custom comparison functions. For example, Redux useSelector and Zustand selectors do not have identical APIs or defaults.

Also avoid returning a fresh object from a selector unless the library provides shallow comparison or memoized selectors:

// New object on every selector call
const userSummary = useSelector((state: RootState) => ({
  name: state.user.name,
  role: state.user.role,
}));
Enter fullscreen mode Exit fullscreen mode

Selecting primitives separately or using a memoized selector gives the subscription a stable result when the underlying values have not changed.

10. Profile before and after optimizing

Console logs can show that a component rendered, but they do not show whether the render was expensive. React Strict Mode may also call component functions more than once during development to detect impure rendering. That can make logs look worse than production behavior.

Use React DevTools Profiler or React's <Profiler> API to answer more useful questions:

  • Which interaction feels slow?
  • Which components rendered during it?
  • How long did they take?
  • Did memoization reduce the work?

Measure a production build when comparing performance. Development checks and tooling add overhead.

Small details that can change the diagnosis

A few cases can make render behavior look different from the simplified examples:

  • Setting state to its current value can let React skip the update. You may still see the component function called in some cases before React discards the result.
  • A memoized component still renders when its own state changes or a context it reads changes.
  • A custom comparison function for React.memo can cost more than rendering the component. Deep comparisons are especially risky when the data structure can grow.
  • Changing a component's key resets its identity and state. Keys are useful for reconciliation, but changing them is not a general re-render optimization.
  • Conditionally removing a component unmounts it. Showing it again creates a new instance, even when its JSX was previously passed through children.
  • Suspense, transitions, hydration, and concurrent rendering can cause React to start, pause, retry, or discard render work. A function call in a log does not prove that React committed a DOM update.

These cases do not invalidate the earlier techniques. They explain why a small demo, development log, and production profile may show different numbers.

Quick diagnostic matrix

Scenario Likely cause First option to consider
Typing in one input renders a large page Input state is owned too high in the tree Move the state into the input or form subtree
Expensive child renders when its parent changes unrelated state Normal parent-to-child render cascade Use composition or React.memo if the child's props remain stable
Memoized child still renders because of a callback prop A new function reference is passed each time Consider useCallback if stable identity lets that child skip work
Memoized list still renders with unchanged source data Filtering creates a new array Consider useMemo when the calculation or stable reference matters
A theme consumer renders after an unrelated context update One context contains unrelated changing values Split the context by concern
Updating a tracking value renders the component Non-visual data is stored in state Use useRef if the value does not affect JSX
Component renders twice after props change An Effect copies or derives state Calculate the value during rendering

What about React Compiler?

When React Compiler is enabled and successfully compiles the relevant components, it can apply memoization automatically and reduce the need for manual React.memo, useMemo, and useCallback. That does not make state placement, context boundaries, or unnecessary Effects irrelevant. Those choices determine how much of the tree participates in an update and how easy the code is to understand.

If the compiler is not enabled in your project, manual memoization remains useful when profiling identifies repeated expensive work.

A practical order for optimization

When a screen feels slow, work through the problem in this order:

  1. Confirm which interaction is slow with the Profiler.
  2. Move temporary state closer to the components that use it.
  3. Remove Effects that derive state unnecessarily.
  4. Split broad contexts and store subscriptions.
  5. Consider React.memo for expensive children that often receive unchanged props.
  6. Consider useCallback or useMemo when unstable references are defeating that memoization or the calculation itself is expensive.

The shortest rule is: reduce the scope of the update before caching values and functions.

React is designed to re-render components. The goal is not to reach zero re-renders. The goal is to prevent expensive work that produces no useful change for the user.

Top comments (0)