Every form in the app follows the same split: a Zod schema is the single
source of truth for shape and validation, a plain config object supplies
field metadata (labels, placeholders, options), a Manager component owns
form lifecycle (useForm, resolver, defaults, submit, discard), and a pure
Renderer component lays out fields over React Hook Form's control. No
component ever renders a field it also validates, and no component ever
validates a field it doesn't render.
This exists because forms are the place business logic and presentation
logic are most tempted to fuse — "just check value.length before rendering
the error" starts in a Renderer and, two forms later, every Renderer has its
own slightly different validation dialect that Zod never sees. Once that
happens, the schema stops being trustworthy: some invalid states are caught by
Zod, some by ad-hoc JSX conditionals, and nobody can tell which without
reading every field. Centralizing validation in the schema and rendering in
the Renderer keeps exactly one artifact answerable for "is this value valid"
and exactly one answerable for "how does this look."
The Manager/Renderer split solves a second, narrower problem: testability and
reuse. A Manager wires useForm to a specific submit path (create an alert
rule, edit one, autosave a draft) — that part legitimately differs per call
site. The Renderer never changes based on why the form is open; it only
needs a control object and it lays out the same fields whether the Manager
behind it is creating, editing, or read-only-viewing. That means a Renderer
can be reused across a create Manager and an edit Manager without
duplicating a single <FormField>.
Zod schema src/schemas/alert-rule.schema.ts
│ alertRuleSchema = z.object({ ... })
│ export type AlertRuleFormValues = z.infer<typeof alertRuleSchema>
▼
Field config src/config/alert-rule-form-config.ts
│ AlertRuleFormConfig.fields.threshold = { name, label, placeholder, type, ... }
│ no validation logic — only what the Renderer needs to draw the field
▼
Manager src/modules/alerts/AlertRuleFormManager.tsx
│ useForm<AlertRuleFormValues>({ resolver: zodResolver(alertRuleSchema), defaultValues, mode })
│ owns: submit, discard, defaults (from store — see ./07-hydration-adapters.md)
▼
Renderer src/modules/alerts/AlertRuleFormRenderer.tsx
│ ({ control }) => <FormField control={control} name="threshold" render={...} />
│ pure: no useForm, no submit, no store access — only reads `control`
▼
Rendered <form> Manager wraps Renderer + submit/discard buttons
Four artifacts, four responsibilities, one direction of dependency (Renderer never imports the Manager; the schema and config never import either):
- Schema (
src/schemas/*.schema.ts) — the shape of the data and every rule it must satisfy. Types are inferred from it (z.infer<...>), never hand-written in parallel. If a value can be invalid, the schema is where that gets encoded, not a conditional in the Renderer. - Config (
src/config/*-form-config.ts) — a plain object keyed by field name, holding only what the Renderer needs to draw a field:label,placeholder,type,optionsfor selects,requiredmessage text for UI display. The config never contains a validation rule the schema doesn't already enforce — it describes presentation, the schema decides validity. - Manager — the only component that calls
useForm. It builds the resolver from the schema, suppliesdefaultValues(sourced from the store, never inlined — see./07-hydration-adapters.md), wraps children in<FormProvider>or passescontroldown explicitly, and ownshandleSubmit, discard/reset, and read-only gating. - Renderer — a function of
control(and static config) to JSX. It renders<FormField>/<FormItem>/<FormControl>/<FormMessage>for each field and nothing else: nouseForm, no submit handler, no store read, no side effects. Given the samecontroland config, it always renders the same tree.
Worked example: an alert rule form — a threshold, a rolling time window, and a severity — used identically for both "create rule" and "edit rule."
-
Schema first. Every constraint the form must enforce lives here, not in the Renderer.
// src/schemas/alert-rule.schema.ts import { z } from 'zod' export const alertRuleSchema = z.object({ name: z.string().min(1, 'Name is required'), metric: z.enum(['error_rate', 'latency_p99', 'token_cost']), threshold: z.coerce.number().positive('Threshold must be greater than 0'), window: z.enum(['5m', '15m', '1h', '24h']), severity: z.enum(['info', 'warning', 'critical']), }) export type AlertRuleFormValues = z.infer<typeof alertRuleSchema>
-
Config supplies field metadata. Same field names as the schema, but the config only ever describes how to draw the field — labels, options, placeholders — never a validity rule.
// src/config/alert-rule-form-config.ts export const AlertRuleFormConfig = { fields: { name: { name: 'name', label: 'Rule name', placeholder: 'e.g. High error rate on checkout-service', type: 'text', }, metric: { name: 'metric', label: 'Metric', type: 'select', options: [ { label: 'Error rate', value: 'error_rate' }, { label: 'Latency (p99)', value: 'latency_p99' }, { label: 'Token cost', value: 'token_cost' }, ], }, threshold: { name: 'threshold', label: 'Threshold', placeholder: 'e.g. 0.05', type: 'text', }, window: { name: 'window', label: 'Rolling window', type: 'select', options: [ { label: '5 minutes', value: '5m' }, { label: '15 minutes', value: '15m' }, { label: '1 hour', value: '1h' }, { label: '24 hours', value: '24h' }, ], }, severity: { name: 'severity', label: 'Severity', type: 'select', options: [ { label: 'Info', value: 'info' }, { label: 'Warning', value: 'warning' }, { label: 'Critical', value: 'critical' }, ], }, }, }
-
Manager owns
useFormand submit. Defaults come from the store for edit mode, or schema-level fallbacks for create mode — never hardcoded per-Manager duplicate literals (see./07-hydration-adapters.mdfor how a loaded rule becomesdefaultValues).// src/modules/alerts/AlertRuleFormManager.tsx 'use client' import { useForm, FormProvider } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { Button } from '@/src/components/ui/button' import { AlertRuleFormRenderer } from './AlertRuleFormRenderer' import { alertRuleSchema, type AlertRuleFormValues } from '@/src/schemas/alert-rule.schema' interface AlertRuleFormManagerProps { defaultValues: AlertRuleFormValues readOnly?: boolean onSave: (values: AlertRuleFormValues) => void | Promise<void> onDiscard?: () => void } export function AlertRuleFormManager({ defaultValues, readOnly, onSave, onDiscard, }: AlertRuleFormManagerProps) { const form = useForm<AlertRuleFormValues>({ resolver: zodResolver(alertRuleSchema), defaultValues, mode: 'onBlur', }) const handleSubmit = form.handleSubmit(async (values) => { await onSave(values) }) return ( <FormProvider {...form}> <form onSubmit={handleSubmit} className="space-y-6"> <AlertRuleFormRenderer control={form.control} readOnly={readOnly} /> <div className="flex justify-end gap-2"> <Button variant="outline" type="button" onClick={onDiscard}> Cancel </Button> <Button variant="primary" type="submit" disabled={readOnly}> Save rule </Button> </div> </form> </FormProvider> ) }
-
Renderer is pure over
control. It never callsuseForm, never reads the store, never knows whether it's inside a create flow or an edit flow — that distinction lives entirely in the Manager above it.// src/modules/alerts/AlertRuleFormRenderer.tsx 'use client' import type { Control } from 'react-hook-form' import { FormField, FormItem, FormLabel, FormControl, FormMessage, } from '@/src/components/ui/form' import { Input } from '@/src/components/ui/input' import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/src/components/ui/select' import { AlertRuleFormConfig } from '@/src/config/alert-rule-form-config' import type { AlertRuleFormValues } from '@/src/schemas/alert-rule.schema' interface AlertRuleFormRendererProps { control: Control<AlertRuleFormValues> readOnly?: boolean } export function AlertRuleFormRenderer({ control, readOnly }: AlertRuleFormRendererProps) { const { name, metric, threshold, window, severity } = AlertRuleFormConfig.fields return ( <div className="space-y-4"> <FormField control={control} name={name.name as 'name'} render={({ field }) => ( <FormItem> <FormLabel>{name.label}</FormLabel> <FormControl> <Input {...field} placeholder={name.placeholder} disabled={readOnly} /> </FormControl> <FormMessage /> </FormItem> )} /> <FormField control={control} name={threshold.name as 'threshold'} render={({ field }) => ( <FormItem> <FormLabel>{threshold.label}</FormLabel> <FormControl> <Input {...field} placeholder={threshold.placeholder} disabled={readOnly} /> </FormControl> <FormMessage /> </FormItem> )} /> <FormField control={control} name={window.name as 'window'} render={({ field }) => ( <FormItem> <FormLabel>{window.label}</FormLabel> <Select onValueChange={field.onChange} value={field.value} disabled={readOnly}> <FormControl> <SelectTrigger> <SelectValue /> </SelectTrigger> </FormControl> <SelectContent> {window.options.map((opt) => ( <SelectItem key={opt.value} value={opt.value}> {opt.label} </SelectItem> ))} </SelectContent> </Select> <FormMessage /> </FormItem> )} /> <FormField control={control} name={severity.name as 'severity'} render={({ field }) => ( <FormItem> <FormLabel>{severity.label}</FormLabel> <Select onValueChange={field.onChange} value={field.value} disabled={readOnly}> <FormControl> <SelectTrigger> <SelectValue /> </SelectTrigger> </FormControl> <SelectContent> {severity.options.map((opt) => ( <SelectItem key={opt.value} value={opt.value}> {opt.label} </SelectItem> ))} </SelectContent> </Select> <FormMessage /> </FormItem> )} /> </div> ) }
Note
metricfrom the config is omitted from this Renderer intentionally in some flows (e.g. when the rule is scoped to a metric already chosen upstream) — that is exactly the kind of per-call-site variation a pure Renderer supports: the same config and schema, a slightly different subset of<FormField>s per Renderer, with the schema still validating the full shape at submit time. -
Wire create and edit through the same pair. A "create rule" call site passes schema-level defaults (
{ name: '', metric: 'error_rate', threshold: 0, window: '15m', severity: 'warning' }); an "edit rule" call site passesdefaultValueshydrated from the store's loaded rule. Both go through the identicalAlertRuleFormManager+AlertRuleFormRenderer— the only difference is whatdefaultValuesandonSavethe Manager is handed.
Multi-step wizard variant. When a form is too large for one screen (for
example, an onboarding flow that collects connection details, then alert
defaults, then notification targets), don't inflate a single Renderer with
every field — split by step, and keep the same schema/config/Manager/Renderer
shape per step: each step gets its own schema slice (or the same schema
validated against a subset via .pick()), its own config, and its own
Renderer, with one Manager per step handling that step's useForm and
"Continue" action. A wizard shell component owns the step sequence — which
step is active, which steps are complete, whether "Continue" is enabled —
and reads per-step validity through a shared hook (analogous to
useStepValidationStatus in the lineage) rather than each step Manager
knowing about its siblings. The wizard shell never reaches into a step's
form state directly; it only knows "is step N valid" and "advance to step
N+1," exactly the same read-only boundary a slice orchestrator keeps with
slices (see ./05-zustand-slice-store.md).
- Never manage error display manually when
<FormMessage>covers it. A<FormField>render prop that puts{fieldState.error?.message}in a raw<span>instead of<FormMessage>loses the shared error styling and any future change to how errors are displayed app-wide. If<FormMessage>can render it, don't hand-roll it. - The Renderer stays pure — no
useForm, no store reads, no submit logic. The moment a Renderer importsuseStoreor callsform.handleSubmititself, it can no longer be reused across a create Manager and an edit Manager, which was the entire point of splitting it out. - The config never encodes a validation rule the schema doesn't already
enforce. A
required: 'X is required'string in the config is display text for a message the schema's.min(1, ...)produces — it is not a second, independent check. If a field needs a new constraint, add it to the schema first; the config only needs to change if the label or options change. - Types are inferred from the schema, never hand-written in parallel.
type AlertRuleFormValues = z.infer<typeof alertRuleSchema>is the only type declaration for form values. A hand-written interface that happens to match today will silently drift the next time the schema changes. - Defaults come from the store, not literals inlined in the Manager.
Hardcoding
defaultValues={{ severity: 'warning', ... }}inside a Manager duplicates logic that belongs in the hydration layer the moment there's a second call site (create vs. edit) with different defaults. See./07-hydration-adapters.mdfor how a loaded record becomesdefaultValuesand how form values map back to the store's shape on submit. modeandcriteriaModeonuseFormare a Manager decision, not a Renderer one. Whether validation runsonBlurvsonChange, and whether it surfaces the first error or all errors per field, is form lifecycle — it belongs next to theuseFormcall, not scattered into the Renderer's field-level props.- A read-only view is a prop, not a second component tree. Pass
readOnlydown from the Manager and disable inputs in the Renderer; don't fork a separate "view mode" Renderer that can drift from the editable one.
- glassflow-etl-ui/src/scheme/topics.scheme.ts
- glassflow-etl-ui/src/config/clickhouse-connection-form-config.ts
- glassflow-etl-ui/src/config/topic-selection-form-config.ts
- glassflow-etl-ui/src/modules/resources/PipelineResourcesFormManager.tsx
- glassflow-etl-ui/src/modules/resources/PipelineResourcesFormRenderer.tsx
- glassflow-etl-ui/src/modules/create/PipelineWizard.tsx
- glassflow-etl-ui/.cursor/architecture/FORM_ARCHITECTURE.md