React Patterns
React Patterns
React 19 and TypeScript conventions for the Toast Admin app.
For shared admin state boundaries and Zustand guardrails, see Admin State Playbook.
Quick Reference
pnpm test # Run all tests (includes React components)
pnpm check # Type check + lint + test
pnpm dev # Start dev server with hot reloadReact Compiler
The Admin app uses React Compiler via babel-plugin-react-compiler. This automatically optimizes re-renders, eliminating most manual memoization.
What the Compiler Handles
| Previously Manual | Now Automatic |
|---|---|
useMemo for values | Compiler memoizes expensive computations |
useCallback for handlers | Compiler memoizes function references |
React.memo for components | Compiler skips unchanged subtrees |
When Manual Optimization Is Still Needed
The compiler cannot optimize everything. You still need to think about:
| Scenario | Solution |
|---|---|
| Expensive initial computation | useState(() => computeExpensive()) |
| Refs for mutable values | useRef (compiler doesn't touch refs) |
| Intentional re-render triggers | State updates still trigger renders |
| Third-party library integration | Follow library's optimization guidance |
Writing Compiler-Friendly Code
// Good - compiler can optimize this
function ProductList({ products }: { products: Product[] }) {
const sorted = products.toSorted((a, b) => a.name.localeCompare(b.name));
return <ul>{sorted.map(p => <ProductItem key={p.id} product={p} />)}</ul>;
}
// Avoid - breaks compiler optimization
function BadComponent() {
// Don't mutate during render
const items = [];
items.push('a'); // Mutation!
// Don't use non-deterministic values without useMemo/useState
const id = Math.random(); // Different every render!
return <div id={id}>{items}</div>;
}React 19 Features
Ref as Prop
React 19 supports passing ref as a regular prop, eliminating the need for forwardRef.
// React 19 - ref as prop (preferred)
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label: string;
ref?: React.Ref<HTMLInputElement>;
}
function Input({ label, ref, ...props }: InputProps) {
return (
<label>
{label}
<input ref={ref} {...props} />
</label>
);
}
// Usage - works naturally
function Form() {
const inputRef = useRef<HTMLInputElement>(null);
return <Input label="Email" ref={inputRef} />;
}// Legacy pattern (still works, but verbose)
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ label, ...props }, ref) => {
return (
<label>
{label}
<input ref={ref} {...props} />
</label>
);
}
);
Input.displayName = 'Input';useActionState for Forms
React 19's useActionState consolidates form submission state (loading, error, success) into a single hook, replacing the common pattern of multiple useState calls with try/catch/finally:
// React 19 - useActionState (preferred for form submissions)
interface FormState {
error: string | null;
success: boolean;
}
function ProfileForm({ user }: { user: User }) {
const [name, setName] = useState(user.name);
const [state, submitAction, isPending] = useActionState(
async (_prev: FormState, formData: FormData): Promise<FormState> => {
try {
const name = formData.get('name');
if (typeof name !== 'string' || !name.trim()) {
return { error: 'Name is required', success: false };
}
await updateProfile(name.trim());
return { error: null, success: true };
} catch (err) {
return {
error: err instanceof Error ? err.message : 'An unexpected error occurred',
success: false,
};
}
},
{ error: null, success: false }
);
return (
<form action={submitAction}>
{state.error && <ErrorMessage>{state.error}</ErrorMessage>}
{state.success && <SuccessMessage>Saved!</SuccessMessage>}
<input name="name" value={name} onChange={(e) => setName(e.target.value)} />
<button type="submit" disabled={isPending}>
{isPending ? 'Saving...' : 'Save'}
</button>
</form>
);
}// Legacy pattern (still works, but verbose)
function ProfileForm({ user }: { user: User }) {
const [name, setName] = useState(user.name);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setSuccess(false);
setIsSaving(true);
try {
await updateProfile(name);
setSuccess(true);
} catch (err) {
setError(err instanceof Error ? err.message : 'An unexpected error occurred');
} finally {
setIsSaving(false);
}
};
return <form onSubmit={handleSubmit}>...</form>;
}Key points:
- Use the form
actionprop with the dispatch function fromuseActionState - Input field values (e.g.,
name,email) can remain as controlleduseState—useActionStatereplaces submission state only isPendingis automatically managed by React's transition system- If state needs to be reset externally (e.g., on mode change), use a
resetKeypattern to invalidate stale action results
Context Shorthand
React 19 allows using context directly as a provider:
// React 19 - context as provider
const ThemeContext = createContext<'light' | 'dark'>('light');
function App() {
return (
<ThemeContext value="dark">
<Page />
</ThemeContext>
);
}
// Legacy pattern (still works)
function AppLegacy() {
return (
<ThemeContext.Provider value="dark">
<Page />
</ThemeContext.Provider>
);
}Document Metadata Hoisting
React 19 hoists <title>, <meta>, and <link> to the document head:
function BlogPost({ post }: { post: Post }) {
return (
<article>
<title>{post.title} | Toast Blog</title>
<meta name="description" content={post.excerpt} />
<h1>{post.title}</h1>
<p>{post.content}</p>
</article>
);
}useEffect Decision Tree
Before reaching for useEffect, ask yourself:
Do I need to synchronize with an external system?
├─ Yes: Database, API, browser API, third-party library
│ → useEffect is appropriate
│
└─ No: Can this be computed during render?
├─ Yes: Derived state
│ → Compute it directly, let compiler optimize
│
└─ No: Is this responding to a user event?
├─ Yes: Event handler
│ → Put logic in the handler, not useEffect
│
└─ No: Is this initialization?
├─ Yes: Lazy useState initializer
│ → useState(() => compute())
│
└─ No: Reconsider - you probably don't need useEffectWhen to Use useEffect
// Data fetching on mount
function ApiStatusIndicator() {
const [status, setStatus] = useState<'loading' | 'connected' | 'error'>('loading');
useEffect(() => {
let ignore = false; // Prevent state updates after unmount
fetchHealthStatus().then(result => {
if (!ignore) {
setStatus(result.status);
}
});
return () => { ignore = true; };
}, []);
return <StatusDot status={status} />;
}
// Subscribing to external store
function useWindowSize() {
const [size, setSize] = useState({ width: 0, height: 0 });
useEffect(() => {
function handleResize() {
setSize({ width: window.innerWidth, height: window.innerHeight });
}
handleResize(); // Set initial value
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return size;
}When NOT to Use useEffect
// BAD: Derived state in useEffect
function FilteredList({ items, filter }: Props) {
const [filtered, setFiltered] = useState<Item[]>([]);
// Don't do this!
useEffect(() => {
setFiltered(items.filter(item => item.name.includes(filter)));
}, [items, filter]);
return <List items={filtered} />;
}
// GOOD: Compute during render
function FilteredList({ items, filter }: Props) {
// Compiler will memoize this automatically
const filtered = items.filter(item => item.name.includes(filter));
return <List items={filtered} />;
}// BAD: Event logic in useEffect
function Form() {
const [submitted, setSubmitted] = useState(false);
useEffect(() => {
if (submitted) {
sendAnalytics('form_submitted');
}
}, [submitted]);
return <button onClick={() => setSubmitted(true)}>Submit</button>;
}
// GOOD: Logic in event handler
function Form() {
const handleSubmit = () => {
sendAnalytics('form_submitted');
// ... rest of submit logic
};
return <button onClick={handleSubmit}>Submit</button>;
}Race Condition Pattern
When fetching data, always handle the case where the component unmounts or dependencies change before the fetch completes. Use a mountedRef when both useEffect and event handlers need the same guard:
// Pattern 1: useEffect-only (simple fetch on mount/dependency change)
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let ignore = false;
setUser(null); // Reset on dependency change
setError(null);
fetchUser(userId)
.then(data => {
if (!ignore) setUser(data);
})
.catch(err => {
if (!ignore) setError(err.message);
});
return () => { ignore = true; };
}, [userId]);
if (error) return <ErrorMessage message={error} />;
if (!user) return <Loading />;
return <UserCard user={user} />;
}
// Pattern 2: Shared mounted ref (when both useEffect and handlers fetch)
function StatusIndicator() {
const [status, setStatus] = useState<'loading' | 'ok' | 'error'>('loading');
const mountedRef = useRef(true);
const refresh = () => {
setStatus('loading');
fetchStatus().then(result => {
if (mountedRef.current) setStatus(result);
});
};
useEffect(() => {
refresh();
return () => { mountedRef.current = false; };
}, []);
return (
<div>
<span>{status}</span>
<button onClick={refresh}>Retry</button>
</div>
);
}Route Guards (Authentication)
Use TanStack Router's beforeLoad for auth checks instead of useEffect + navigate(). This prevents flash of unauthorized content:
// GOOD: beforeLoad runs before the route renders
import { createRootRoute, Outlet, redirect } from '@tanstack/react-router';
import { authClient } from '../lib/auth';
export const Route = createRootRoute({
beforeLoad: async ({ location }) => {
if (location.pathname === '/login') {
return; // Login page is public
}
const session = await authClient.getSession();
if (!session.data) {
throw redirect({ to: '/login' });
}
},
component: RootLayout,
});
// BAD: useEffect redirect causes a flash of unauthorized content
function RootLayout() {
const { data: session } = useSession();
const navigate = useNavigate();
useEffect(() => {
if (!session) {
navigate({ to: '/login' }); // Component renders first, then redirects
}
}, [session, navigate]);
return <Outlet />;
}TypeScript Patterns
Component Props
// Props interface with JSDoc for documentation
interface LoginFormProps {
/** Callback when login succeeds */
onSuccess?: () => void;
/** Initial email to populate the form */
defaultEmail?: string;
}
// Extending HTML element props
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'destructive';
size?: 'sm' | 'md' | 'lg';
}
// Children pattern
interface CardProps {
title: string;
children: React.ReactNode;
}State Typing
// Simple state - type is inferred
const [count, setCount] = useState(0);
// Complex state - explicit type
const [user, setUser] = useState<User | null>(null);
// Union states for loading patterns
type AsyncState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: string };
const [state, setState] = useState<AsyncState<User>>({ status: 'idle' });Event Handlers
// Form events
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
// ...
};
// Input change events
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setEmail(e.target.value);
};
// Click events
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
// ...
};
// Inline handler - type inferred from context
<input onChange={(e) => setEmail(e.target.value)} />Refs
// DOM element ref
const inputRef = useRef<HTMLInputElement>(null);
// Mutable value ref (no re-render on change)
const timerRef = useRef<number | null>(null);
// Callback ref for measuring elements
const measureRef = useCallback((node: HTMLDivElement | null) => {
if (node) {
console.log(node.getBoundingClientRect());
}
}, []);State Management
useState vs useReducer
| Use useState when... | Use useReducer when... |
|---|---|
| State is a primitive or simple object | State has multiple sub-values |
| Updates are independent | Next state depends on previous state |
| Logic is straightforward | Update logic is complex |
| Few state transitions | Many possible state transitions |
// useState - simple, independent values (input fields)
function LoginForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
// Note: For submission state (loading, error, success), use useActionState
// instead of multiple useState calls. See "useActionState for Forms" above.
// ...
}
// useReducer - related state with complex transitions
type AuthState = {
status: 'idle' | 'loading' | 'authenticated' | 'error';
user: User | null;
error: string | null;
};
type AuthAction =
| { type: 'LOGIN_START' }
| { type: 'LOGIN_SUCCESS'; user: User }
| { type: 'LOGIN_ERROR'; error: string }
| { type: 'LOGOUT' };
function authReducer(state: AuthState, action: AuthAction): AuthState {
switch (action.type) {
case 'LOGIN_START':
return { ...state, status: 'loading', error: null };
case 'LOGIN_SUCCESS':
return { status: 'authenticated', user: action.user, error: null };
case 'LOGIN_ERROR':
return { status: 'error', user: null, error: action.error };
case 'LOGOUT':
return { status: 'idle', user: null, error: null };
}
}Derived State
Prefer computing values during render over storing derived state:
// GOOD: Derived during render
function TodoList({ todos }: { todos: Todo[] }) {
const completedCount = todos.filter(t => t.completed).length;
const pendingCount = todos.length - completedCount;
return (
<div>
<p>{completedCount} completed, {pendingCount} pending</p>
{/* ... */}
</div>
);
}
// BAD: Duplicated state
function TodoList({ todos }: { todos: Todo[] }) {
const [completedCount, setCompletedCount] = useState(0);
useEffect(() => {
setCompletedCount(todos.filter(t => t.completed).length);
}, [todos]);
// ...
}Lazy Initialization
Use a function initializer for expensive computations:
// Runs once on mount, not on every render
const [data, setData] = useState(() => {
return JSON.parse(localStorage.getItem('savedData') ?? '{}');
});Performance
Keys
Always use stable, unique keys for list items:
// GOOD: Unique, stable ID
{users.map(user => (
<UserCard key={user.id} user={user} />
))}
// BAD: Index as key (causes issues with reordering)
{users.map((user, index) => (
<UserCard key={index} user={user} />
))}
// BAD: Random key (defeats reconciliation)
{users.map(user => (
<UserCard key={Math.random()} user={user} />
))}Error Boundaries
Error Boundaries catch render-time, constructor, and class lifecycle method errors in child components and display fallback UI instead of crashing the entire app. The reusable ErrorBoundary component lives in packages/ui/ and is used at the route level in the root layout.
import { ErrorBoundary } from '@toast/ui';
// Static fallback
<ErrorBoundary fallback={<p>Something went wrong.</p>}>
<MyComponent />
</ErrorBoundary>
// Dynamic fallback with error details and retry
<ErrorBoundary
fallback={({ error, resetErrorBoundary }) => (
<div role="alert">
<p>Error: {error.message}</p>
<button onClick={resetErrorBoundary}>Try again</button>
</div>
)}
onError={(error) => console.error('Caught:', error)}
resetKeys={[router.state.location.pathname]}
>
<MyComponent />
</ErrorBoundary>The root layout (__root.tsx) wraps <Outlet /> with an ErrorBoundary that:
- Auto-resets when the route changes (via
resetKeys={[router.state.location.pathname]}) - Displays a full-page error fallback with "Try again" and "Go to Dashboard" buttons
- Shows error details in development mode
- Uses
role="alert"for screen reader announcements
Error Boundaries catch render-time, constructor, and class lifecycle method errors, but not errors from event handlers, effects, or async operations. For async/event handler errors, use apiFetch with useActionState. See error-handling.md for the full error handling strategy.
Suspense Boundaries
The root layout wraps <Outlet /> with a <Suspense> boundary that shows a loading spinner while lazy-loaded route components are fetched. This works in tandem with code splitting below.
// In __root.tsx
<Suspense fallback={<LoadingState />}>
<Outlet />
</Suspense>Code Splitting
Split non-critical route components into separate chunks that load on demand. This reduces the initial bundle size by deferring code that isn't needed on first render.
Route-level splitting uses React.lazy with a separate file for the component:
// settings.tsx - route definition (eagerly loaded, small)
import { createRoute } from '@tanstack/react-router';
import { lazy } from 'react';
import { Route as rootRoute } from './__root';
const SettingsPage = lazy(() => import('./settings.lazy'));
export const Route = createRoute({
getParentRoute: () => rootRoute,
path: '/settings',
component: SettingsPage,
});// settings.lazy.tsx - page component (code-split chunk)
export default function SettingsPage() {
return <div>Settings content...</div>;
}The root layout's <Suspense> boundary shows a loading spinner while the chunk is fetched.
Non-route component splitting uses the same React.lazy pattern:
import { lazy, Suspense } from 'react';
const HeavyEditor = lazy(() => import('../components/HeavyEditor'));
function EditPage() {
return (
<Suspense fallback={<LoadingSpinner />}>
<HeavyEditor />
</Suspense>
);
}Shared Layouts
AppShell for Authenticated Pages
All authenticated pages use the AppShell component (apps/admin/src/components/AppShell.tsx) for consistent layout. It provides the header (logo, user info), main content area, and footer (API status).
import { AppShell } from '../components/AppShell';
function MyPage({ user }: { user: User }) {
return (
<AppShell
user={user}
headerActions={<Button onClick={handleSignOut}>Sign out</Button>}
>
<h1>Page Title</h1>
<p>Page content goes here.</p>
</AppShell>
);
}Key points:
- Do not duplicate header/footer markup in individual route files — use
AppShell - The
headerActionsprop accepts anyReactNodefor the right side of the header (e.g., sign-out button, back link) - The login page is the exception — it has its own centered layout without a header
AppShellis a presentational component; it receivesuseras a prop rather than callinguseSessioninternally
Data Fetching
Use apiFetch for API Calls
The apiFetch utility in apps/admin/src/lib/api.ts is the single entry point for API calls. It combines base URL resolution (getApiUrl()) with safe JSON fetching (fetchJson), so consumers never need to call getApiUrl() directly or check for undefined:
import { apiFetch } from '../lib/api';
// GOOD: Single utility handles URL resolution + fetch + error handling
const result = await apiFetch<User>('/api/users/me');
if (!result.ok) {
return { error: result.error, success: false };
}
const user = result.data;
// BAD: Manual URL construction + raw fetch
const apiUrl = getApiUrl();
if (!apiUrl) {
/* handle error */
}
const response = await fetch(`${apiUrl}/api/users/me`);
const data = await response.json(); // Throws on non-JSON responsesapiFetch returns the same FetchResult<T> discriminated union as fetchJson:
type FetchResult<T> = { ok: true; data: T } | { ok: false; error: string };This integrates cleanly with useActionState:
const [state, submitAction, isPending] = useActionState(
async (_prev: FormState, formData: FormData): Promise<FormState> => {
const result = await apiFetch<Settings>('/api/settings', {
method: 'PUT',
body: JSON.stringify(Object.fromEntries(formData)),
});
if (!result.ok) {
return { error: result.error, success: false };
}
return { error: null, success: true };
},
{ error: null, success: false }
);When to Use fetchJson vs apiFetch
| Utility | When to use |
|---|---|
apiFetch | API calls to the Toast backend (uses getApiUrl() + fetchJson) |
fetchJson | Non-API fetches where you already have the full URL |
See AGENTS.md for the defensive coding rationale.
Testing
See testing.md for general testing patterns. This section covers React-specific guidance.
Query Priority
Follow React Testing Library's query priority:
// 1. Accessible queries (preferred)
screen.getByRole('button', { name: 'Submit' });
screen.getByLabelText('Email');
screen.getByPlaceholderText('Search...');
screen.getByText('Welcome');
// 2. Semantic queries
screen.getByAltText('User avatar');
screen.getByTitle('Close');
// 3. Test IDs (last resort)
screen.getByTestId('complex-component');Async Patterns
Handle async operations properly to avoid act() warnings:
import { render, screen } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
it('shows success message after form submission', async () => {
const user = userEvent.setup();
render(<ContactForm />);
await user.type(screen.getByLabelText('Email'), 'test@example.com');
await user.click(screen.getByRole('button', { name: 'Submit' }));
// Wait for async state updates
expect(await screen.findByText('Message sent!')).toBeInTheDocument();
});
it('handles API errors', async () => {
vi.spyOn(global, 'fetch').mockRejectedValue(new Error('Network error'));
render(<DataLoader />);
// findBy* waits for element to appear
expect(await screen.findByText('Network error')).toBeInTheDocument();
});Mocking Components
Mock child components that have side effects:
// Mock a component that fetches on mount
vi.mock('../components/ApiStatusIndicator', () => ({
ApiStatusIndicator: () => <div data-testid="api-status">Mocked</div>,
}));User Event Setup
Always use userEvent.setup() for realistic event simulation:
it('handles user interactions', async () => {
const user = userEvent.setup();
const handleClick = vi.fn();
render(<Button onClick={handleClick}>Click me</Button>);
await user.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1);
});Accessibility
All components must meet WCAG 2.1 AA standards. Key patterns:
Error Announcements
Dynamic error messages must be announced to screen readers:
// GOOD: role="alert" announces the error immediately
{state.error && (
<p role="alert" className="text-red-500">{state.error}</p>
)}
// BAD: Screen readers won't announce this
{state.error && (
<p className="text-red-500">{state.error}</p>
)}Decorative Content
Emoji and icons used for decoration must be hidden from assistive technology:
// GOOD: Hidden from screen readers
<span aria-hidden="true">📝</span> Posts
// BAD: Screen reader announces "memo Posts"
📝 PostsForm Labels
Every form input must have an accessible label:
// GOOD: Explicit label association
<label htmlFor="email">Email</label>
<input id="email" type="email" />
// GOOD: aria-label for icon-only buttons
<button aria-label="Close dialog">✕</button>
// BAD: No accessible name
<input type="email" placeholder="Email" />Status Indicators
Never rely on color alone to convey information:
// GOOD: Color + text
<span className="text-green-500">● Connected</span>
// BAD: Color only
<span className="text-green-500">●</span>Skip Navigation
Provide a skip-to-content link as the first focusable element in the layout so keyboard users can bypass repetitive navigation:
// In the root layout — visually hidden until focused
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-50 focus:rounded-md focus:bg-primary focus:px-4 focus:py-2 focus:text-primary-foreground focus:shadow-lg"
>
Skip to content
</a>
// In each page — mark the main content landmark
<main id="main-content" className="flex-1">
{/* page content */}
</main>Keyboard Navigation
Interactive elements must be keyboard accessible:
// GOOD: Native button is keyboard accessible
<button onClick={handleClick}>Action</button>
// BAD: div with click handler is not keyboard accessible
<div onClick={handleClick}>Action</div>Best Practices
Do
- Use function components (not class components)
- Use TypeScript for all components
- Keep components focused - split when they grow
- Colocate tests with components (
Component.test.tsx) - Use semantic HTML elements
- Add JSDoc comments for public component props
Don't
- Don't use
any- use proper types orunknown - Don't mutate state directly - always use setters
- Don't use
useEffectfor derived state - Don't suppress ESLint rules without justification
- Don't use
indexas a key for dynamic lists