Created
March 2, 2026 17:01
-
-
Save Rafael-Ramblas/ab282f892c3c893846e5bb4d138e05b4 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # This is a cursor rule generated from https://github.com/vercel-labs/agent-skills/tree/main/skills/react-best-practices | |
| --- | |
| description: Vercel React & Next.js performance best practices - 57 rules across 8 categories | |
| globs: "**/*.{ts,tsx}" | |
| alwaysApply: false | |
| --- | |
| # React Best Practices (Vercel Engineering) | |
| Comprehensive performance optimization guide for React and Next.js applications. Apply these rules when writing, reviewing, or refactoring React/Next.js code. | |
| --- | |
| ## 1. Eliminating Waterfalls (CRITICAL) | |
| Waterfalls are the #1 performance killer. Each sequential await adds full network latency. | |
| ### 1.1 Defer Await Until Needed | |
| Move `await` into branches where actually used. | |
| ```typescript | |
| // ❌ BAD: blocks both branches | |
| async function handleRequest(userId: string, skipProcessing: boolean) { | |
| const userData = await fetchUserData(userId) | |
| if (skipProcessing) return { skipped: true } | |
| return processUserData(userData) | |
| } | |
| // ✅ GOOD: only blocks when needed | |
| async function handleRequest(userId: string, skipProcessing: boolean) { | |
| if (skipProcessing) return { skipped: true } | |
| const userData = await fetchUserData(userId) | |
| return processUserData(userData) | |
| } | |
| ``` | |
| ### 1.2 Promise.all() for Independent Operations | |
| ```typescript | |
| // ❌ BAD: sequential, 3 round trips | |
| const user = await fetchUser() | |
| const posts = await fetchPosts() | |
| const comments = await fetchComments() | |
| // ✅ GOOD: parallel, 1 round trip | |
| const [user, posts, comments] = await Promise.all([ | |
| fetchUser(), | |
| fetchPosts(), | |
| fetchComments() | |
| ]) | |
| ``` | |
| ### 1.3 Start Promises Early in API Routes | |
| ```typescript | |
| // ❌ BAD: sequential | |
| export async function GET(request: Request) { | |
| const session = await auth() | |
| const config = await fetchConfig() | |
| const data = await fetchData(session.user.id) | |
| return Response.json({ data, config }) | |
| } | |
| // ✅ GOOD: parallel start | |
| export async function GET(request: Request) { | |
| const sessionPromise = auth() | |
| const configPromise = fetchConfig() | |
| const session = await sessionPromise | |
| const [config, data] = await Promise.all([ | |
| configPromise, | |
| fetchData(session.user.id) | |
| ]) | |
| return Response.json({ data, config }) | |
| } | |
| ``` | |
| ### 1.4 Strategic Suspense Boundaries | |
| ```tsx | |
| // ❌ BAD: blocks entire page | |
| async function Page() { | |
| const data = await fetchData() | |
| return <div><DataDisplay data={data} /></div> | |
| } | |
| // ✅ GOOD: streams content | |
| function Page() { | |
| return ( | |
| <div> | |
| <Header /> | |
| <Suspense fallback={<Skeleton />}> | |
| <DataDisplay /> | |
| </Suspense> | |
| <Footer /> | |
| </div> | |
| ) | |
| } | |
| ``` | |
| --- | |
| ## 2. Bundle Size Optimization (CRITICAL) | |
| ### 2.1 Avoid Barrel File Imports | |
| ```tsx | |
| // ❌ BAD: loads entire library (200-800ms import cost) | |
| import { Check, X, Menu } from 'lucide-react' | |
| // ✅ GOOD: loads only what you need | |
| import Check from 'lucide-react/dist/esm/icons/check' | |
| import X from 'lucide-react/dist/esm/icons/x' | |
| // ✅ ALTERNATIVE: Next.js config | |
| // next.config.js | |
| module.exports = { | |
| experimental: { optimizePackageImports: ['lucide-react', '@mui/material'] } | |
| } | |
| ``` | |
| ### 2.2 Dynamic Imports for Heavy Components | |
| ```tsx | |
| // ❌ BAD: Monaco bundles with main chunk | |
| import { MonacoEditor } from './monaco-editor' | |
| // ✅ GOOD: loads on demand | |
| import dynamic from 'next/dynamic' | |
| const MonacoEditor = dynamic( | |
| () => import('./monaco-editor').then(m => m.MonacoEditor), | |
| { ssr: false } | |
| ) | |
| ``` | |
| ### 2.3 Defer Non-Critical Third-Party Libraries | |
| ```tsx | |
| // ❌ BAD: blocks initial bundle | |
| import { Analytics } from '@vercel/analytics/react' | |
| // ✅ GOOD: loads after hydration | |
| const Analytics = dynamic( | |
| () => import('@vercel/analytics/react').then(m => m.Analytics), | |
| { ssr: false } | |
| ) | |
| ``` | |
| ### 2.4 Conditional Module Loading | |
| ```tsx | |
| useEffect(() => { | |
| if (enabled && !frames && typeof window !== 'undefined') { | |
| import('./animation-frames.js') | |
| .then(mod => setFrames(mod.frames)) | |
| } | |
| }, [enabled, frames]) | |
| ``` | |
| ### 2.5 Preload on User Intent | |
| ```tsx | |
| function EditorButton({ onClick }: { onClick: () => void }) { | |
| const preload = () => { | |
| if (typeof window !== 'undefined') void import('./monaco-editor') | |
| } | |
| return <button onMouseEnter={preload} onFocus={preload} onClick={onClick}>Open</button> | |
| } | |
| ``` | |
| --- | |
| ## 3. Server-Side Performance (HIGH) | |
| ### 3.1 Authenticate Server Actions | |
| ```typescript | |
| // ❌ BAD: no auth check | |
| 'use server' | |
| export async function deleteUser(userId: string) { | |
| await db.user.delete({ where: { id: userId } }) | |
| } | |
| // ✅ GOOD: always verify auth | |
| 'use server' | |
| export async function deleteUser(userId: string) { | |
| const session = await verifySession() | |
| if (!session || session.user.role !== 'admin') throw unauthorized() | |
| await db.user.delete({ where: { id: userId } }) | |
| } | |
| ``` | |
| ### 3.2 React.cache() for Per-Request Deduplication | |
| ```typescript | |
| import { cache } from 'react' | |
| export const getCurrentUser = cache(async () => { | |
| const session = await auth() | |
| if (!session?.user?.id) return null | |
| return await db.user.findUnique({ where: { id: session.user.id } }) | |
| }) | |
| ``` | |
| ### 3.3 Minimize RSC Serialization | |
| ```tsx | |
| // ❌ BAD: serializes all 50 fields | |
| <Profile user={user} /> | |
| // ✅ GOOD: serializes only needed fields | |
| <Profile name={user.name} avatar={user.avatar} /> | |
| ``` | |
| ### 3.4 Parallel Data Fetching with Composition | |
| ```tsx | |
| // ❌ BAD: Sidebar waits for Page's fetch | |
| export default async function Page() { | |
| const header = await fetchHeader() | |
| return <div><div>{header}</div><Sidebar /></div> | |
| } | |
| // ✅ GOOD: both fetch simultaneously | |
| export default function Page() { | |
| return <div><Header /><Sidebar /></div> | |
| } | |
| ``` | |
| ### 3.5 Use after() for Non-Blocking Operations | |
| ```typescript | |
| import { after } from 'next/server' | |
| export async function POST(request: Request) { | |
| await updateDatabase(request) | |
| after(async () => { logUserAction({ userAgent }) }) | |
| return Response.json({ status: 'success' }) | |
| } | |
| ``` | |
| ### 3.6 LRU Cache for Cross-Request Caching | |
| ```typescript | |
| import { LRUCache } from 'lru-cache' | |
| const cache = new LRUCache<string, any>({ max: 1000, ttl: 5 * 60 * 1000 }) | |
| export async function getUser(id: string) { | |
| const cached = cache.get(id) | |
| if (cached) return cached | |
| const user = await db.user.findUnique({ where: { id } }) | |
| cache.set(id, user) | |
| return user | |
| } | |
| ``` | |
| --- | |
| ## 4. Client-Side Data Fetching (MEDIUM-HIGH) | |
| ### 4.1 Use SWR for Automatic Deduplication | |
| ```tsx | |
| // ❌ BAD: no deduplication | |
| const [users, setUsers] = useState([]) | |
| useEffect(() => { fetch('/api/users').then(r => r.json()).then(setUsers) }, []) | |
| // ✅ GOOD: multiple instances share one request | |
| import useSWR from 'swr' | |
| const { data: users } = useSWR('/api/users', fetcher) | |
| ``` | |
| ### 4.2 Use Passive Event Listeners | |
| ```typescript | |
| // ✅ GOOD: enables immediate scrolling | |
| document.addEventListener('touchstart', handleTouch, { passive: true }) | |
| document.addEventListener('wheel', handleWheel, { passive: true }) | |
| ``` | |
| ### 4.3 Version localStorage Data | |
| ```typescript | |
| const VERSION = 'v2' | |
| function saveConfig(config: Config) { | |
| try { localStorage.setItem(`config:${VERSION}`, JSON.stringify(config)) } catch {} | |
| } | |
| ``` | |
| --- | |
| ## 5. Re-render Optimization (MEDIUM) | |
| ### 5.1 Derive State During Render (Not in Effects) | |
| ```tsx | |
| // ❌ BAD: redundant state and effect | |
| const [fullName, setFullName] = useState('') | |
| useEffect(() => { setFullName(firstName + ' ' + lastName) }, [firstName, lastName]) | |
| // ✅ GOOD: derive during render | |
| const fullName = firstName + ' ' + lastName | |
| ``` | |
| ### 5.2 Use Functional setState | |
| ```tsx | |
| // ❌ BAD: stale closure risk | |
| const addItem = useCallback((item) => setItems([...items, item]), [items]) | |
| // ✅ GOOD: stable callback, no stale closures | |
| const addItem = useCallback((item) => setItems(curr => [...curr, item]), []) | |
| ``` | |
| ### 5.3 Lazy State Initialization | |
| ```tsx | |
| // ❌ BAD: runs on every render | |
| const [settings, setSettings] = useState(JSON.parse(localStorage.getItem('settings') || '{}')) | |
| // ✅ GOOD: runs only once | |
| const [settings, setSettings] = useState(() => { | |
| const stored = localStorage.getItem('settings') | |
| return stored ? JSON.parse(stored) : {} | |
| }) | |
| ``` | |
| ### 5.4 Extract to Memoized Components | |
| ```tsx | |
| // ✅ GOOD: skips computation when loading | |
| const UserAvatar = memo(function UserAvatar({ user }) { | |
| const id = useMemo(() => computeAvatarId(user), [user]) | |
| return <Avatar id={id} /> | |
| }) | |
| function Profile({ user, loading }) { | |
| if (loading) return <Skeleton /> | |
| return <UserAvatar user={user} /> | |
| } | |
| ``` | |
| ### 5.5 Hoist Default Non-primitive Props | |
| ```tsx | |
| // ❌ BAD: breaks memoization | |
| const UserAvatar = memo(({ onClick = () => {} }) => { ... }) | |
| // ✅ GOOD: stable default | |
| const NOOP = () => {} | |
| const UserAvatar = memo(({ onClick = NOOP }) => { ... }) | |
| ``` | |
| ### 5.6 Use Primitive Dependencies in Effects | |
| ```tsx | |
| // ❌ BAD: re-runs on any user field change | |
| useEffect(() => { console.log(user.id) }, [user]) | |
| // ✅ GOOD: re-runs only when id changes | |
| useEffect(() => { console.log(user.id) }, [user.id]) | |
| ``` | |
| ### 5.7 Put Interaction Logic in Event Handlers | |
| ```tsx | |
| // ❌ BAD: effect re-runs on unrelated changes | |
| useEffect(() => { if (submitted) post('/api/register') }, [submitted, theme]) | |
| // ✅ GOOD: do it in the handler | |
| function handleSubmit() { post('/api/register'); showToast('Registered', theme) } | |
| ``` | |
| ### 5.8 Use Transitions for Non-Urgent Updates | |
| ```tsx | |
| import { startTransition } from 'react' | |
| const handler = () => { | |
| startTransition(() => setScrollY(window.scrollY)) | |
| } | |
| ``` | |
| ### 5.9 Use useRef for Transient Values | |
| ```tsx | |
| // ❌ BAD: renders every update | |
| const [lastX, setLastX] = useState(0) | |
| // ✅ GOOD: no re-render for tracking | |
| const lastXRef = useRef(0) | |
| useEffect(() => { | |
| const onMove = (e) => { lastXRef.current = e.clientX } | |
| window.addEventListener('mousemove', onMove) | |
| return () => window.removeEventListener('mousemove', onMove) | |
| }, []) | |
| ``` | |
| ### 5.10 Don't Wrap Simple Expressions in useMemo | |
| ```tsx | |
| // ❌ BAD: useMemo overhead exceeds benefit | |
| const isLoading = useMemo(() => user.isLoading || notifications.isLoading, [user.isLoading, notifications.isLoading]) | |
| // ✅ GOOD: simple expression | |
| const isLoading = user.isLoading || notifications.isLoading | |
| ``` | |
| ### 5.11 Defer State Reads to Usage Point | |
| ```tsx | |
| // ❌ BAD: subscribes to all searchParams changes | |
| const searchParams = useSearchParams() | |
| const handleShare = () => { shareChat(chatId, { ref: searchParams.get('ref') }) } | |
| // ✅ GOOD: reads on demand | |
| const handleShare = () => { | |
| const params = new URLSearchParams(window.location.search) | |
| shareChat(chatId, { ref: params.get('ref') }) | |
| } | |
| ``` | |
| --- | |
| ## 6. Rendering Performance (MEDIUM) | |
| ### 6.1 CSS content-visibility for Long Lists | |
| ```css | |
| .message-item { | |
| content-visibility: auto; | |
| contain-intrinsic-size: 0 80px; | |
| } | |
| ``` | |
| ### 6.2 Hoist Static JSX | |
| ```tsx | |
| // ✅ GOOD: reuses same element | |
| const skeleton = <div className="animate-pulse h-20 bg-gray-200" /> | |
| function Container() { return <div>{loading && skeleton}</div> } | |
| ``` | |
| ### 6.3 Animate SVG Wrapper (Not SVG Element) | |
| ```tsx | |
| // ❌ BAD: no hardware acceleration | |
| <svg className="animate-spin">...</svg> | |
| // ✅ GOOD: hardware accelerated | |
| <div className="animate-spin"><svg>...</svg></div> | |
| ``` | |
| ### 6.4 Use Explicit Conditional Rendering | |
| ```tsx | |
| // ❌ BAD: renders "0" when count is 0 | |
| {count && <span>{count}</span>} | |
| // ✅ GOOD: renders nothing when count is 0 | |
| {count > 0 ? <span>{count}</span> : null} | |
| ``` | |
| ### 6.5 Prevent Hydration Mismatch Without Flickering | |
| Use inline script to set client-only values before React hydrates. | |
| ### 6.6 Use useTransition Over Manual Loading States | |
| ```tsx | |
| const [isPending, startTransition] = useTransition() | |
| const handleSearch = (value: string) => { | |
| setQuery(value) | |
| startTransition(async () => { | |
| const data = await fetchResults(value) | |
| setResults(data) | |
| }) | |
| } | |
| ``` | |
| --- | |
| ## 7. JavaScript Performance (LOW-MEDIUM) | |
| ### 7.1 Build Index Maps for Repeated Lookups | |
| ```typescript | |
| // ❌ BAD: O(n) per lookup | |
| orders.map(order => ({ ...order, user: users.find(u => u.id === order.userId) })) | |
| // ✅ GOOD: O(1) per lookup | |
| const userById = new Map(users.map(u => [u.id, u])) | |
| orders.map(order => ({ ...order, user: userById.get(order.userId) })) | |
| ``` | |
| ### 7.2 Use Set/Map for O(1) Lookups | |
| ```typescript | |
| // ❌ BAD: O(n) per check | |
| items.filter(item => allowedIds.includes(item.id)) | |
| // ✅ GOOD: O(1) per check | |
| const allowedSet = new Set(allowedIds) | |
| items.filter(item => allowedSet.has(item.id)) | |
| ``` | |
| ### 7.3 Combine Multiple Array Iterations | |
| ```typescript | |
| // ❌ BAD: 3 iterations | |
| const admins = users.filter(u => u.isAdmin) | |
| const testers = users.filter(u => u.isTester) | |
| // ✅ GOOD: 1 iteration | |
| const admins: User[] = [], testers: User[] = [] | |
| for (const user of users) { | |
| if (user.isAdmin) admins.push(user) | |
| if (user.isTester) testers.push(user) | |
| } | |
| ``` | |
| ### 7.4 Early Length Check for Array Comparisons | |
| ```typescript | |
| function hasChanges(current: string[], original: string[]) { | |
| if (current.length !== original.length) return true | |
| // ... expensive comparison only if lengths match | |
| } | |
| ``` | |
| ### 7.5 Use toSorted() for Immutability | |
| ```typescript | |
| // ❌ BAD: mutates original | |
| const sorted = users.sort((a, b) => a.name.localeCompare(b.name)) | |
| // ✅ GOOD: creates new array | |
| const sorted = users.toSorted((a, b) => a.name.localeCompare(b.name)) | |
| ``` | |
| ### 7.6 Avoid Layout Thrashing | |
| ```typescript | |
| // ❌ BAD: interleaved reads and writes force reflows | |
| element.style.width = '100px' | |
| const width = element.offsetWidth // Forces reflow | |
| element.style.height = '200px' | |
| // ✅ GOOD: batch writes, then read | |
| element.style.width = '100px' | |
| element.style.height = '200px' | |
| const { width, height } = element.getBoundingClientRect() | |
| ``` | |
| ### 7.7 Hoist RegExp Creation | |
| ```tsx | |
| // ❌ BAD: new RegExp every render | |
| const regex = new RegExp(`(${query})`, 'gi') | |
| // ✅ GOOD: memoize | |
| const regex = useMemo(() => new RegExp(`(${escapeRegex(query)})`, 'gi'), [query]) | |
| ``` | |
| ### 7.8 Cache Property Access in Loops | |
| ```typescript | |
| // ✅ GOOD | |
| const value = obj.config.settings.value | |
| const len = arr.length | |
| for (let i = 0; i < len; i++) { process(value) } | |
| ``` | |
| ### 7.9 Use Loop for Min/Max Instead of Sort | |
| ```typescript | |
| // ❌ BAD: O(n log n) | |
| const sorted = [...projects].sort((a, b) => b.updatedAt - a.updatedAt) | |
| return sorted[0] | |
| // ✅ GOOD: O(n) | |
| let latest = projects[0] | |
| for (const p of projects) if (p.updatedAt > latest.updatedAt) latest = p | |
| return latest | |
| ``` | |
| --- | |
| ## 8. Advanced Patterns (LOW) | |
| ### 8.1 Initialize App Once, Not Per Mount | |
| ```tsx | |
| let didInit = false | |
| function App() { | |
| useEffect(() => { | |
| if (didInit) return | |
| didInit = true | |
| loadFromStorage() | |
| checkAuthToken() | |
| }, []) | |
| } | |
| ``` | |
| ### 8.2 Store Event Handlers in Refs / useEffectEvent | |
| ```tsx | |
| import { useEffectEvent } from 'react' | |
| function useWindowEvent(event: string, handler: (e) => void) { | |
| const onEvent = useEffectEvent(handler) | |
| useEffect(() => { | |
| window.addEventListener(event, onEvent) | |
| return () => window.removeEventListener(event, onEvent) | |
| }, [event]) | |
| } | |
| ``` | |
| --- | |
| ## References | |
| - [react.dev](https://react.dev) | |
| - [nextjs.org](https://nextjs.org) | |
| - [swr.vercel.app](https://swr.vercel.app) | |
| - [Vercel Blog: Package Import Optimization](https://vercel.com/blog/how-we-optimized-package-imports-in-next-js) | |
| - [Vercel Blog: Dashboard Performance](https://vercel.com/blog/how-we-made-the-vercel-dashboard-twice-as-fast) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment