frontend-performance
Frontend Performance
This skill provides patterns for optimizing React application performance.
Canonical Examples
Study these real implementations:
- Code Splitting: router.tsx
- Lazy Loading: Route-level lazy loading with TanStack Router
Core Optimization Strategies
1. Code Splitting & Lazy Loading
Route-level code splitting (automatic with TanStack Router):
// Routes are automatically code-split
export const Route = createFileRoute('/studios/$studioId/tasks')({
component: TasksPage, // Automatically lazy-loaded
});
Component-level lazy loading:
import { lazy, Suspense } from 'react';
const HeavyComponent = lazy(() => import('./HeavyComponent'));
function Page() {
return (
<Suspense fallback={<LoadingSpinner />}>
<HeavyComponent />
</Suspense>
);
}
2. Memoization
Rule: Default to NOT memoizing — see engineering-best-practices-enforcer for the full decision rules on useCallback and useMemo. Use the patterns below only when a genuine dependency or performance need is identified.
useMemo for expensive computations (sort, filter, map on large arrays):
const sortedItems = useMemo(
() => items.sort((a, b) => a.name.localeCompare(b.name)),
[items]
);
React.memo for component memoization (only when parent re-renders frequently with stable props):
export const ItemCard = React.memo(({ item }: ItemCardProps) => {
return <div>{item.name}</div>;
});
3. Derive State During Render, Not Effects
Rule: If a value can be computed from existing props or state, compute it inline during render. Never store derived values in separate state or sync them via useEffect.
// ❌ Unnecessary state + effect — causes double render on every name change
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);
// ✅ Derived inline — always in sync, zero extra renders
const fullName = `${firstName} ${lastName}`;
When to use useEffect for state: only when the value requires an async operation, a DOM read, or an external subscription — not for prop-to-state derivations.
4. Functional setState for Stable Callbacks
Rule: When the new state depends on the previous value, use the function form of setState. This keeps callbacks stable and prevents stale closure bugs.
// ❌ Callback must depend on items — recreated on every change, stale closure risk
const addItem = useCallback((item: Item) => {
setItems([...items, item]);
}, [items]);
// ✅ Callback is stable — always receives current state
const addItem = useCallback((item: Item) => {
setItems(curr => [...curr, item]);
}, []); // No dependency on items
Apply this in useCallback, event handlers, and any callback passed to a child component.
5. Lazy State Initialization
Rule: For expensive initial state values (reading localStorage, building data structures, parsing), pass a function to useState so the cost runs only once.
// ❌ localStorage.getItem() runs on every render
const [settings, setSettings] = useState(
JSON.parse(localStorage.getItem('settings') ?? '{}')
);
// ✅ Initializer runs only on mount
const [settings, setSettings] = useState(() => {
const stored = localStorage.getItem('settings');
return stored ? JSON.parse(stored) : {};
});
Use for: localStorage/sessionStorage reads, building index Maps from arrays, DOM reads, or heavy transformations. For simple primitives (useState(0)), skip it.
6. Defer State Reads to Usage Point
Rule: If state is only needed inside a callback or event handler — not in the render output — read it there directly instead of subscribing via a hook. Subscriptions cause re-renders even when nothing visible changes.
// ❌ Subscribes to searchParams — component re-renders on every URL change
const searchParams = useSearchParams();
const handleSubmit = () => {
track({ params: searchParams.toString() });
};
// ✅ Read on demand inside the handler — no subscription, no re-renders
const handleSubmit = () => {
track({ params: new URLSearchParams(window.location.search).toString() });
};
Also applies to TanStack Router's useSearch() — if the search value is only needed in a submit handler, access router.state.location.search inside the handler instead.
7a. Virtual Scrolling
For long lists (>100 items), use @tanstack/react-virtual:
import { useVirtualizer } from '@tanstack/react-virtual';
function VirtualList({ items }: { items: Item[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 100,
});
return (
<div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
<div style={{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }}>
{virtualizer.getVirtualItems().map((virtualItem) => (
<div
key={virtualItem.index}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
>
<ItemCard item={items[virtualItem.index]} />
</div>
))}
</div>
</div>
);
}
7c. High-Noise Selection Panels (Template/Field Catalogs)
When a picker can include thousands of options (for example, report columns across many client-dedicated templates), apply progressive disclosure before considering full virtualization:
- Telemetry first: show counts for templates, submitted records, and option totals so users understand scale.
- Collapse by default: if template groups exceed a threshold (for example
>10), collapse groups and expand only top-N by relevance (for example highest submitted volume). - Narrowing controls: include
- text search across group + option metadata (
name,key,label,type) Selected onlymodeGroups with selectionmode
- text search across group + option metadata (
- Bulk visibility controls: explicit
Expand all/Collapse allactions for power users. - Keep canonical options visible: shared/cross-template options should remain in a dedicated section and not be hidden by template collapse.
- Selection guardrails: enforce hard caps and show soft warnings when UX/readability degrades at higher counts.
This pattern reduces initial DOM/render load and user cognitive load while preserving access to the full catalog.
7b. Image Optimization
// Use native lazy loading
<img src={url} loading="lazy" alt={alt} />
// Use responsive images
<img
srcSet={`${url}-small.jpg 400w, ${url}-medium.jpg 800w, ${url}-large.jpg 1200w`}
sizes="(max-width: 640px) 400px, (max-width: 1024px) 800px, 1200px"
src={url}
alt={alt}
/>
7. Bundle Size Optimization
Analyze bundle:
pnpm --filter erify_studios build
# Then inspect dist/ with vite-bundle-visualizer or rollup-plugin-visualizer output
Barrel file imports: Libraries like Radix UI, Lucide React, and date-fns re-export thousands of modules from their root entry. Importing from the root barrel forces the bundler to load everything.
// ❌ Root barrel import — loads all Radix UI primitives (~10k re-exports)
import { Dialog, Popover, Tooltip } from '@radix-ui/react-primitives';
// ✅ Direct package imports — each Radix primitive is a separate package
import * as Dialog from '@radix-ui/react-dialog';
import * as Popover from '@radix-ui/react-popover';
// ✅ Named icon import instead of full Lucide barrel
import { Check } from 'lucide-react'; // Tree-shaken by Vite with ESM
@eridu/ui components are already pre-composed and safe to import by name:
import { Button, Input } from '@eridu/ui'; // ✅ — built package, not a raw Radix barrel
@eridu/api-types subpath exports — always use the documented subpath, not the root:
import { STUDIO_ROLE } from '@eridu/api-types/memberships'; // ✅
import { STUDIO_ROLE } from '@eridu/api-types'; // ❌ root barrel
Performance Checklist
- Routes are code-split (automatic with TanStack Router)
- Heavy components use
lazy()+Suspense - Derived values computed inline during render — no
useEffectto sync derived state -
setStatedepending on previous value uses functional form:setState(curr => ...) - Expensive initial state uses lazy init:
useState(() => expensiveOp()) - State only needed inside callbacks is read on demand, not subscribed via hook
- Expensive computations use
useMemo(genuinely expensive — large sort/filter/map) -
useCallbackonly when identity stability is provably required — seeengineering-best-practices-enforcer -
React.memoonly on components with demonstrably stable props and frequent parent re-renders - Long lists (>100 items) use virtual scrolling
- Images use
loading="lazy" - Bundle analyzed — no root barrel imports from Radix UI or Lucide
-
@eridu/api-typesimported via subpath exports, never root
Related Skills
- frontend-tech-stack - Tech stack configuration
- studio-list-pattern - Infinite scroll patterns
More from allenlin90/eridu-services
service-pattern-nestjs
Comprehensive NestJS service implementation patterns. This skill should be used when implementing Model Services, Orchestration Services, or business logic with NestJS decorators.
8erify-authorization
Patterns for implementing authorization in erify_api with current StudioMembership + AdminGuard behavior, plus planned RBAC references
6data-validation
Provides comprehensive guidance for input validation, data serialization, and ID management in backend APIs. This skill should be used when designing validation schemas, transforming request/response data, mapping database IDs to external identifiers, and ensuring type safety across API boundaries.
6code-quality
Provides general code quality and best practices guidance applicable across languages and frameworks. Focuses on linting, testing, and type safety.
6repository-pattern-nestjs
Comprehensive Prisma repository implementation patterns for NestJS. This skill should be used when implementing repositories that extend BaseRepository or use Prisma delegates.
6task-template-builder
Provides guidelines for the Task Template Builder architecture, including Schema alignment, Draft storage, Drag-and-Drop, and Validation logic.
6