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:
- Its own state changed.
- One of its ancestors rendered, causing the normal render cascade through descendants.
- A context it reads received a different value.
- 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>
);
}
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>
);
}
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>
);
}
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:
-
Dashboardre-renders. - Its own state or context changes.
- It is removed and mounted again through conditional rendering.
{isOpen && children}
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>
);
});
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)} />
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} />
</>
);
}
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);
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} />;
}
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>
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>
);
}
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>;
}
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>;
}
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>;
}
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);
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,
}));
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.memocan cost more than rendering the component. Deep comparisons are especially risky when the data structure can grow. - Changing a component's
keyresets 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:
- Confirm which interaction is slow with the Profiler.
- Move temporary state closer to the components that use it.
- Remove Effects that derive state unnecessarily.
- Split broad contexts and store subscriptions.
- Consider
React.memofor expensive children that often receive unchanged props. - Consider
useCallbackoruseMemowhen 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)