Toast
ContributorPatterns

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 reload

React 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 ManualNow Automatic
useMemo for valuesCompiler memoizes expensive computations
useCallback for handlersCompiler memoizes function references
React.memo for componentsCompiler skips unchanged subtrees

When Manual Optimization Is Still Needed

The compiler cannot optimize everything. You still need to think about:

ScenarioSolution
Expensive initial computationuseState(() => computeExpensive())
Refs for mutable valuesuseRef (compiler doesn't touch refs)
Intentional re-render triggersState updates still trigger renders
Third-party library integrationFollow 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 action prop with the dispatch function from useActionState
  • Input field values (e.g., name, email) can remain as controlled useStateuseActionState replaces submission state only
  • isPending is automatically managed by React's transition system
  • If state needs to be reset externally (e.g., on mode change), use a resetKey pattern 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 useEffect

When 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 objectState has multiple sub-values
Updates are independentNext state depends on previous state
Logic is straightforwardUpdate logic is complex
Few state transitionsMany 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 headerActions prop accepts any ReactNode for 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
  • AppShell is a presentational component; it receives user as a prop rather than calling useSession internally

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 responses

apiFetch 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

UtilityWhen to use
apiFetchAPI calls to the Toast backend (uses getApiUrl() + fetchJson)
fetchJsonNon-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"
📝 Posts

Form 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 or unknown
  • Don't mutate state directly - always use setters
  • Don't use useEffect for derived state
  • Don't suppress ESLint rules without justification
  • Don't use index as a key for dynamic lists

On this page