Toast
ContributorPatterns

Error Handling

Error Handling

Error handling patterns for both the Toast API (server-side) and the Admin panel (client-side).

Client-Side Error Handling (React)

API Calls with apiFetch

Always use the apiFetch utility from apps/admin/src/lib/api.ts for API calls. It handles API URL resolution, JSON parsing, and error extraction in one call. Never use raw fetch — it throws on non-JSON responses and requires manual error extraction and URL construction:

import { apiFetch } from '../lib/api';

// Returns { ok: true, data: T } | { ok: false, error: string }
const result = await apiFetch<Settings>('/api/settings');
if (!result.ok) {
  // result.error contains a user-friendly message
  // (including "API URL is not configured" if getApiUrl() returns undefined)
  return { error: result.error, success: false };
}

Form Submission Errors with useActionState

Use React 19's useActionState to manage form submission state (loading, error, success). The action function should catch errors and return state — never throw:

const [state, submitAction, isPending] = useActionState(
  async (_prev: FormState, formData: FormData): Promise<FormState> => {
    const result = await apiFetch<User>('/api/users', {
      method: 'POST',
      body: JSON.stringify(Object.fromEntries(formData)),
    });
    if (!result.ok) {
      return { error: result.error, success: false };
    }
    return { error: null, success: true };
  },
  { error: null, success: false }
);

Displaying Errors Accessibly

Error messages must use role="alert" so screen readers announce them:

{state.error && (
  <p role="alert" className="text-red-500">{state.error}</p>
)}

Error Boundaries

Error Boundaries catch render-time errors in child components and display fallback UI instead of crashing the entire application. React 19 removes duplicate console error logs for caught errors and adds root-level error hooks (onCaughtError, onUncaughtError, onRecoverableError) for finer control over error reporting.

The ErrorBoundary component lives in packages/ui/ and is used at the route level in the root layout (apps/admin/src/routes/__root.tsx).

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 by boundary:', error)}
  resetKeys={[router.state.location.pathname]} // router from useRouter() (TanStack Router)
>
  <MyComponent />
</ErrorBoundary>

Key points:

  • The root layout wraps <Outlet /> with an ErrorBoundary that auto-resets on navigation (via resetKeys)
  • The fallback UI uses role="alert" for screen reader announcements
  • In development mode, error details are shown in a collapsible section
  • Error Boundaries catch render-time, constructor, and class lifecycle method errors, but not event handler, effect, or async errors (use apiFetch / useActionState for those)

Server-Side Error Handling (API)

Error Handler Middleware

All errors are processed by the global error handler in apps/api/src/middleware/error-handler.ts.

Behavior

EnvironmentResponse BodyLogs
DevelopmentFull error + stackFull details
ProductionGeneric message + requestIdFull details (structured)

Response Format

{
  "error": "Internal Server Error",
  "message": "An unexpected error occurred",
  "requestId": "abc-123-def"
}

In development, also includes:

{
  "error": "Error",
  "message": "Specific error message",
  "stack": "Error: ...\n    at ...",
  "requestId": "abc-123-def"
}

Throwing Errors

In Controllers

Return appropriate HTTP status codes:

export async function getById(c: Context) {
  const id = c.req.param('id');
  const item = await contentService.findById(id);

  if (!item) {
    return c.json({ error: 'Not found' }, 404);
  }

  return c.json(item, 200);
}

In Services

Throw errors for business rule violations:

export async function publish(id: string): Promise<ContentResponse> {
  const content = await contentRepository.findById(id);

  if (!content) {
    throw new Error('Content not found');
  }

  if (content.status === 'published') {
    throw new Error('Content is already published');
  }

  // ... proceed with publishing
}

Validation Errors

Zod validation errors are handled automatically by @hono/zod-openapi:

// In route definition
const createRoute = createRoute({
  request: {
    body: {
      content: {
        'application/json': { schema: CreateContentSchema },
      },
    },
  },
  // ...
});

// Invalid request automatically returns 400 with validation details

Error Types

HTTP Status Codes

CodeWhen to Use
400Invalid request (validation failed)
401Not authenticated
403Authenticated but not authorized
404Resource not found
409Conflict (duplicate, invalid state)
500Unexpected server error

Custom Error Classes (Future)

For more structured error handling, consider:

// Not yet implemented, but a pattern to consider
class NotFoundError extends Error {
  status = 404;
  constructor(resource: string, id: string) {
    super(`${resource} with id ${id} not found`);
  }
}

class ConflictError extends Error {
  status = 409;
  constructor(message: string) {
    super(message);
  }
}

Logging Errors

Errors are logged with structured data:

// In error-handler.ts
logger.error(
  {
    requestId: c.get('requestId'),
    path: c.req.path,
    method: c.req.method,
    status: 500,
    error: err.message,
    stack: err.stack,
  },
  'Request failed'
);

See logging.md for more on structured logging.

Testing Error Cases

Always test error paths:

describe('GET /api/content/:id', () => {
  it('returns 404 for non-existent content', async () => {
    mockFindById.mockResolvedValue(null);

    const res = await app.request('/api/content/non-existent-id');

    expect(res.status).toBe(404);
    expect(await res.json()).toEqual({
      error: 'Not found',
    });
  });
});

On this page