Client-Side Rendering
Master client-side rendering performance — SPA rendering optimization, reducing unnecessary re-renders, skeleton screen patterns, progressive rendering strategies, virtual DOM efficiency, React performance profiling, and concurrent rendering features for responsive user interfaces.
When to Use
- A SPA has slow initial render due to large JavaScript bundles
- React DevTools Profiler shows components re-rendering unnecessarily
- User interactions feel sluggish (INP > 200ms) in a client-rendered application
- Skeleton screens are needed to improve perceived performance during data fetching
- State changes in parent components cause expensive child re-renders
- A dashboard with many widgets re-renders all widgets when one updates
- Form inputs lag because typing triggers expensive renders in unrelated components
- List rendering with hundreds of items causes visible frame drops
- Transitioning between views in a SPA shows blank content instead of loading states
- React concurrent features (useTransition, useDeferredValue) could improve responsiveness
Instructions
Profile rendering with React DevTools Profiler. Identify which components render, why, and how long they take:
1. Open React DevTools → Profiler tab
2. Click "Record" → interact with the application → "Stop"
3. Examine the flame chart:
- Gray components: did not render
- Colored components: rendered (warmer = slower)
- "Why did this render?" shows the trigger
4. Look for:
- Components rendering on unrelated state changes
- Large subtrees re-rendering for small changes
- Components rendering >16ms (frame budget)
Prevent unnecessary re-renders with memoization. Use React.memo, useMemo, and useCallback strategically:
// React.memo: skip re-render when props are unchanged
const ExpensiveList = React.memo(function ExpensiveList({
items,
onItemClick,
}: {
items: Item[];
onItemClick: (id: string) => void;
}) {
return (
<ul>
{items.map(item => (
<ListItem key={item.id} item={item} />
))}
</ul>
);
});
// Parent: stabilize callback reference
function Dashboard() {
const [items, setItems] = useState<Item[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
// useCallback: stable reference across renders
const handleItemClick = useCallback((id: string) => {
setSelectedId(id);
}, []);
return (
<>
<Sidebar selectedId={selectedId} />
<ExpensiveList items={items} />
</>
);
}
Implement skeleton screens for data-loading states. Skeletons reduce perceived load time by 15-30%:
function ProductGrid() {
const { data, isLoading } = useProducts();
if (isLoading) {
return (
<div className="grid">
{Array.from({ length: 12 }, (_, i) => (
<ProductCardSkeleton key={i} />
))}
</div>
);
}
return (
<div className="grid">
{data.map(product => <ProductCard key={product.id} product={product} />)}
</div>
);
}
Use a CSS shimmer animation (background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%); background-size: 200% 100%; animation: shimmer 1.5s infinite) on .skeleton elements with explicit dimensions matching the loaded content to prevent layout shift.
Use concurrent rendering for responsive interactions. React 18 concurrent features prevent expensive renders from blocking user input:
import { useTransition, useDeferredValue } from 'react';
// useTransition: mark state updates as non-urgent
function SearchPage() {
const [query, setQuery] = useState('');
const [results, setResults] = useState<Result[]>([]);
const [isPending, startTransition] = useTransition();
function handleSearch(e: React.ChangeEvent<HTMLInputElement>) {
const value = e.target.value;
setQuery(value); // urgent: update input immediately
startTransition(() => {
// non-urgent: can be interrupted by user input
const filtered = filterResults(allData, value);
setResults(filtered);
});
}
return (
<>
<input value={query} />
{isPending && <Spinner />}
<ResultsList results={results} />
</>
);
}
// useDeferredValue: defer expensive re-renders
function FilteredList({ filter }: { filter: string }) {
const deferredFilter = useDeferredValue(filter);
return (
<div style={{ opacity: filter !== deferredFilter ? 0.7 : 1 }}>
<ExpensiveList filter={deferredFilter} />
</div>
);
}
Optimize list rendering. Large lists are the most common CSR performance problem:
// Key stability: use stable IDs, not array index
// Bad: items.map((item, index) => <Item key={index} ... />)
// Good: items.map(item => <Item key={item.id} ... />)
// Avoid creating new objects/arrays in render
// Bad: <List items={data.filter(d => d.active)} />
// Good:
const activeItems = useMemo(
() => data.filter(d => d.active),
[data]
);
return <List items={activeItems} />;
// For very long lists (1000+), use virtualization
// See: perf-lazy-loading skill for virtual scrolling patterns
Batch state updates for fewer renders. React 18 automatically batches state updates in all contexts:
// React 18: all state updates are batched automatically
// This triggers ONE re-render, not three:
async function handleSubmit() {
const data = await fetchData();
setItems(data.items); // batched
setTotal(data.total); // batched
setLoading(false); // batched → single re-render
}
// When you need to force a synchronous update (rare):
import { flushSync } from 'react-dom';
flushSync(() => {
setMeasurement(value); // renders immediately
});
// DOM is updated here — safe to measure
const height = ref.current.offsetHeight;
Profile with the Performance panel. Beyond React DevTools, the Chrome Performance panel shows the full picture:
1. Performance tab → Record → interact → Stop
2. Look for:
- Long Tasks (>50ms) in the Main thread
- Scripting vs Rendering vs Painting breakdown
- React commit phases (look for "React" in the call stack)
3. Common findings:
- Large component trees re-rendering: many short React frames
- Single expensive computation: one long scripting block
- Layout thrashing: alternating "Recalculate Style" and "Layout"
Details
Virtual DOM Reconciliation Cost
React's reconciliation algorithm diffs the previous and next virtual DOM trees to determine minimal DOM updates. The diffing itself is O(n) where n is the number of elements. For 1000 list items, the diff cost is ~1-5ms. The expensive part is DOM mutation: inserting, moving, or removing real DOM nodes costs ~0.1-0.5ms each. This is why stable keys are critical — they help React match elements across renders and minimize DOM mutations.
Worked Example: Figma File Browser
Figma uses windowed rendering plus React.memo on file cards with Zustand selector-based subscriptions, so selecting a file does not re-render the grid. The search input uses useDeferredValue to keep typing responsive. Result: 60fps scrolling through 10,000+ files with <50ms INP.
Worked Example: Linear Issue Tracker
Linear achieves instant-feeling interactions via: (1) optimistic updates before server confirmation, (2) fine-grained state subscriptions so status changes re-render only that row, (3) CSS transitions instead of React-driven animations, (4) skeleton screens matching loaded layout. Result: <50ms INP for status changes and zero layout shift.
Anti-Patterns
Premature memoization. Only memoize components that receive complex props and render frequently, are expensive (>5ms), or sit below frequently-changing state. Profile first, memoize second.
State in the wrong component. Lifting state too high causes entire subtrees to re-render. Keep state as close to where it is used as possible.
Creating objects in JSX props. <Child style={{ color: 'red' }} /> creates a new object every render, defeating React.memo. Extract constants or use useMemo.
Synchronous heavy computation during render. Move heavy computation (sorting, filtering 10K+ items) to a Web Worker or use useDeferredValue.
Source
Process
- Read the instructions and examples in this document.
- Apply the patterns to your implementation, adapting to your specific context.
- Verify your implementation against the details and edge cases listed above.
Harness Integration
- Type: knowledge — this skill is a reference document, not a procedural workflow.
- No tools or state — consumed as context by other skills and agents.
Success Criteria
- React DevTools Profiler shows no unnecessary re-renders on common interactions.
- All loading states use skeleton screens that match loaded content dimensions.
- useTransition or useDeferredValue is used for expensive filtering/sorting operations.
- List rendering uses stable keys and memoized items where beneficial.
- INP is under 200ms for all common user interactions.
1---2name: perf-client-side-rendering3description: Client-Side Rendering4---5# Client-Side Rendering67> Master client-side rendering performance — SPA rendering optimization, reducing unnecessary re-renders, skeleton screen patterns, progressive rendering strategies, virtual DOM efficiency, React performance profiling, and concurrent rendering features for responsive user interfaces.89## When to Use1011- A SPA has slow initial render due to large JavaScript bundles12- React DevTools Profiler shows components re-rendering unnecessarily13- User interactions feel sluggish (INP > 200ms) in a client-rendered application14- Skeleton screens are needed to improve perceived performance during data fetching15- State changes in parent components cause expensive child re-renders16- A dashboard with many widgets re-renders all widgets when one updates17- Form inputs lag because typing triggers expensive renders in unrelated components18- List rendering with hundreds of items causes visible frame drops19- Transitioning between views in a SPA shows blank content instead of loading states20- React concurrent features (useTransition, useDeferredValue) could improve responsiveness2122## Instructions23241. **Profile rendering with React DevTools Profiler.** Identify which components render, why, and how long they take:2526 ```27 1. Open React DevTools → Profiler tab28 2. Click "Record" → interact with the application → "Stop"29 3. Examine the flame chart:30 - Gray components: did not render31 - Colored components: rendered (warmer = slower)32 - "Why did this render?" shows the trigger33 4. Look for:34 - Components rendering on unrelated state changes35 - Large subtrees re-rendering for small changes36 - Components rendering >16ms (frame budget)37 ```38392. **Prevent unnecessary re-renders with memoization.** Use React.memo, useMemo, and useCallback strategically:4041 ```typescript42 // React.memo: skip re-render when props are unchanged43 const ExpensiveList = React.memo(function ExpensiveList({44 items,45 onItemClick,46 }: {47 items: Item[];48 onItemClick: (id: string) => void;49 }) {50 return (51 <ul>52 {items.map(item => (53 <ListItem key={item.id} item={item} onClick={onItemClick} />54 ))}55 </ul>56 );57 });5859 // Parent: stabilize callback reference60 function Dashboard() {61 const [items, setItems] = useState<Item[]>([]);62 const [selectedId, setSelectedId] = useState<string | null>(null);6364 // useCallback: stable reference across renders65 const handleItemClick = useCallback((id: string) => {66 setSelectedId(id);67 }, []);6869 return (70 <>71 <Sidebar selectedId={selectedId} />72 <ExpensiveList items={items} onItemClick={handleItemClick} />73 </>74 );75 }76 ```77783. **Implement skeleton screens for data-loading states.** Skeletons reduce perceived load time by 15-30%:7980 ```typescript81 function ProductGrid() {82 const { data, isLoading } = useProducts();8384 if (isLoading) {85 return (86 <div className="grid">87 {Array.from({ length: 12 }, (_, i) => (88 <ProductCardSkeleton key={i} />89 ))}90 </div>91 );92 }9394 return (95 <div className="grid">96 {data.map(product => <ProductCard key={product.id} product={product} />)}97 </div>98 );99 }100 ```101102 Use a CSS shimmer animation (`background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%); background-size: 200% 100%; animation: shimmer 1.5s infinite`) on `.skeleton` elements with explicit dimensions matching the loaded content to prevent layout shift.1031044. **Use concurrent rendering for responsive interactions.** React 18 concurrent features prevent expensive renders from blocking user input:105106 ```typescript107 import { useTransition, useDeferredValue } from 'react';108109 // useTransition: mark state updates as non-urgent110 function SearchPage() {111 const [query, setQuery] = useState('');112 const [results, setResults] = useState<Result[]>([]);113 const [isPending, startTransition] = useTransition();114115 function handleSearch(e: React.ChangeEvent<HTMLInputElement>) {116 const value = e.target.value;117 setQuery(value); // urgent: update input immediately118119 startTransition(() => {120 // non-urgent: can be interrupted by user input121 const filtered = filterResults(allData, value);122 setResults(filtered);123 });124 }125126 return (127 <>128 <input value={query} onChange={handleSearch} />129 {isPending && <Spinner />}130 <ResultsList results={results} />131 </>132 );133 }134135 // useDeferredValue: defer expensive re-renders136 function FilteredList({ filter }: { filter: string }) {137 const deferredFilter = useDeferredValue(filter);138 return (139 <div style={{ opacity: filter !== deferredFilter ? 0.7 : 1 }}>140 <ExpensiveList filter={deferredFilter} />141 </div>142 );143 }144 ```1451465. **Optimize list rendering.** Large lists are the most common CSR performance problem:147148 ```typescript149 // Key stability: use stable IDs, not array index150 // Bad: items.map((item, index) => <Item key={index} ... />)151 // Good: items.map(item => <Item key={item.id} ... />)152153 // Avoid creating new objects/arrays in render154 // Bad: <List items={data.filter(d => d.active)} />155 // Good:156 const activeItems = useMemo(157 () => data.filter(d => d.active),158 [data]159 );160 return <List items={activeItems} />;161162 // For very long lists (1000+), use virtualization163 // See: perf-lazy-loading skill for virtual scrolling patterns164 ```1651666. **Batch state updates for fewer renders.** React 18 automatically batches state updates in all contexts:167168 ```typescript169 // React 18: all state updates are batched automatically170 // This triggers ONE re-render, not three:171 async function handleSubmit() {172 const data = await fetchData();173 setItems(data.items); // batched174 setTotal(data.total); // batched175 setLoading(false); // batched → single re-render176 }177178 // When you need to force a synchronous update (rare):179 import { flushSync } from 'react-dom';180 flushSync(() => {181 setMeasurement(value); // renders immediately182 });183 // DOM is updated here — safe to measure184 const height = ref.current.offsetHeight;185 ```1861877. **Profile with the Performance panel.** Beyond React DevTools, the Chrome Performance panel shows the full picture:188189 ```190 1. Performance tab → Record → interact → Stop191 2. Look for:192 - Long Tasks (>50ms) in the Main thread193 - Scripting vs Rendering vs Painting breakdown194 - React commit phases (look for "React" in the call stack)195 3. Common findings:196 - Large component trees re-rendering: many short React frames197 - Single expensive computation: one long scripting block198 - Layout thrashing: alternating "Recalculate Style" and "Layout"199 ```200201## Details202203### Virtual DOM Reconciliation Cost204205React's reconciliation algorithm diffs the previous and next virtual DOM trees to determine minimal DOM updates. The diffing itself is O(n) where n is the number of elements. For 1000 list items, the diff cost is ~1-5ms. The expensive part is DOM mutation: inserting, moving, or removing real DOM nodes costs ~0.1-0.5ms each. This is why stable keys are critical — they help React match elements across renders and minimize DOM mutations.206207### Worked Example: Figma File Browser208209Figma uses windowed rendering plus React.memo on file cards with Zustand selector-based subscriptions, so selecting a file does not re-render the grid. The search input uses useDeferredValue to keep typing responsive. Result: 60fps scrolling through 10,000+ files with <50ms INP.210211### Worked Example: Linear Issue Tracker212213Linear achieves instant-feeling interactions via: (1) optimistic updates before server confirmation, (2) fine-grained state subscriptions so status changes re-render only that row, (3) CSS transitions instead of React-driven animations, (4) skeleton screens matching loaded layout. Result: <50ms INP for status changes and zero layout shift.214215### Anti-Patterns216217**Premature memoization.** Only memoize components that receive complex props and render frequently, are expensive (>5ms), or sit below frequently-changing state. Profile first, memoize second.218219**State in the wrong component.** Lifting state too high causes entire subtrees to re-render. Keep state as close to where it is used as possible.220221**Creating objects in JSX props.** `<Child style={{ color: 'red' }} />` creates a new object every render, defeating React.memo. Extract constants or use useMemo.222223**Synchronous heavy computation during render.** Move heavy computation (sorting, filtering 10K+ items) to a Web Worker or use useDeferredValue.224225## Source226227- React: Performance — https://react.dev/learn/render-and-commit228- React: useTransition — https://react.dev/reference/react/useTransition229- React: Profiler — https://react.dev/reference/react/Profiler230- web.dev: Rendering performance — https://web.dev/articles/rendering-performance231232## Process2332341. Read the instructions and examples in this document.2352. Apply the patterns to your implementation, adapting to your specific context.2363. Verify your implementation against the details and edge cases listed above.237238## Harness Integration239240- **Type:** knowledge — this skill is a reference document, not a procedural workflow.241- **No tools or state** — consumed as context by other skills and agents.242243## Success Criteria244245- React DevTools Profiler shows no unnecessary re-renders on common interactions.246- All loading states use skeleton screens that match loaded content dimensions.247- useTransition or useDeferredValue is used for expensive filtering/sorting operations.248- List rendering uses stable keys and memoized items where beneficial.249- INP is under 200ms for all common user interactions.