Profiling with React DevTools and Chrome DevTools
Metrics like INP tell you that something hurts; profilers tell you where. React’s model makes that
especially valuable because the same JSX can hide wildly different costs: a cheap function call that
returns the same element types, versus a subtree that recomputes expensive derived data, allocates
new objects, and forces children to reconcile. In 2026, with the React Compiler reducing the
need for hand-rolled memoization, your debugging time is better spent in DevTools than in sprinkling
useCallback everywhere “just in case.”
This section walks through a practical workflow: capture a React profiler trace for the interaction you care about, read the flamegraph with an eye toward commit time and render reasons, then cross-check the browser’s Performance panel for long tasks and layout work that explain INP regressions. Along the way, you will see small TypeScript utilities and context patterns that make unnecessary re-renders obvious—and sometimes unnecessary in the first place.
React DevTools Profiler: record, reproduce, read
Install the React DevTools browser extension if you have not already. Open your app, then DevTools, and select the Profiler tab. The basic loop is simple:
- Click Record.
- Perform the interaction you are investigating: open a modal, type into a filtered list, switch tabs, navigate client-side.
- Stop recording.
The profiler shows a timeline of commits and a flamegraph of which components rendered and how long they took. Wider bars are not automatically “bugs”—a large subtree may render quickly—but they are invitations to click in and compare durations across commits.
Select a commit and drill into a component instance. Modern DevTools exposes a “Why did this render?” style explanation: the parent re-rendered, props changed by reference, state changed, or a hook dependency changed. That single sentence saves hours of speculation. When the compiler is enabled and rules are followed, you should see fewer spurious “props changed” reasons caused purely by inline function identity; when you still see churn, it often traces to unstable objects created in parents, context values that are new every render, or list structures that defeat reconciliation.
For a live, visceral view, open DevTools settings and enable Highlight updates. React will flash components as they re-render. It is noisy, which is the point: a screen that strobes on every keystroke is telling you that either the updates are genuinely broad—or that state lives too high in the tree.
Patterns the profiler surfaces
Several issues show up again and again in React codebases, independent of framework version:
Children re-render with the parent even when their props look “the same.” If DevTools says props changed, verify whether the parent passes fresh object or array literals each render. The compiler can stabilize many cases, but it is not a license to construct giant inline objects for child props when a child is sensitive or when a library compares references shallowly.
Context value identity thrash. If you wrap the tree in a provider whose value is
{ state, dispatch } created inline, every parent render produces a new object and every consumer
re-renders—even components that only need the dispatcher. Splitting context is the classic fix; it
is boring and effective.
Lists that reconcile poorly. Wrong or unstable key values cause subtree remounts that look
like “mysterious” slowness. The profiler shows unmount/mount churn. Fix keys and colocate state so
edits do not ripple through unrelated rows.
Chrome DevTools Performance: the main thread never lies
React’s profiler is excellent for component-level attribution. Chrome’s Performance panel is excellent for thread-level truth: long tasks, layout, recalculate style, and GPU work. For INP investigations, you care deeply about anything that keeps the main thread busy for tens of milliseconds at the wrong time.
A repeatable recipe:
- Open the Performance tab.
- Enable CPU throttling (for example 4× slowdown) to approximate mobile devices.
- Enable network throttling (Slow 4G is a common choice) if your interaction triggers fetches.
- Click Record, perform the interaction, then stop.
Scan the main thread track for long tasks—often highlighted or easy to spot as blocks that exceed 50ms. Those blocks are prime INP suspects because they delay input processing and painting. Zoom into them and read the bottom-up view: is it Scripting (your React update, a library, JSON.parse), Layout or Recalculate Style (expensive DOM measurement), or Paint?
If React’s commit shows up as a tall scripting slice, return to the React profiler with the same
interaction and ask which components dominated that commit. If layout dominates, your problem may be
DOM reads in a loop, unconditional offsetHeight queries during render, or CSS that forces
synchronous layout. Fixing that class of issue rarely involves memo at all.
Tracing prop changes in development
Sometimes you only want a quick console signal. The following hook compares the current props to the previous render and logs differences. Keep it development-only—never ship noisy logging to production.
import { useEffect, useRef } from "react";
function useTraceUpdate<T extends Record<string, unknown>>(props: T) {
const prev = useRef(props);
useEffect(() => {
const changedProps = Object.entries(props).reduce<Record<string, [unknown, unknown]>>(
(acc, [key, value]) => {
if (prev.current[key] !== value) {
acc[key] = [prev.current[key], value];
}
return acc;
},
{},
);
if (Object.keys(changedProps).length > 0) {
console.log("Changed props:", changedProps);
}
prev.current = props;
});
}
Use it sparingly on components you suspect:
type MyProps = { userId: string; onSave: () => void };
function MyComponent(props: MyProps) {
useTraceUpdate(props as Record<string, unknown>);
return <output>{props.userId}</output>;
}
If logs show onSave toggling every render while userId stays stable, you have a referential
instability problem upstream—or a child that should not receive a new callback each time.
Context splitting: stop coupling reads to writes
A single context that exposes both state and dispatch is convenient to author and expensive to
consume. Any state change creates a new { state, dispatch } object; consumers that only dispatch
still re-render because the context value’s identity changed.
Splitting into two contexts isolates stable dispatch from changing state:
import {
createContext,
useContext,
useReducer,
type Dispatch,
type ReactNode,
} from "react"
type AppState = { count: number }
type AppAction = { type: "ADD" }
const AppStateContext = createContext<AppState | null>(null)
const AppDispatchContext = createContext<Dispatch<AppAction> | null>(null)
function AddButton() {
const dispatch = useContext(AppDispatchContext)
if (!dispatch) throw new Error("AddButton must be used within provider")
return <button onClick={() => dispatch({ type: "ADD" })}>Add</button>
}
function appReducer(state: AppState, action: AppAction): AppState {
if (action.type === "ADD") return { count: state.count + 1 }
return state
}
function AppProviders({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(appReducer, { count: 0 })
return (
<AppDispatchContext.Provider value={dispatch}>
<AppStateContext.Provider value={state}>{children}</AppStateContext.Provider>
</AppDispatchContext.Provider>
)
}
Components that only call dispatch no longer subscribe to state updates they never read. This
pattern pairs naturally with TypeScript because each context has a narrow type; your hooks file can
expose useAppState and useAppDispatch helpers that assert non-null once instead of repeating
guards.
From traces to targeted fixes
Profiling should end with a short list of concrete actions: move state down, split context, fix a
list key, defer non-urgent work with startTransition, or remove a synchronous heavy import from
the critical path. If you cannot connect a change to a shorter flamegraph bar or a smaller long
task, reconsider whether the complexity is worth it—especially now that the compiler handles much of
the mechanical memoization story for you.