React
React 19 and TypeScript conventions for the Toast Admin app.
React 19 and TypeScript conventions for the Toast Admin app.
For shared admin state boundaries and Zustand guardrails, see the Admin State.
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 or event-handler errors, use apiFetch with useActionState. See Error Handling for the full error handling strategy.
Suspense Boundaries
Toast currently prefers route-local Suspense boundaries for lazily loaded pages. The root layout does not wrap the entire app in Suspense; instead, individual routes (for example settings.tsx) add a boundary around their lazy component.
// In a route module such as settings.tsx
<Suspense fallback={null}>
<SettingsPage />
</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 route-local <Suspense> boundary handles loading 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
DashboardLayout for Authenticated Pages
All authenticated pages render inside DashboardLayout via the _authenticated route. Route modules do not import a shell component directly — the layout route applies it once for the whole authenticated section.
// apps/admin/src/routes/_authenticated.tsx
export const Route = createRoute({
getParentRoute: () => rootRoute,
id: '_authenticated',
component: AuthenticatedLayout,
});
function AuthenticatedLayout() {
const { data: session } = useSession();
return (
<DashboardLayout user={session.user} onSignOut={() => void signOut()}>
<Outlet />
</DashboardLayout>
);
}Key points:
- Do not duplicate header/footer/sidebar markup in individual route files — authenticated routes inherit
DashboardLayout - The login page is the exception — it is not nested under
_authenticated - Layout-level sign-out and user chrome live in the authenticated layout, not in feature pages
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 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 lint rules without justification
- Don't use
indexas a key for dynamic lists