Comprehensive practices for building 60-240 FPS UI with React and TypeScript
Table of Contents
- React Rendering Fundamentals
- React 19 Compiler
- Component Optimization
- Memoization Patterns
- State Management
- Context Optimization
- Virtualization
- Concurrent Features
- Event Handling
- CSS & Styling Performance
- Image Optimization
- Code Splitting
- TypeScript Patterns
- Web Workers
- Profiling & Debugging
- Quick Reference
1. React Rendering Fundamentals
The Render Cycle
┌─────────────────────────────────────────────────────────────────────────────┐
│ REACT RENDERING CYCLE │
├───────────────┬───────────────────┬─────────────────────────────────────────┤
│ TRIGGER │ RENDER │ COMMIT │
│ (State/Props) │ (Virtual DOM) │ (Real DOM) │
├───────────────┼───────────────────┼─────────────────────────────────────────┤
│ • setState │ • Call component │ • Apply DOM changes │
│ • Props │ functions │ • Run useLayoutEffect │
│ change │ • Diff old vs new │ • Paint to screen │
│ • Parent │ • Calculate │ • Run useEffect │
│ re-render │ minimal updates │ │
└───────────────┴───────────────────┴─────────────────────────────────────────┘
Frame Budget
| Target FPS | Frame Budget | JS Budget (approx) |
|---|
| 60 FPS | 16.67ms | ~10ms |
| 120 FPS | 8.33ms | ~5ms |
| 240 FPS | 4.17ms | ~3ms |
What Triggers Re-renders
// 1. State change
const [count, setCount] = useState(0);
setCount(1); // → Re-render
// 2. Props change
<Child data={newData} /> // → Child re-renders
// 3. Parent re-render
function Parent() {
const [, forceUpdate] = useState({});
// When Parent re-renders, Child re-renders too
return <Child />;
}
// 4. Context change
const ThemeContext = createContext('light');
// All consumers re-render when context value changes
Virtual DOM Reconciliation
// React compares virtual DOM trees
// Key rules for efficient reconciliation:
// 1. Same type → update props
<div className="old" /> → <div className="new" /> // Update class
// 2. Different type → unmount and remount
<div /> → <span /> // Complete replacement
// 3. Keys identify elements in lists
{items.map(item => <Item key={item.id} />)} // Track by ID
2. React 19 Compiler
Automatic Optimization
React 19’s compiler automatically handles what you previously did manually:
// ❌ Pre-React 19: Manual memoization everywhere
const MemoizedComponent = React.memo(function Component({ data }: Props) {
const processed = useMemo(() => expensiveOp(data), [data]);
const handler = useCallback(() => doAction(data), [data]);
return (
<div onClick={handler}>
{processed.map(item => <Item key={item.id} item={item} />)}
</div>
);
});
// ✅ React 19: Just write simple code
function Component({ data }: Props) {
const processed = expensiveOp(data);
const handler = () => doAction(data);
return (
<div onClick={handler}>
{processed.map(item => <Item key={item.id} item={item} />)}
</div>
);
}
// Compiler automatically:
// - Memoizes component
// - Memoizes expensive calculations
// - Stabilizes function references
When Manual Optimization Still Matters
// ✅ Still use useMemo for:
// 1. Third-party libraries requiring stable references
const mapLayer = useMemo(
() => createMapboxLayer(geoData),
[geoData]
);
// 2. Truly expensive O(n²) or worse operations
const relationships = useMemo(() => {
return items.flatMap(item =>
items.map(other => computeRelationship(item, other))
);
}, [items]);
// 3. Objects passed to non-React APIs
const chartConfig = useMemo(
() => ({ width: 800, height: 600, theme: currentTheme }),
[currentTheme]
);
// 4. Reference equality for effect dependencies
const options = useMemo(
() => ({ retry: 3, timeout: 5000 }),
[]
);
useEffect(() => {
fetchData(options);
}, [options]);
Checking Compiler Status
// Check if compiler is optimizing
if (process.env.NODE_ENV === 'development') {
// React DevTools shows "compiled" badge
}
// babel.config.js for React 19
module.exports = {
plugins: [
['babel-plugin-react-compiler', {
// Configuration
}]
]
};
3. Component Optimization
Isolate State to Smallest Scope
// ❌ BAD: State at top level causes cascade re-renders
function ProductPage({ product }: Props) {
const [quantity, setQuantity] = useState(1); // Changes often
return (
<div>
<ProductHeader product={product} /> {/* Re-renders! */}
<ProductGallery images={product.images} /> {/* Re-renders! */}
<QuantitySelector
value={quantity}
onChange={setQuantity}
/>
<ProductReviews productId={product.id} /> {/* Re-renders! */}
</div>
);
}
// ✅ GOOD: State isolated in child
function ProductPage({ product }: Props) {
return (
<div>
<ProductHeader product={product} /> {/* Never re-renders */}
<ProductGallery images={product.images} /> {/* Never re-renders */}
<QuantitySection product={product} /> {/* Only this re-renders */}
<ProductReviews productId={product.id} /> {/* Never re-renders */}
</div>
);
}
function QuantitySection({ product }: Props) {
const [quantity, setQuantity] = useState(1); // Isolated here
return (
<>
<QuantitySelector value={quantity} onChange={setQuantity} />
<AddToCartButton product={product} quantity={quantity} />
</>
);
}
React.memo for Expensive Children
// ✅ Wrap components that receive stable props but have expensive renders
interface ProductCardProps {
product: Product;
onAddToCart: (id: string) => void;
}
const ProductCard = memo(function ProductCard({
product,
onAddToCart
}: ProductCardProps) {
// Expensive rendering
return (
<div className="product-card">
<img src={product.image} alt={product.name} />
<h3>{product.name}</h3>
<p>{product.description}</p>
<button onClick={() => onAddToCart(product.id)}>
Add to Cart
</button>
</div>
);
});
Custom Comparison Function
// ✅ For complex props, provide custom comparison
interface ChartProps {
data: DataPoint[];
config: ChartConfig;
}
const Chart = memo(
function Chart({ data, config }: ChartProps) {
return <ExpensiveChartRender data={data} config={config} />;
},
(prevProps, nextProps) => {
// Return true if props are equal (skip re-render)
return (
prevProps.data.length === nextProps.data.length &&
prevProps.config.theme === nextProps.config.theme &&
prevProps.data === nextProps.data // Reference equality for immutable data
);
}
);
Component Composition Pattern
// ✅ Use children to avoid re-rendering static content
function ExpensiveLayout({ children }: { children: ReactNode }) {
// This component might re-render...
const [, forceUpdate] = useState({});
return (
<div className="layout">
<Header />
{children} {/* Children are passed by reference, not re-created */}
<Footer />
</div>
);
}
// Usage
function App() {
return (
<ExpensiveLayout>
<StaticContent /> {/* This won't re-render when layout does */}
</ExpensiveLayout>
);
}
4. Memoization Patterns
useMemo for Expensive Calculations
// ✅ Memoize truly expensive operations
function SearchResults({ items, query }: Props) {
const filteredItems = useMemo(() => {
console.log('Filtering...'); // Only logs when items or query change
return items.filter(item =>
item.name.toLowerCase().includes(query.toLowerCase())
);
}, [items, query]);
const sortedItems = useMemo(() => {
console.log('Sorting...');
return [...filteredItems].sort((a, b) => a.name.localeCompare(b.name));
}, [filteredItems]);
return (
<ul>
{sortedItems.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
}
When NOT to useMemo
// ❌ Don't memoize simple operations
const fullName = useMemo(
() => `${firstName} ${lastName}`, // String concat is cheap
[firstName, lastName]
);
// ✅ Just compute directly
const fullName = `${firstName} ${lastName}`;
// ❌ Don't memoize primitive values
const isAdult = useMemo(() => age >= 18, [age]);
// ✅ Just compute directly
const isAdult = age >= 18;
useCallback for Stable References
// ✅ Stabilize callbacks passed to memoized children
function ParentList({ items }: Props) {
const [selectedId, setSelectedId] = useState<string | null>(null);
const handleSelect = useCallback((id: string) => {
setSelectedId(id);
}, []);
const handleDelete = useCallback((id: string) => {
// Delete logic
}, []);
return (
<ul>
{items.map(item => (
<MemoizedItem
key={item.id}
item={item}
isSelected={selectedId === item.id}
onSelect={handleSelect}
onDelete={handleDelete}
/>
))}
</ul>
);
}
const MemoizedItem = memo(function Item({
item,
isSelected,
onSelect,
onDelete
}: ItemProps) {
return (
<li className={isSelected ? 'selected' : ''}>
<span onClick={() => onSelect(item.id)}>{item.name}</span>
<button onClick={() => onDelete(item.id)}>Delete</button>
</li>
);
});
Memoization Cost Analysis
// useMemo has overhead:
// 1. Store previous deps in memory
// 2. Compare deps on every render
// 3. Store previous result
// Only beneficial when:
// - Computation is expensive (> 1ms)
// - Dependencies rarely change
// - Result is passed to memoized children
// Measure before adding useMemo!
console.time('computation');
const result = expensiveOperation(data);
console.timeEnd('computation'); // Check if > 1ms
5. State Management
| Solution | Re-render Scope | Bundle Size | Best For |
|---|
| useState | Component + children | 0KB | Local state |
| useReducer | Component + children | 0KB | Complex local state |
| Context | All consumers | 0KB | Theme, auth |
| Zustand | Selected slices only | ~1KB | App state |
| Jotai | Per-atom | ~2KB | Fine-grained |
| Redux Toolkit | Selected slices | ~10KB | Large apps |
| TanStack Query | Per-query | ~12KB | Server state |
Zustand with Selectors
import { create } from 'zustand';
import { shallow } from 'zustand/shallow';
interface Store {
user: User | null;
cart: CartItem[];
orders: Order[];
addToCart: (item: CartItem) => void;
removeFromCart: (id: string) => void;
setUser: (user: User | null) => void;
}
const useStore = create<Store>((set) => ({
user: null,
cart: [],
orders: [],
addToCart: (item) => set((state) => ({
cart: [...state.cart, item]
})),
removeFromCart: (id) => set((state) => ({
cart: state.cart.filter(item => item.id !== id)
})),
setUser: (user) => set({ user }),
}));
// ✅ Select only what you need
function CartIcon() {
// Only re-renders when cart length changes
const itemCount = useStore((state) => state.cart.length);
return <Badge count={itemCount} />;
}
function CartTotal() {
// Only re-renders when total changes
const total = useStore((state) =>
state.cart.reduce((sum, item) => sum + item.price * item.quantity, 0)
);
return <span>${total.toFixed(2)}</span>;
}
// ✅ Multiple selectors with shallow comparison
function CartSummary() {
const { itemCount, total } = useStore(
(state) => ({
itemCount: state.cart.length,
total: state.cart.reduce((sum, item) => sum + item.price, 0),
}),
shallow // Compare object values, not reference
);
return (
<div>
{itemCount} items - ${total.toFixed(2)}
</div>
);
}
TanStack Query for Server State
import {
useQuery,
useMutation,
useQueryClient,
QueryClient,
QueryClientProvider
} from '@tanstack/react-query';
// Setup
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes (garbage collection)
retry: 3,
refetchOnWindowFocus: false,
},
},
});
function App() {
return (
<QueryClientProvider client={queryClient}>
<MyApp />
</QueryClientProvider>
);
}
// ✅ Fetch with caching
function ProductList() {
const { data, isLoading, error } = useQuery({
queryKey: ['products'],
queryFn: fetchProducts,
});
if (isLoading) return <Skeleton />;
if (error) return <Error error={error} />;
return (
<ul>
{data?.map(product => (
<ProductItem key={product.id} product={product} />
))}
</ul>
);
}
// ✅ Mutations with optimistic updates
function AddToCartButton({ product }: Props) {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: addToCart,
// Optimistic update
onMutate: async (newItem) => {
await queryClient.cancelQueries({ queryKey: ['cart'] });
const previous = queryClient.getQueryData(['cart']);
queryClient.setQueryData(['cart'], (old: CartItem[]) => [...old, newItem]);
return { previous };
},
onError: (err, newItem, context) => {
queryClient.setQueryData(['cart'], context?.previous);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['cart'] });
},
});
return (
<button
onClick={() => mutation.mutate(product)}
disabled={mutation.isPending}
>
{mutation.isPending ? 'Adding...' : 'Add to Cart'}
</button>
);
}
State Colocation
// ✅ Keep state as close to usage as possible
// Form state - local to form
function ContactForm() {
const [formData, setFormData] = useState({
name: '',
email: '',
message: ''
});
return <form>...</form>;
}
// UI state - local to component
function Dropdown() {
const [isOpen, setIsOpen] = useState(false);
return <div>...</div>;
}
// Shared state - lifted to common ancestor or global store
function App() {
return (
<CartProvider>
<Header /> {/* Uses cart */}
<Products /> {/* Uses cart */}
<Checkout /> {/* Uses cart */}
</CartProvider>
);
}
6. Context Optimization
The Problem with Context
// ❌ BAD: Single context with everything
interface AppState {
user: User | null;
theme: Theme;
cart: CartItem[];
notifications: Notification[];
}
const AppContext = createContext<AppState>(initialState);
// When ANYTHING changes, ALL consumers re-render!
function CartIcon() {
const { cart } = useContext(AppContext); // Re-renders on theme change too!
return <Badge count={cart.length} />;
}
Split Contexts by Update Frequency
// ✅ GOOD: Separate contexts by how often they change
// Rarely changes
const UserContext = createContext<User | null>(null);
const ThemeContext = createContext<Theme>(defaultTheme);
// Changes often
const CartContext = createContext<CartState>(initialCart);
const NotificationContext = createContext<Notification[]>([]);
// Compose providers
function AppProviders({ children }: { children: ReactNode }) {
return (
<UserProvider>
<ThemeProvider>
<CartProvider>
<NotificationProvider>
{children}
</NotificationProvider>
</CartProvider>
</ThemeProvider>
</UserProvider>
);
}
Memoize Context Value
// ❌ BAD: New object every render
function CartProvider({ children }: { children: ReactNode }) {
const [items, setItems] = useState<CartItem[]>([]);
return (
<CartContext.Provider value={{ items, setItems }}> {/* New object! */}
{children}
</CartContext.Provider>
);
}
// ✅ GOOD: Memoized value
function CartProvider({ children }: { children: ReactNode }) {
const [items, setItems] = useState<CartItem[]>([]);
const value = useMemo(
() => ({ items, setItems }),
[items]
);
return (
<CartContext.Provider value={value}>
{children}
</CartContext.Provider>
);
}
Split State and Dispatch
// ✅ Separate read and write contexts
interface CartState {
items: CartItem[];
total: number;
}
type CartDispatch = (action: CartAction) => void;
const CartStateContext = createContext<CartState | null>(null);
const CartDispatchContext = createContext<CartDispatch | null>(null);
function CartProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(cartReducer, initialState);
return (
<CartStateContext.Provider value={state}>
<CartDispatchContext.Provider value={dispatch}>
{children}
</CartDispatchContext.Provider>
</CartStateContext.Provider>
);
}
// Components that only dispatch don't re-render on state changes
function AddToCartButton({ product }: Props) {
const dispatch = useContext(CartDispatchContext); // Stable reference
return (
<button onClick={() => dispatch({ type: 'ADD', product })}>
Add
</button>
);
}
// Components that read state re-render as expected
function CartTotal() {
const state = useContext(CartStateContext);
return <span>${state?.total.toFixed(2)}</span>;
}
Context Selectors (use-context-selector)
import { createContext, useContextSelector } from 'use-context-selector';
const StoreContext = createContext<Store>(initialStore);
// ✅ Only re-renders when selected value changes
function UserName() {
const name = useContextSelector(
StoreContext,
(state) => state.user?.name
);
return <span>{name}</span>;
}
function CartCount() {
const count = useContextSelector(
StoreContext,
(state) => state.cart.length
);
return <Badge count={count} />;
}
7. Virtualization
TanStack Virtual (Recommended)
import { useVirtualizer } from '@tanstack/react-virtual';
interface Item {
id: string;
name: string;
description: string;
}
function VirtualList({ items }: { items: Item[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50, // Estimated row height
overscan: 5, // Extra items to render above/below
});
return (
<div
ref={parentRef}
style={{
height: '400px',
overflow: 'auto',
}}
>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((virtualRow) => (
<div
key={virtualRow.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
<ItemRow item={items[virtualRow.index]} />
</div>
))}
</div>
</div>
);
}
Variable Height Items
function VariableHeightList({ items }: { items: Item[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 100, // Initial estimate
// Measure actual size
measureElement: (element) => {
return element.getBoundingClientRect().height;
},
});
return (
<div ref={parentRef} style={{ height: '400px', overflow: 'auto' }}>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((virtualRow) => (
<div
key={virtualRow.key}
ref={virtualizer.measureElement}
data-index={virtualRow.index}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualRow.start}px)`,
}}
>
<DynamicHeightItem item={items[virtualRow.index]} />
</div>
))}
</div>
</div>
);
}
Virtual Grid
function VirtualGrid({ items, columns = 4 }: Props) {
const parentRef = useRef<HTMLDivElement>(null);
const rowCount = Math.ceil(items.length / columns);
const rowVirtualizer = useVirtualizer({
count: rowCount,
getScrollElement: () => parentRef.current,
estimateSize: () => 200,
overscan: 2,
});
return (
<div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
position: 'relative',
}}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => (
<div
key={virtualRow.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
display: 'grid',
gridTemplateColumns: `repeat(${columns}, 1fr)`,
gap: '16px',
}}
>
{Array.from({ length: columns }).map((_, colIndex) => {
const itemIndex = virtualRow.index * columns + colIndex;
if (itemIndex >= items.length) return null;
return (
<GridItem
key={items[itemIndex].id}
item={items[itemIndex]}
/>
);
})}
</div>
))}
</div>
</div>
);
}
function InfiniteList() {
const parentRef = useRef<HTMLDivElement>(null);
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
queryKey: ['items'],
queryFn: ({ pageParam = 0 }) => fetchItems(pageParam),
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
const allItems = data?.pages.flatMap(page => page.items) ?? [];
const virtualizer = useVirtualizer({
count: hasNextPage ? allItems.length + 1 : allItems.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50,
overscan: 5,
});
// Fetch more when approaching end
useEffect(() => {
const [lastItem] = [...virtualizer.getVirtualItems()].reverse();
if (!lastItem) return;
if (
lastItem.index >= allItems.length - 1 &&
hasNextPage &&
!isFetchingNextPage
) {
fetchNextPage();
}
}, [
hasNextPage,
fetchNextPage,
allItems.length,
isFetchingNextPage,
virtualizer.getVirtualItems(),
]);
return (
<div ref={parentRef} style={{ height: '400px', overflow: 'auto' }}>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((virtualRow) => {
const isLoaderRow = virtualRow.index > allItems.length - 1;
return (
<div
key={virtualRow.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
{isLoaderRow ? (
hasNextPage ? <Spinner /> : 'Nothing more to load'
) : (
<ItemRow item={allItems[virtualRow.index]} />
)}
</div>
);
})}
</div>
</div>
);
}
8. Concurrent Features
useTransition for Non-Blocking Updates
import { useState, useTransition } from 'react';
function SearchPage() {
const [query, setQuery] = useState('');
const [results, setResults] = useState<Item[]>([]);
const [isPending, startTransition] = useTransition();
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
// Urgent: Update input immediately
setQuery(value);
// Non-urgent: Can be interrupted
startTransition(() => {
const filtered = filterItems(value); // Expensive
setResults(filtered);
});
};
return (
<div>
<input value={query} onChange={handleChange} />
{isPending && <Spinner />}
<ul style={{ opacity: isPending ? 0.7 : 1 }}>
{results.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
);
}
useDeferredValue for Expensive Children
import { useDeferredValue, Suspense, memo } from 'react';
function SearchPage() {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
const isStale = query !== deferredQuery;
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<Suspense fallback={<Skeleton />}>
<div style={{ opacity: isStale ? 0.7 : 1 }}>
<SearchResults query={deferredQuery} />
</div>
</Suspense>
</div>
);
}
// Memoized to avoid re-render with same query
const SearchResults = memo(function SearchResults({ query }: { query: string }) {
const results = useSearchResults(query); // Could be expensive
return (
<ul>
{results.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
});
Suspense for Data Fetching
import { Suspense } from 'react';
import { useSuspenseQuery } from '@tanstack/react-query';
function ProductPage({ id }: { id: string }) {
return (
<div>
<Suspense fallback={<HeaderSkeleton />}>
<ProductHeader id={id} />
</Suspense>
<Suspense fallback={<DetailsSkeleton />}>
<ProductDetails id={id} />
</Suspense>
<Suspense fallback={<ReviewsSkeleton />}>
<ProductReviews id={id} />
</Suspense>
</div>
);
}
function ProductDetails({ id }: { id: string }) {
const { data } = useSuspenseQuery({
queryKey: ['product', id],
queryFn: () => fetchProduct(id),
});
return (
<div>
<h1>{data.name}</h1>
<p>{data.description}</p>
<span>${data.price}</span>
</div>
);
}
Parallel Data Loading
// ✅ Load data in parallel using Suspense
function Dashboard() {
return (
<div className="dashboard">
{/* These load in parallel, not sequentially */}
<Suspense fallback={<StatsSkeleton />}>
<Stats />
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<SalesChart />
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<RecentOrders />
</Suspense>
</div>
);
}
9. Event Handling
Passive Event Listeners
// ✅ Passive listeners don't block scrolling
useEffect(() => {
const handleScroll = () => {
// Handle scroll
};
window.addEventListener('scroll', handleScroll, { passive: true });
return () => window.removeEventListener('scroll', handleScroll);
}, []);
Debouncing
import { useMemo, useCallback, useEffect } from 'react';
import { debounce } from 'lodash-es';
function SearchInput({ onSearch }: Props) {
const [query, setQuery] = useState('');
// Memoize debounced function
const debouncedSearch = useMemo(
() => debounce((q: string) => onSearch(q), 300),
[onSearch]
);
// Cleanup on unmount
useEffect(() => {
return () => {
debouncedSearch.cancel();
};
}, [debouncedSearch]);
const handleChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setQuery(value);
debouncedSearch(value);
}, [debouncedSearch]);
return <input value={query} onChange={handleChange} />;
}
Throttling
import { useMemo, useEffect } from 'react';
import { throttle } from 'lodash-es';
function ScrollTracker() {
const throttledHandler = useMemo(
() => throttle(() => {
const scrollY = window.scrollY;
// Track scroll position
}, 100), // Max once per 100ms
[]
);
useEffect(() => {
window.addEventListener('scroll', throttledHandler, { passive: true });
return () => {
throttledHandler.cancel();
window.removeEventListener('scroll', throttledHandler);
};
}, [throttledHandler]);
return null;
}
requestAnimationFrame for Visual Updates
function SmoothScroll() {
const [scrollY, setScrollY] = useState(0);
const rafRef = useRef<number>();
const scrollRef = useRef(0);
useEffect(() => {
const handleScroll = () => {
scrollRef.current = window.scrollY;
// Batch visual updates with RAF
if (rafRef.current === undefined) {
rafRef.current = requestAnimationFrame(() => {
setScrollY(scrollRef.current);
rafRef.current = undefined;
});
}
};
window.addEventListener('scroll', handleScroll, { passive: true });
return () => {
window.removeEventListener('scroll', handleScroll);
if (rafRef.current) {
cancelAnimationFrame(rafRef.current);
}
};
}, []);
return <div style={{ transform: `translateY(${scrollY * 0.5}px)` }} />;
}
Event Delegation
// ✅ Single handler for many elements
function List({ items }: Props) {
const handleClick = useCallback((e: MouseEvent<HTMLUListElement>) => {
const target = e.target as HTMLElement;
const itemId = target.closest('[data-item-id]')?.getAttribute('data-item-id');
if (itemId) {
handleItemClick(itemId);
}
}, []);
return (
<ul onClick={handleClick}>
{items.map(item => (
<li key={item.id} data-item-id={item.id}>
{item.name}
</li>
))}
</ul>
);
}
Zero-Runtime CSS Solutions
| Solution | Runtime Cost | Type Safety | Best For |
|---|
| Tailwind CSS | Zero | No (but IDE support) | Most projects |
| CSS Modules | Zero | No | Component isolation |
| vanilla-extract | Zero | Yes | Type-safe styles |
| Linaria | Zero | Partial | CSS-in-JS syntax |
| styled-components | High | Partial | Avoid if perf-critical |
| Emotion | High | Partial | Avoid if perf-critical |
Tailwind CSS (Recommended)
// ✅ Zero runtime, styles purged at build
function Button({ primary, children }: Props) {
return (
<button
className={cn(
'px-4 py-2 rounded font-medium transition-colors duration-200',
primary
? 'bg-blue-500 hover:bg-blue-600 text-white'
: 'bg-gray-200 hover:bg-gray-300 text-gray-800'
)}
>
{children}
</button>
);
}
// Utility for conditional classes
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
CSS Modules
// Button.module.css
.button {
padding: 0.5rem 1rem;
border-radius: 0.25rem;
font-weight: 500;
}
.primary {
background-color: #3b82f6;
color: white;
}
// Button.tsx
import styles from './Button.module.css';
function Button({ primary, children }: Props) {
return (
<button className={cn(styles.button, primary && styles.primary)}>
{children}
</button>
);
}
Avoid Inline Style Objects
// ❌ BAD: New object every render
<div style={{ transform: `translateX(${x}px)` }} />
// ✅ GOOD: CSS custom properties
<div
style={{ '--x': `${x}px` } as CSSProperties}
className="transform translate-x-[var(--x)]"
/>
// ✅ BEST: CSS transitions
<div className={cn(
'transition-transform duration-300',
isOpen ? 'translate-x-0' : '-translate-x-full'
)} />
GPU-Accelerated Properties
/* ✅ GPU composited (fast) */
.animate {
transform: translateX(100px);
opacity: 0.5;
filter: blur(2px);
}
/* ❌ Triggers layout (slow) */
.animate {
left: 100px;
width: 200px;
height: 200px;
}
/* ✅ Use will-change sparingly */
.will-animate {
will-change: transform;
}
240fps-Specific Optimizations
For 240Hz displays (gaming monitors, high-refresh devices), every millisecond counts. These advanced techniques are essential for sub-4ms frame budgets.
CSS Containment
/* Isolate components from parent reflows */
.isolated-component {
contain: layout paint;
}
/* Skip rendering off-viewport content entirely */
.lazy-section {
content-visibility: auto;
contain-intrinsic-size: 0 500px; /* Placeholder size */
}
/* Full isolation for maximum performance */
.perf-critical {
contain: strict;
}
will-change Lifecycle Management
// ✅ GOOD: Add before animation, remove after
function AnimatedCard({ isAnimating }: Props) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const element = ref.current;
if (!element) return;
if (isAnimating) {
element.style.willChange = 'transform, opacity';
}
return () => {
element.style.willChange = 'auto';
};
}, [isAnimating]);
return <div ref={ref}>...</div>;
}
// ❌ BAD: Permanent will-change (wastes GPU memory)
.always-ready {
will-change: transform; /* Don't do this */
}
OffscreenCanvas for Heavy Drawing
// Move canvas rendering to Web Worker
function HeavyVisualization({ data }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const workerRef = useRef<Worker>();
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const offscreen = canvas.transferControlToOffscreen();
workerRef.current = new Worker(
new URL('./canvas-worker.ts', import.meta.url),
{ type: 'module' }
);
workerRef.current.postMessage({ canvas: offscreen, data }, [offscreen]);
return () => workerRef.current?.terminate();
}, [data]);
return <canvas ref={canvasRef} width={800} height={600} />;
}
// canvas-worker.ts
self.onmessage = (e: MessageEvent) => {
const { canvas, data } = e.data;
const ctx = canvas.getContext('2d');
// All rendering happens off main thread
renderVisualization(ctx, data);
};
Forced Synchronous Layout Prevention
// ❌ BAD: Layout thrashing (read-write-read pattern)
function BadResize() {
elements.forEach(el => {
const width = el.offsetWidth; // Read (forces layout)
el.style.width = `${width * 2}px`; // Write (invalidates layout)
// Next read forces ANOTHER layout!
});
}
// ✅ GOOD: Batch reads, then batch writes
function GoodResize() {
// Phase 1: Read all
const widths = elements.map(el => el.offsetWidth);
// Phase 2: Write all
elements.forEach((el, i) => {
el.style.width = `${widths[i] * 2}px`;
});
}
// ✅ BEST: Use requestAnimationFrame for DOM writes
function BestResize() {
const widths = elements.map(el => el.offsetWidth);
requestAnimationFrame(() => {
elements.forEach((el, i) => {
el.style.width = `${widths[i] * 2}px`;
});
});
}
Passive Event Listeners (Critical for 240fps)
// ✅ Passive listeners don't block compositor
useEffect(() => {
const handleScroll = () => {
// Handle scroll - cannot call preventDefault()
};
const handleWheel = () => {
// Handle wheel - cannot call preventDefault()
};
window.addEventListener('scroll', handleScroll, { passive: true });
window.addEventListener('wheel', handleWheel, { passive: true });
window.addEventListener('touchmove', handleTouch, { passive: true });
return () => {
window.removeEventListener('scroll', handleScroll);
window.removeEventListener('wheel', handleWheel);
window.removeEventListener('touchmove', handleTouch);
};
}, []);
11. Image Optimization
Next.js Image (Recommended)
import Image from 'next/image';
function ProductImage({ src, alt }: Props) {
return (
<Image
src={src}
alt={alt}
width={400}
height={300}
priority // Preload for above-the-fold
placeholder="blur"
blurDataURL={generateBlurHash(src)}
/>
);
}
// Responsive images
function ResponsiveHero() {
return (
<Image
src="/hero.jpg"
alt="Hero"
fill
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
style={{ objectFit: 'cover' }}
/>
);
}
Native Lazy Loading
// ✅ Browser-native lazy loading
<img
src={url}
alt={alt}
loading="lazy" // Lazy load
decoding="async" // Non-blocking decode
width={200} // Prevents layout shift
height={200}
/>
// ✅ With srcSet for responsive
<img
src={url}
srcSet={`
${url}?w=400 400w,
${url}?w=800 800w,
${url}?w=1200 1200w
`}
sizes="(max-width: 600px) 400px, (max-width: 1200px) 800px, 1200px"
alt={alt}
loading="lazy"
/>
Image Preloading
// Preload critical images
function preloadImage(src: string) {
const link = document.createElement('link');
link.rel = 'preload';
link.as = 'image';
link.href = src;
document.head.appendChild(link);
}
// In component
useEffect(() => {
preloadImage('/hero.jpg');
}, []);
// Or in HTML head
<link rel="preload" as="image" href="/hero.jpg" />
12. Code Splitting
Route-Based Splitting
import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';
// Lazy load route components
const Home = lazy(() => import('./pages/Home'));
const Products = lazy(() => import('./pages/Products'));
const ProductDetail = lazy(() => import('./pages/ProductDetail'));
const Admin = lazy(() => import('./pages/Admin'));
function App() {
return (
<Suspense fallback={<PageSkeleton />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/products" element={<Products />} />
<Route path="/products/:id" element={<ProductDetail />} />
<Route path="/admin/*" element={<Admin />} />
</Routes>
</Suspense>
);
}
Component-Level Splitting
// Lazy load heavy components
const Chart = lazy(() => import('./components/Chart'));
const PDFViewer = lazy(() => import('./components/PDFViewer'));
const RichTextEditor = lazy(() => import('./components/RichTextEditor'));
function Dashboard() {
const [showChart, setShowChart] = useState(false);
return (
<div>
<button onClick={() => setShowChart(true)}>
Show Analytics
</button>
{showChart && (
<Suspense fallback={<ChartSkeleton />}>
<Chart />
</Suspense>
)}
</div>
);
}
Named Exports with Lazy
// For named exports
const Modal = lazy(() =>
import('./components').then(module => ({
default: module.Modal
}))
);
Preloading on Hover
function NavLink({ to, children }: Props) {
const preloadComponent = () => {
// Preload when user hovers
import(`./pages/${to}`);
};
return (
<Link
to={to}
onMouseEnter={preloadComponent}
onFocus={preloadComponent}
>
{children}
</Link>
);
}
13. TypeScript Patterns
// ✅ Use const assertions for literal types
const ACTIONS = {
ADD: 'ADD',
REMOVE: 'REMOVE',
UPDATE: 'UPDATE',
} as const;
type Action = typeof ACTIONS[keyof typeof ACTIONS];
// ✅ Prefer interfaces for objects (faster compilation)
interface User {
id: string;
name: string;
email: string;
}
// ✅ Use type for unions/intersections
type Status = 'idle' | 'loading' | 'success' | 'error';
// ✅ Generic constraints for better inference
function useQuery<T extends object>(
key: string,
fetcher: () => Promise<T>
): QueryResult<T> {
// Implementation
}
Discriminated Unions for State
// ✅ Type-safe state handling
type AsyncState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error };
function UserProfile() {
const [state, setState] = useState<AsyncState<User>>({ status: 'idle' });
// TypeScript narrows the type based on status
if (state.status === 'loading') {
return <Spinner />;
}
if (state.status === 'error') {
return <Error error={state.error} />; // error is available
}
if (state.status === 'success') {
return <Profile user={state.data} />; // data is available
}
return <button onClick={loadUser}>Load Profile</button>;
}
Strict Event Typing
// ✅ Type events precisely
function Form() {
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
// Process form
};
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
// Handle change
};
return (
<form onSubmit={handleSubmit}>
<input onChange={handleChange} />
</form>
);
}
Component Props Patterns
// ✅ Extend native element props
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary';
loading?: boolean;
}
function Button({ variant = 'primary', loading, children, ...props }: ButtonProps) {
return (
<button {...props} disabled={loading || props.disabled}>
{loading ? <Spinner /> : children}
</button>
);
}
// ✅ Polymorphic components
interface BoxProps<T extends ElementType> {
as?: T;
children: ReactNode;
}
type PolymorphicProps<T extends ElementType> = BoxProps<T> &
Omit<ComponentPropsWithoutRef<T>, keyof BoxProps<T>>;
function Box<T extends ElementType = 'div'>({
as,
children,
...props
}: PolymorphicProps<T>) {
const Component = as || 'div';
return <Component {...props}>{children}</Component>;
}
// Usage
<Box as="section" className="container">Content</Box>
<Box as="article">Article content</Box>
14. Web Workers
Basic Worker Setup
// worker.ts
self.onmessage = (e: MessageEvent<{ data: number[] }>) => {
const result = heavyComputation(e.data.data);
self.postMessage(result);
};
function heavyComputation(data: number[]): number {
return data.reduce((a, b) => a + b, 0);
}
// Component
function DataProcessor() {
const [result, setResult] = useState<number | null>(null);
const workerRef = useRef<Worker | null>(null);
useEffect(() => {
workerRef.current = new Worker(
new URL('./worker.ts', import.meta.url),
{ type: 'module' }
);
workerRef.current.onmessage = (e: MessageEvent<number>) => {
setResult(e.data);
};
return () => workerRef.current?.terminate();
}, []);
const processData = (data: number[]) => {
workerRef.current?.postMessage({ data });
};
return (
<button onClick={() => processData([1, 2, 3, 4, 5])}>
Process
</button>
);
}
Comlink for Easier API
// worker.ts
import { expose } from 'comlink';
const api = {
async processData(data: number[]): Promise<number> {
// Heavy computation
return data.reduce((a, b) => a + b, 0);
},
async sortItems(items: Item[]): Promise<Item[]> {
return items.sort((a, b) => a.name.localeCompare(b.name));
}
};
expose(api);
export type WorkerApi = typeof api;
// Component
import { wrap, type Remote } from 'comlink';
import type { WorkerApi } from './worker';
function useWorker() {
const workerRef = useRef<Remote<WorkerApi>>();
useEffect(() => {
const worker = new Worker(
new URL('./worker.ts', import.meta.url),
{ type: 'module' }
);
workerRef.current = wrap<WorkerApi>(worker);
return () => worker.terminate();
}, []);
return workerRef.current;
}
function Component() {
const worker = useWorker();
const [result, setResult] = useState<number | null>(null);
const handleClick = async () => {
if (worker) {
const sum = await worker.processData([1, 2, 3, 4, 5]);
setResult(sum);
}
};
return <button onClick={handleClick}>Process</button>;
}
Worker Pool
// workerPool.ts
import { wrap, type Remote } from 'comlink';
import type { WorkerApi } from './worker';
class WorkerPool {
private workers: Remote<WorkerApi>[] = [];
private index = 0;
constructor(size = navigator.hardwareConcurrency || 4) {
for (let i = 0; i < size; i++) {
const worker = new Worker(
new URL('./worker.ts', import.meta.url),
{ type: 'module' }
);
this.workers.push(wrap<WorkerApi>(worker));
}
}
getWorker(): Remote<WorkerApi> {
const worker = this.workers[this.index];
this.index = (this.index + 1) % this.workers.length;
return worker;
}
async processInParallel<T, R>(
items: T[],
fn: (worker: Remote<WorkerApi>, item: T) => Promise<R>
): Promise<R[]> {
return Promise.all(
items.map((item, i) => fn(this.workers[i % this.workers.length], item))
);
}
terminate() {
this.workers = [];
}
}
export const workerPool = new WorkerPool();
15. Profiling & Debugging
// Enable Profiler in production (optional)
// Build with: REACT_APP_ENABLE_PROFILER=true
import { Profiler, ProfilerOnRenderCallback } from 'react';
const onRender: ProfilerOnRenderCallback = (
id,
phase,
actualDuration,
baseDuration,
startTime,
commitTime
) => {
console.log({
id,
phase,
actualDuration: `${actualDuration.toFixed(2)}ms`,
baseDuration: `${baseDuration.toFixed(2)}ms`,
});
};
function App() {
return (
<Profiler id="App" onRender={onRender}>
<MainContent />
</Profiler>
);
}
why-did-you-render
// wdyr.ts - import at top of index.tsx
import React from 'react';
if (process.env.NODE_ENV === 'development') {
const whyDidYouRender = require('@welldone-software/why-did-you-render');
whyDidYouRender(React, {
trackAllPureComponents: true,
trackHooks: true,
logOnDifferentValues: true,
});
}
// Mark specific components
MyComponent.whyDidYouRender = true;
// Or in the component
function MyComponent(props: Props) {
// Component code
}
MyComponent.whyDidYouRender = {
logOnDifferentValues: true,
customName: 'MyComponent'
};
// Custom performance marks
function ExpensiveComponent() {
useEffect(() => {
performance.mark('expensive-start');
return () => {
performance.mark('expensive-end');
performance.measure('expensive-render', 'expensive-start', 'expensive-end');
const measure = performance.getEntriesByName('expensive-render')[0];
console.log(`Render took: ${measure.duration.toFixed(2)}ms`);
};
}, []);
return <div>...</div>;
}
Core Web Vitals
import { onLCP, onFID, onCLS, onINP, onTTFB } from 'web-vitals';
// Report to analytics
onLCP(console.log); // Largest Contentful Paint
onFID(console.log); // First Input Delay (deprecated)
onINP(console.log); // Interaction to Next Paint (replacement)
onCLS(console.log); // Cumulative Layout Shift
onTTFB(console.log); // Time to First Byte
// Send to analytics
function sendToAnalytics(metric: Metric) {
const body = JSON.stringify({
name: metric.name,
value: metric.value,
id: metric.id,
});
navigator.sendBeacon('/analytics', body);
}
onINP(sendToAnalytics);
Bundle Analysis
# Next.js
ANALYZE=true next build
# Vite
npx vite-bundle-visualizer
# webpack
npx webpack-bundle-analyzer stats.json
16. Quick Reference
240fps Additional Checks
Cheat Sheet
// Stable callback
const handleClick = useCallback((id: string) => {
doSomething(id);
}, []);
// Memoized expensive computation
const processed = useMemo(() => {
return expensiveOperation(data);
}, [data]);
// Memoized component
const MemoizedChild = memo(Child);
// Non-blocking state update
const [isPending, startTransition] = useTransition();
startTransition(() => {
setExpensiveState(newValue);
});
// Deferred value
const deferredQuery = useDeferredValue(query);
// Virtual list
const virtualizer = useVirtualizer({
count: items.length,
estimateSize: () => 50,
});
Common Pitfalls
| Pitfall | Impact | Fix |
|---|
| State in parent | Cascade re-renders | Move state down |
| Object/array in props | Child always re-renders | useMemo or lift out |
| Index as key | Wrong state after reorder | Use stable ID |
| Single large Context | All consumers re-render | Split contexts |
| Runtime CSS-in-JS | Style recalculation | Use Tailwind/CSS Modules |
| No virtualization | DOM thrashing | Use TanStack Virtual |
| Sync heavy work | Frozen UI | Web Worker or useTransition |
| Full-size images | Slow LCP | Use next/image or srcSet |
Frame Budget
60 FPS (16.67ms) 240 FPS (4.17ms)
├── JavaScript: 5-6ms ├── JavaScript: 1-1.5ms
├── Style calc: 1-2ms ├── Style calc: 0.5ms
├── Layout: 2-3ms ├── Layout: 0.5-1ms
├── Paint: 2-3ms ├── Paint: 0.5-1ms
└── Composite: 2-3ms └── Composite: 0.5-1ms
Core Web Vitals Targets (2025)
| Metric | Good | Needs Work | Poor |
|---|
| LCP | < 2.5s | 2.5-4s | > 4s |
| INP | < 200ms | 200-500ms | > 500ms |
| CLS | < 0.1 | 0.1-0.25 | > 0.25 |