Docs /frontend/components-and-forms

Components & Forms Guide

Frontend components architecture and form handling patterns

Frontend Components

Key shared components and patterns in Izri.

DataBoundary

A generic boundary for loading and not-found states.

File: apps/web/app/components/common/data-boundary.tsx

Props:

  • isLoading: boolean
  • data: any | null | undefined
  • loadingMessage?: string
  • notFoundMessage?: string
  • withContainer?: boolean (default true)
  • loadingFallback?: ReactNode
  • notFoundFallback?: ReactNode

Usage:

<DataBoundary
  isLoading={isPending}
  data={project}
  loadingMessage="Loading project..."
  notFoundMessage="Project not found"
>
  <ProjectDetails project={project!} />
</DataBoundary>

LayoutErrorBoundary

File: apps/web/app/components/errors/layout-error-boundary.tsx

Wraps layout children to catch rendering errors and present a consistent UI.

Pattern:

  • Use in layout routes to isolate failures
  • Pair with DataBoundary for route-level data

Sidebar and Page Layout

Files:

  • apps/web/app/components/layout/sidebar.tsx
  • apps/web/app/components/layout/page-layout.tsx

Notes:

  • Sidebar reads organization context to render org-scoped navigation
  • PageLayout provides a consistent container and spacing

Organization Switcher

File: apps/web/app/components/organizations/organization-switcher.tsx

Notes:

  • Uses organization context hooks to list/select organizations
  • Works with organization-scoped routing (/organizations/:slug/...)

UI Primitives (shadcn/ui)

Located in apps/web/app/components/ui/ (button, card, input, select, etc.)

Conventions:

  • Keep variants minimal and consistent
  • Prefer composition over one-off bespoke styles

Toasts (Sonner)

App-wide notifications are surfaced through Sonner. The host is mounted once in apps/web/app/root.tsx as <Toaster richColors position="bottom-right" /> — do not mount additional <Toaster> instances. Trigger toasts from anywhere via the imperative toast API:

import { toast } from 'sonner'

toast.success(`Scope config saved (v${saved.version})`)
toast.error('Could not save scope config', { description: err.message })
toast.info('Heads up')
toast.warning('Slow path detected')

Conventions:

  • Use toasts for transient action feedback — save success / failure, copy- to-clipboard, "queued for processing". Use inline alerts (or the <DataBoundary> boundary) for state that's load-bearing on the current view (e.g. "couldn't load delta report — Try again").
  • Prefer .success / .error / .info / .warning over the bare toast() so the right icon + color is applied automatically (Sonner is configured with richColors).
  • Always include a description for .error with the message from the failed mutation. Worked example: apps/web/app/components/scope/scope-config-editor.tsx.

Tabs

Tabs is the app's primitive for organising secondary navigation inside a single page (e.g. "Modules / Categories / Sensitive" on the scope dashboard, or "Signals / Visual diff / AI insights / Activity" on the delta report).

import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'

<Tabs defaultValue="signals" className="space-y-4">
  <TabsList>
    <TabsTrigger value="signals">Signals</TabsTrigger>
    {hasVisual ? <TabsTrigger value="visual">Visual diff</TabsTrigger> : null}
  </TabsList>
  <TabsContent value="signals">…</TabsContent>
  {hasVisual ? <TabsContent value="visual">…</TabsContent> : null}
</Tabs>

Conventions:

  • Gate the trigger AND the content together. If a tab's panel is conditionally rendered, the trigger must be too — Radix Tabs renders nothing if the active panel is missing.
  • Default value should always have data. Pick a tab that's guaranteed to exist regardless of which optional tabs are present.
  • Don't use Tabs for primary navigation — that's the sidebar / route hierarchy. Tabs are for slicing a single screen, not for cross-screen navigation.
  • Worked examples: apps/web/app/components/scope/scope-config-editor.tsx, apps/web/app/components/delta-reports/delta-report-view.tsx.

Forms Guide

Form handling with React Hook Form and Zod validation.

Overview

Izri uses React Hook Form for form state management and Zod for validation.

Setup

Install Dependencies:

cd apps/web
pnpm add react-hook-form @hookform/resolvers zod

Basic Form

Simple Form Example:

import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'

const schema = z.object({
  name: z.string().min(1, 'Name is required'),
  email: z.string().email('Invalid email'),
})

type FormData = z.infer<typeof schema>

export function ContactForm() {
  const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
    resolver: zodResolver(schema),
  })

  const onSubmit = (data: FormData) => {
    console.log(data)
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <input {...register('name')} placeholder="Name" />
        {errors.name && <p>{errors.name.message}</p>}
      </div>

      <div>
        <input {...register('email')} placeholder="Email" />
        {errors.email && <p>{errors.email.message}</p>}
      </div>

      <button type="submit">Submit</button>
    </form>
  )
}

Form with tRPC

Create Project Form:

import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { useTRPC } from '@/lib/trpc'
import { useNavigate } from 'react-router'

const createProjectSchema = z.object({
  name: z.string().min(1, 'Name is required').max(255),
  repository: z.string().url('Must be a valid URL'),
  language: z.enum(['typescript', 'javascript', 'python']),
})

type CreateProjectFormData = z.infer<typeof createProjectSchema>

export function CreateProjectForm() {
  const navigate = useNavigate()
  const utils = useTRPC()

  const { register, handleSubmit, formState: { errors } } = useForm<CreateProjectFormData>({
    resolver: zodResolver(createProjectSchema),
  })

  const createProject = utils.projects.create.useMutation({
    onSuccess: (data) => {
      utils.projects.list.invalidate()
      navigate(`/projects/${data.id}`)
    },
  })

  const onSubmit = (data: CreateProjectFormData) => {
    createProject.mutate(data)
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
      <div>
        <label htmlFor="name">Project Name</label>
        <input
          id="name"
          {...register('name')}
          className="w-full border rounded px-3 py-2"
        />
        {errors.name && <p className="text-red-500">{errors.name.message}</p>}
      </div>

      <div>
        <label htmlFor="repository">Repository URL</label>
        <input
          id="repository"
          {...register('repository')}
          className="w-full border rounded px-3 py-2"
        />
        {errors.repository && <p className="text-red-500">{errors.repository.message}</p>}
      </div>

      <div>
        <label htmlFor="language">Language</label>
        <select id="language" {...register('language')} className="w-full border rounded px-3 py-2">
          <option value="typescript">TypeScript</option>
          <option value="javascript">JavaScript</option>
          <option value="python">Python</option>
        </select>
        {errors.language && <p className="text-red-500">{errors.language.message}</p>}
      </div>

      <button
        type="submit"
        disabled={createProject.isPending}
        className="px-4 py-2 bg-blue-600 text-white rounded disabled:opacity-50"
      >
        {createProject.isPending ? 'Creating...' : 'Create Project'}
      </button>
    </form>
  )
}

Reusable Form Components

// components/ui/form.tsx
import { type InputHTMLAttributes } from 'react'
import { type FieldError } from 'react-hook-form'

interface FormFieldProps extends InputHTMLAttributes<HTMLInputElement> {
  label: string
  error?: FieldError
}

export function FormField({ label, error, ...props }: FormFieldProps) {
  return (
    <div className="space-y-1">
      <label className="block text-sm font-medium">
        {label}
      </label>
      <input
        className="w-full border rounded px-3 py-2 focus:outline-none focus:ring-2"
        {...props}
      />
      {error && (
        <p className="text-sm text-red-500">{error.message}</p>
      )}
    </div>
  )
}

Usage:

export function LoginForm() {
  const { register, formState: { errors } } = useForm()

  return (
    <form>
      <FormField
        label="Email"
        type="email"
        {...register('email')}
        error={errors.email}
      />
      <FormField
        label="Password"
        type="password"
        {...register('password')}
        error={errors.password}
      />
    </form>
  )
}

Validation

Custom Validation:

const schema = z.object({
  password: z.string().min(8, 'At least 8 characters'),
  confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
  message: 'Passwords must match',
  path: ['confirmPassword'],
})

Async Validation:

const schema = z.object({
  slug: z.string().refine(async (slug) => {
    const exists = await checkSlugAvailable(slug)
    return !exists
  }, 'Slug already taken'),
})

Default Values

const { register } = useForm({
  defaultValues: {
    name: project.name,
    repository: project.repository,
  },
})

Related: Setup & Configuration | Testing | README

Reading this with an agent? /docs/frontend/components-and-forms.md serves the raw markdown.

All docs