con un clic
react-perf
React performance optimization patterns
Instalar con Codex o Claude Copia este prompt, pégalo en Codex, Claude u otro asistente, y deja que revise la página de la skill y la instale por ti.
Menú
React performance optimization patterns
Instalar con Codex o Claude Copia este prompt, pégalo en Codex, Claude u otro asistente, y deja que revise la página de la skill y la instale por ti.
Basado en la clasificación ocupacional SOC
Audit a project against a canon's rules and checklist. Read-only — produces prioritized report without fixing. Works with any canon (nextjs, sql, typescript, etc.).
Lens home base - status, help, and setup
Plan and build a new feature with quality gates.
Simple changes done right. Make the change, clean up after yourself, report what happened.
Review against canons + quality gate, fix findings, verify. Claude-native — no external models.
Plan and improve existing code with quality gates.
| name | react-perf |
| description | React performance optimization patterns |
The React team's core belief: Don't optimize what you haven't measured. Most components don't need memoization. The ones that do need it applied correctly, or it's worse than nothing.
"Premature optimization is the root of all evil, but missing obvious optimization is the root of all jank."
React performance: fewer renders, less JavaScript shipped, only visible rows rendered, always measured first.
React.memo skips re-rendering when props haven't changed. It only helps when ALL three are true: same props frequently, expensive render, and stable prop identity.
Not this:
// Cheap component -- memo adds overhead for nothing
const Badge = React.memo(({ label }: { label: string }) => {
return <span className="badge">{label}</span>;
});
This:
// Expensive + frequently receives same props
const Chart = React.memo(({ data, config }: ChartProps) => {
const paths = computePathsFromData(data); // expensive SVG calculations
return <svg>{paths.map(p => <path key={p.id} d={p.d} fill={p.color} />)}</svg>;
});
When NOT to use it: component is cheap to render, props almost always differ, or props include unstabilized children/inline objects/inline functions.
A -- Referential stability (keeping same object reference):
function Dashboard({ userId }: { userId: string }) {
// Without useMemo, filter is new every render, causing DataGrid's useEffect to re-fire
const filter = useMemo(() => ({ userId, active: true }), [userId]);
return <DataGrid filter={filter} />;
}
B -- Expensive computation:
function Analytics({ transactions }: { transactions: Transaction[] }) {
const sorted = useMemo(
() => [...transactions].sort((a, b) => b.amount - a.amount),
[transactions]
);
return <TransactionTable rows={sorted} />;
}
Not this -- useMemo for trivial work:
// String concat is not expensive. Just compute it.
const fullName = useMemo(() => `${first} ${last}`, [first, last]);
// This:
const fullName = `${first} ${last}`;
Dependency array pitfall:
// BUG: options is a new object every render, useMemo never caches
const options = { threshold: 10, limit: 100 };
const result = useMemo(() => processItems(items, options), [items, options]);
// FIX: inline the values so the dependency is stable
const result = useMemo(() => processItems(items, { threshold: 10, limit: 100 }), [items]);
useCallback only matters when the callback is passed to a React.memo component.
Not this -- useCallback without a memoized consumer:
function SearchPage() {
const handleChange = useCallback((e) => setQuery(e.target.value), []);
return <SearchInput onChange={handleChange} />; // SearchInput isn't memoized, pointless
}
This -- paired with React.memo:
const SearchInput = React.memo(({ onChange }: SearchInputProps) => {
return <input onChange={onChange} />;
});
function SearchPage() {
const [query, setQuery] = useState('');
const handleChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
setQuery(e.target.value);
}, []);
return (
<>
<SearchInput onChange={handleChange} />
<Results query={query} />
</>
);
}
The relationship: useCallback is the supplier, React.memo is the consumer. One without the other does nothing.
React Compiler (formerly React Forget) inserts memoization at build time, making manual memo largely unnecessary.
// You write (no manual memoization):
function ProductList({ products, onSelect }: ProductListProps) {
const sorted = products.toSorted((a, b) => a.price - b.price);
return (
<ul>
{sorted.map(p => <ProductCard key={p.id} product={p} onSelect={onSelect} />)}
</ul>
);
}
// Compiler auto-memoizes sorted, the map output, and ProductCard props
What to do today: new projects on React 19+ enable the compiler; existing projects keep manual memo until migration; either way follow Rules of React (pure render, stable hooks) so the compiler can optimize.
Route-based splitting (the baseline):
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
const AdminPanel = lazy(() => import('./pages/AdminPanel'));
function App() {
return (
<Suspense fallback={<PageSkeleton />}>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
<Route path="/admin" element={<AdminPanel />} />
</Routes>
</Suspense>
);
}
Preloading on hover/intent:
const Settings = lazy(() => import('./pages/Settings'));
function NavLink() {
const preload = () => import('./pages/Settings');
return <Link to="/settings" onMouseEnter={preload} onFocus={preload}>Settings</Link>;
}
Not this: static imports for every page, forcing one giant bundle on first load.
For long lists (100+ items), render only visible rows with TanStack Virtual.
Not this:
// 10,000 DOM nodes
<ul>{users.map(u => <UserCard key={u.id} user={u} />)}</ul>
This:
import { useVirtualizer } from '@tanstack/react-virtual';
function UserList({ users }: { users: User[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: users.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 64,
});
return (
<div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map(row => (
<div key={row.key} style={{
position: 'absolute', top: row.start, height: row.size, width: '100%',
}}>
<UserCard user={users[row.index]} />
</div>
))}
</div>
</div>
);
}
Windowing: ~20 DOM nodes exist regardless of list length. 10,000 items? Still ~20 nodes.
Not this -- index keys on a dynamic list:
{todos.map((todo, index) => (
// Inserting at top shifts all indexes -- React unmounts/remounts every item
<TodoItem key={index} todo={todo} />
))}
This -- stable unique keys:
{todos.map(todo => (
<TodoItem key={todo.id} todo={todo} /> // React knows exactly which items moved
))}
Index keys are fine for static lists that never reorder and have no component state. Index keys break lists that reorder, filter, insert, or have local state (inputs, toggles).
Every state update re-renders the owning component and all descendants. Push state down.
Not this:
function App() {
const [searchQuery, setSearchQuery] = useState('');
const [isTooltipOpen, setIsTooltipOpen] = useState(false);
return (
<div>
<Header /> {/* re-renders on tooltip change */}
<SearchBar query={searchQuery} onChange={setSearchQuery} />
<Sidebar /> {/* re-renders on search change */}
<Tooltip open={isTooltipOpen} onToggle={setIsTooltipOpen} />
</div>
);
}
This:
function App() {
return (
<div>
<Header />
<SearchSection /> {/* owns its own query state */}
<Sidebar />
<TooltipWrapper /> {/* owns its own open state */}
</div>
);
}
The rule: if only one subtree uses a piece of state, that subtree owns it. Lift only when siblings genuinely share.
Children passed as props are already created -- they skip re-rendering when the parent re-renders.
Not this:
function App() {
const [color, setColor] = useState('red');
return (
<div style={{ color }}>
<input value={color} onChange={e => setColor(e.target.value)} />
<ExpensiveTree /> {/* re-renders every keystroke */}
</div>
);
}
This:
function ColorPicker({ children }: { children: ReactNode }) {
const [color, setColor] = useState('red');
return (
<div style={{ color }}>
<input value={color} onChange={e => setColor(e.target.value)} />
{children} {/* same object reference, skips re-render */}
</div>
);
}
function App() {
return (
<ColorPicker>
<ExpensiveTree />
</ColorPicker>
);
}
Why: <ExpensiveTree /> is created in App's render. App doesn't re-render when ColorPicker's state changes, so the element reference is stable.
DevTools Profiler workflow: Record > interact > stop > read flame chart. Wide bars = slow renders. Gray = skipped. Focus on components that render often AND take long.
Programmatic Profiler:
import { Profiler, ProfilerOnRenderCallback } from 'react';
const onRender: ProfilerOnRenderCallback = (id, phase, actualDuration) => {
if (actualDuration > 16) { // longer than one frame at 60fps
console.warn(`Slow render: ${id} took ${actualDuration.toFixed(1)}ms`);
}
};
function App() {
return (
<Profiler id="Navigation" onRender={onRender}>
<Navigation />
</Profiler>
);
}
The optimization loop: Profile > rendering too often? React.memo + stable props. Each render slow? useMemo or virtualize. Re-profile to verify.
Math.random() or index on dynamic lists forces full DOM recreation.style={{ color: 'red' }} defeats React.memo with a new reference every render.Is the component visibly slow when profiled?
No -> Don't memoize.
Yes -> Rendering too often with same props?
Yes -> React.memo + stabilize props (useCallback/useMemo)
No -> Each render expensive?
Yes -> useMemo the expensive part, or virtualize
No -> Problem is elsewhere. Check parent.
| Target | Action |
|---|---|
| Route/page | Always split |
| Heavy component (editor, chart, map) | Split with lazy + Suspense |
| Small UI element | Keep in main bundle |
"Profile first. Fix what's slow. Skip the rest." -- React Performance Guidance