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/.warningover the baretoast()so the right icon + color is applied automatically (Sonner is configured withrichColors). - Always include a
descriptionfor.errorwith 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