React Patterns
Performance and composition patterns for React 19 + Vite + Cloudflare Workers projects. Use as a checklist when writing new components, a review guide when auditing existing code, or a refactoring playbook when something feels slow or tangled.
Rules are ranked by impact. Fix CRITICAL issues before touching MEDIUM ones.
When to Apply
- Writing new React components or pages
- Reviewing code for performance issues
- Refactoring components with too many props or re-renders
- Debugging "why is this slow?" or "why does this re-render?"
- Building reusable component libraries
- Code review before merging
1. Eliminating Waterfalls (CRITICAL)
Sequential async calls where they could be parallel. The #1 performance killer.
| Pattern |
Problem |
Fix |
| Await in sequence |
const a = await getA(); const b = await getB(); |
const [a, b] = await Promise.all([getA(), getB()]); |
| Fetch in child |
Parent renders, then child fetches, then grandchild fetches |
Hoist fetches to the highest common ancestor, pass data down |
| Suspense cascade |
Multiple Suspense boundaries that resolve sequentially |
One Suspense boundary wrapping all async siblings |
| Await before branch |
const data = await fetch(); if (condition) { use(data); } |
Move await inside the branch — don't fetch what you might not use |
| Import then render |
const Component = await import('./Heavy'); return <Component /> |
Use React.lazy() + <Suspense> — renders fallback instantly |
How to find them: Search for await in components. Each await is a potential waterfall. If two awaits are independent, they should be parallel.
2. Bundle Size (CRITICAL)
Every KB the user downloads is a KB they wait for.
| Pattern |
Problem |
Fix |
| Barrel imports |
import { Button } from '@/components' pulls the entire barrel file |
import { Button } from '@/components/ui/button' — direct import |
| No code splitting |
Heavy component loaded on every page |
React.lazy(() => import('./HeavyComponent')) + <Suspense> |
| Third-party at load |
Analytics/tracking loaded before the app renders |
Load after hydration: useEffect(() => { import('./analytics') }, []) |
| Full library import |
import _ from 'lodash' (70KB) |
import debounce from 'lodash/debounce' (1KB) |
| Lucide tree-shaking |
import * as Icons from 'lucide-react' (all icons) |
Explicit map: import { Home, Settings } from 'lucide-react' |
| Duplicate React |
Library bundles its own React → "Cannot read properties of null" |
resolve.dedupe: ['react', 'react-dom'] in vite.config.ts |
How to find them: npx vite-bundle-visualizer — shows what's in your bundle.
3. Composition Architecture (HIGH)
How you structure components matters more than how you optimise them.
| Pattern |
Problem |
Fix |
| Boolean prop explosion |
<Card isCompact isClickable showBorder hasIcon isLoading> |
Explicit variants: <CompactCard>, <ClickableCard> |
| Compound components |
Complex component with 15 props |
Split into <Dialog>, <Dialog.Trigger>, <Dialog.Content> with shared context |
| renderX props |
<Layout renderSidebar={...} renderHeader={...} renderFooter={...}> |
Use children + named slots: <Layout><Sidebar /><Header /></Layout> |
| Lift state |
Sibling components can't share state |
Move state to parent or context provider |
| Provider implementation |
Consumer code knows about state management internals |
Provider exposes interface { state, actions, meta } — implementation hidden |
| Inline components |
function Parent() { function Child() { ... } return <Child /> } |
Define Child outside Parent — inline components remount on every render |
The test: If a component has more than 5 boolean props, it needs composition, not more props.
4. Re-render Prevention (MEDIUM)
Not all re-renders are bad. Only fix re-renders that cause visible jank or wasted computation.
| Pattern |
Problem |
Fix |
| Default object/array props |
function Foo({ items = [] }) → new array ref every render |
Hoist: const DEFAULT = []; function Foo({ items = DEFAULT }) |
| Derived state in effect |
useEffect(() => setFiltered(items.filter(...)), [items]) |
Derive during render: const filtered = useMemo(() => items.filter(...), [items]) |
| Object dependency |
useEffect(() => {...}, [config]) fires every render if config is {} |
Use primitive deps: useEffect(() => {...}, [config.id, config.type]) |
| Subscribe to unused state |
Component reads { user, theme, settings } but only uses user |
Split context or use selector: useSyncExternalStore |
| State for transient values |
const [mouseX, setMouseX] = useState(0) on mousemove |
Use useRef for values that change frequently but don't need re-render |
| Inline callback props |
<Button => doThing(id)} /> — new function every render |
useCallback or functional setState: <Button /> |
How to find them: React DevTools Profiler → "Why did this render?" or <React.StrictMode> double-renders in dev.
5. React 19 Specifics (MEDIUM)
Patterns that changed or are new in React 19.
| Pattern |
Old (React 18) |
New (React 19) |
| Form state |
useFormState |
useActionState — renamed |
| Ref forwarding |
forwardRef((props, ref) => ...) |
function Component({ ref, ...props }) — ref is a regular prop |
| Context |
useContext(MyContext) |
use(MyContext) — works in conditionals and loops |
| Pending UI |
Manual loading state |
useTransition + startTransition for non-urgent updates |
| Route-level lazy |
Works with createBrowserRouter only |
Still true — <Route lazy={...}> is silently ignored with <BrowserRouter> |
| Optimistic updates |
Manual state management |
useOptimistic hook |
| Metadata |
Helmet or manual <head> management |
<title>, <meta>, <link> in component JSX — hoisted to <head> automatically |
6. Rendering Performance (MEDIUM)
| Pattern |
Problem |
Fix |
| Layout shift on load |
Content jumps when async data arrives |
Skeleton screens matching final layout dimensions |
| Animate SVG directly |
Janky SVG animation |
Wrap in <div>, animate the div instead |
| Large list rendering |
1000+ items in a table/list |
@tanstack/react-virtual for virtualised rendering |
| content-visibility |
Long scrollable content renders everything upfront |
content-visibility: auto on off-screen sections |
| Conditional render with && |
{count && <Items />} renders 0 when count is 0 |
Use ternary: {count > 0 ? <Items /> : null} |
7. Data Fetching (MEDIUM)
| Pattern |
Problem |
Fix |
| No deduplication |
Same data fetched by 3 components |
TanStack Query or SWR — automatic dedup + caching |
| Fetch on mount |
useEffect(() => { fetch(...) }, []) — waterfalls, no caching, no dedup |
TanStack Query: useQuery({ queryKey: ['users'], queryFn: fetchUsers }) |
| No optimistic update |
User clicks save, waits 2 seconds, then sees change |
useMutation with onMutate for instant visual feedback |
| Stale closure in interval |
setInterval captures stale state |
useRef for the interval ID and current values |
| Polling without cleanup |
setInterval in useEffect without clearInterval |
Return cleanup: useEffect(() => { const id = setInterval(...); return () => clearInterval(id); }) |
8. Vite + Cloudflare Specifics (MEDIUM)
| Pattern |
Problem |
Fix |
import.meta.env in Node scripts |
Undefined — only works in Vite-processed files |
Use loadEnv() from vite |
| React duplicate instance |
Library bundles its own React |
resolve.dedupe + optimizeDeps.include in vite.config.ts |
| Radix Select empty string |
<SelectItem value=""> throws |
Use sentinel: <SelectItem value="__any__"> |
| React Hook Form null |
{...field} passes null to Input |
Spread manually: value={field.value ?? ''} |
| Env vars at edge |
process.env doesn't exist in Workers |
Use c.env (Hono context) or import.meta.env (Vite build-time) |
Using as a Review Checklist
When reviewing code, go through categories 1-3 (CRITICAL + HIGH) for every PR. Categories 4-8 only when performance is a concern.
/react-patterns [file or component path]
Read the file, check against rules in priority order, report findings as:
file:line — [rule] description of issue
1---2name: react-patterns3description: React 19 performance patterns and composition architecture for Vite + Cloudflare projects. 50+ rules ranked by impact — eliminating waterfalls, bundle optimisation, re-render prevention, composition over boolean props, server/client boundaries, and React 19 APIs. Use when writing, reviewing, or refactoring React components. Triggers: 'react patterns', 'react review', 'react performance', 'optimise components', 'react best practices', 'composition patterns', 'why is it slow', 'reduce re-renders', 'fix waterfall'.4license: Unspecified5---6# React Patterns78Performance and composition patterns for React 19 + Vite + Cloudflare Workers projects. Use as a checklist when writing new components, a review guide when auditing existing code, or a refactoring playbook when something feels slow or tangled.910Rules are ranked by impact. Fix CRITICAL issues before touching MEDIUM ones.1112## When to Apply1314- Writing new React components or pages15- Reviewing code for performance issues16- Refactoring components with too many props or re-renders17- Debugging "why is this slow?" or "why does this re-render?"18- Building reusable component libraries19- Code review before merging2021## 1. Eliminating Waterfalls (CRITICAL)2223Sequential async calls where they could be parallel. The #1 performance killer.2425| Pattern | Problem | Fix |26|---------|---------|-----|27| **Await in sequence** | `const a = await getA(); const b = await getB();` | `const [a, b] = await Promise.all([getA(), getB()]);` |28| **Fetch in child** | Parent renders, then child fetches, then grandchild fetches | Hoist fetches to the highest common ancestor, pass data down |29| **Suspense cascade** | Multiple Suspense boundaries that resolve sequentially | One Suspense boundary wrapping all async siblings |30| **Await before branch** | `const data = await fetch(); if (condition) { use(data); }` | Move await inside the branch — don't fetch what you might not use |31| **Import then render** | `const Component = await import('./Heavy'); return <Component />` | Use `React.lazy()` + `<Suspense>` — renders fallback instantly |3233**How to find them**: Search for `await` in components. Each `await` is a potential waterfall. If two awaits are independent, they should be parallel.3435## 2. Bundle Size (CRITICAL)3637Every KB the user downloads is a KB they wait for.3839| Pattern | Problem | Fix |40|---------|---------|-----|41| **Barrel imports** | `import { Button } from '@/components'` pulls the entire barrel file | `import { Button } from '@/components/ui/button'` — direct import |42| **No code splitting** | Heavy component loaded on every page | `React.lazy(() => import('./HeavyComponent'))` + `<Suspense>` |43| **Third-party at load** | Analytics/tracking loaded before the app renders | Load after hydration: `useEffect(() => { import('./analytics') }, [])` |44| **Full library import** | `import _ from 'lodash'` (70KB) | `import debounce from 'lodash/debounce'` (1KB) |45| **Lucide tree-shaking** | `import * as Icons from 'lucide-react'` (all icons) | Explicit map: `import { Home, Settings } from 'lucide-react'` |46| **Duplicate React** | Library bundles its own React → "Cannot read properties of null" | `resolve.dedupe: ['react', 'react-dom']` in vite.config.ts |4748**How to find them**: `npx vite-bundle-visualizer` — shows what's in your bundle.4950## 3. Composition Architecture (HIGH)5152How you structure components matters more than how you optimise them.5354| Pattern | Problem | Fix |55|---------|---------|-----|56| **Boolean prop explosion** | `<Card isCompact isClickable showBorder hasIcon isLoading>` | Explicit variants: `<CompactCard>`, `<ClickableCard>` |57| **Compound components** | Complex component with 15 props | Split into `<Dialog>`, `<Dialog.Trigger>`, `<Dialog.Content>` with shared context |58| **renderX props** | `<Layout renderSidebar={...} renderHeader={...} renderFooter={...}>` | Use children + named slots: `<Layout><Sidebar /><Header /></Layout>` |59| **Lift state** | Sibling components can't share state | Move state to parent or context provider |60| **Provider implementation** | Consumer code knows about state management internals | Provider exposes interface `{ state, actions, meta }` — implementation hidden |61| **Inline components** | `function Parent() { function Child() { ... } return <Child /> }` | Define Child outside Parent — inline components remount on every render |6263**The test**: If a component has more than 5 boolean props, it needs composition, not more props.6465## 4. Re-render Prevention (MEDIUM)6667Not all re-renders are bad. Only fix re-renders that cause visible jank or wasted computation.6869| Pattern | Problem | Fix |70|---------|---------|-----|71| **Default object/array props** | `function Foo({ items = [] })` → new array ref every render | Hoist: `const DEFAULT = []; function Foo({ items = DEFAULT })` |72| **Derived state in effect** | `useEffect(() => setFiltered(items.filter(...)), [items])` | Derive during render: `const filtered = useMemo(() => items.filter(...), [items])` |73| **Object dependency** | `useEffect(() => {...}, [config])` fires every render if config is `{}` | Use primitive deps: `useEffect(() => {...}, [config.id, config.type])` |74| **Subscribe to unused state** | Component reads `{ user, theme, settings }` but only uses `user` | Split context or use selector: `useSyncExternalStore` |75| **State for transient values** | `const [mouseX, setMouseX] = useState(0)` on mousemove | Use `useRef` for values that change frequently but don't need re-render |76| **Inline callback props** | `<Button onClick={() => doThing(id)} />` — new function every render | `useCallback` or functional setState: `<Button onClick={handleClick} />` |7778**How to find them**: React DevTools Profiler → "Why did this render?" or `<React.StrictMode>` double-renders in dev.7980## 5. React 19 Specifics (MEDIUM)8182Patterns that changed or are new in React 19.8384| Pattern | Old (React 18) | New (React 19) |85|---------|---------------|----------------|86| **Form state** | `useFormState` | `useActionState` — renamed |87| **Ref forwarding** | `forwardRef((props, ref) => ...)` | `function Component({ ref, ...props })` — ref is a regular prop |88| **Context** | `useContext(MyContext)` | `use(MyContext)` — works in conditionals and loops |89| **Pending UI** | Manual loading state | `useTransition` + `startTransition` for non-urgent updates |90| **Route-level lazy** | Works with `createBrowserRouter` only | Still true — `<Route lazy={...}>` is silently ignored with `<BrowserRouter>` |91| **Optimistic updates** | Manual state management | `useOptimistic` hook |92| **Metadata** | Helmet or manual `<head>` management | `<title>`, `<meta>`, `<link>` in component JSX — hoisted to `<head>` automatically |9394## 6. Rendering Performance (MEDIUM)9596| Pattern | Problem | Fix |97|---------|---------|-----|98| **Layout shift on load** | Content jumps when async data arrives | Skeleton screens matching final layout dimensions |99| **Animate SVG directly** | Janky SVG animation | Wrap in `<div>`, animate the div instead |100| **Large list rendering** | 1000+ items in a table/list | `@tanstack/react-virtual` for virtualised rendering |101| **content-visibility** | Long scrollable content renders everything upfront | `content-visibility: auto` on off-screen sections |102| **Conditional render with &&** | `{count && <Items />}` renders `0` when count is 0 | Use ternary: `{count > 0 ? <Items /> : null}` |103104## 7. Data Fetching (MEDIUM)105106| Pattern | Problem | Fix |107|---------|---------|-----|108| **No deduplication** | Same data fetched by 3 components | TanStack Query or SWR — automatic dedup + caching |109| **Fetch on mount** | `useEffect(() => { fetch(...) }, [])` — waterfalls, no caching, no dedup | TanStack Query: `useQuery({ queryKey: ['users'], queryFn: fetchUsers })` |110| **No optimistic update** | User clicks save, waits 2 seconds, then sees change | `useMutation` with `onMutate` for instant visual feedback |111| **Stale closure in interval** | `setInterval` captures stale state | `useRef` for the interval ID and current values |112| **Polling without cleanup** | `setInterval` in useEffect without `clearInterval` | Return cleanup: `useEffect(() => { const id = setInterval(...); return () => clearInterval(id); })` |113114## 8. Vite + Cloudflare Specifics (MEDIUM)115116| Pattern | Problem | Fix |117|---------|---------|-----|118| **`import.meta.env` in Node scripts** | Undefined — only works in Vite-processed files | Use `loadEnv()` from vite |119| **React duplicate instance** | Library bundles its own React | `resolve.dedupe` + `optimizeDeps.include` in vite.config.ts |120| **Radix Select empty string** | `<SelectItem value="">` throws | Use sentinel: `<SelectItem value="__any__">` |121| **React Hook Form null** | `{...field}` passes null to Input | Spread manually: `value={field.value ?? ''}` |122| **Env vars at edge** | `process.env` doesn't exist in Workers | Use `c.env` (Hono context) or `import.meta.env` (Vite build-time) |123124## Using as a Review Checklist125126When reviewing code, go through categories 1-3 (CRITICAL + HIGH) for every PR. Categories 4-8 only when performance is a concern.127128```129/react-patterns [file or component path]130```131132Read the file, check against rules in priority order, report findings as:133```134file:line — [rule] description of issue135```