Trovella Wiki

Form Handling

How forms are built with controlled inputs and validated with Zod schemas from @repo/validators.

Forms in Trovella use controlled React inputs paired with Zod validation schemas from @repo/validators. React Hook Form is accepted in the tech stack but has not been installed -- current forms are simple enough that direct useState control is sufficient.

Current Form Patterns

Simple Controlled Inputs

Most forms use useState for each field and pass values through onChange handlers. The PAT creation dialog is a representative example:

function CreateTokenDialog() {
  const [name, setName] = useState("");
  const [expiresInDays, setExpiresInDays] = useState("");

  function handleCreate() {
    if (!name.trim()) return;
    const days = expiresInDays ? parseInt(expiresInDays, 10) : undefined;
    createMutation.mutate({ name: name.trim(), expiresInDays: days });
  }

  return (
    <Input
      value={name}
      onChange={(e) => onNameChange(e.target.value)}
      onKeyDown={(e) => { if (e.key === "Enter") handleCreate(); }}
    />
  );
}

Key characteristics:

  • State is local to the dialog component. State resets when the dialog closes.
  • Validation is minimal on the client -- a trim() check before submit.
  • The tRPC mutation handles server-side validation through the Zod schema in the procedure definition.

Search-Style Forms

The search debugger uses a two-state pattern -- one for the input field, one for the submitted value -- to decouple typing from query execution:

const [query, setQuery] = useState("");
const [submittedQuery, setSubmittedQuery] = useState("");

function handleSearch(e: React.SyntheticEvent) {
  e.preventDefault();
  if (query.trim()) setSubmittedQuery(query.trim());
}

The submittedQuery drives the tRPC query with enabled: submittedQuery.length > 0, so the network request only fires on explicit submit. See Server State -- Conditional Query.

Settings-Style Forms (Many Fields)

The AI playground uses usePlaygroundSettings -- a custom hook that manages 25+ fields via individual useState calls and returns them as a memoized object. See Client State -- Feature-Scoped Custom Hooks for the full pattern.

This approach works for settings panels where each field is independent and there is no cross-field validation. It would not scale well for forms with complex interdependencies -- that is where React Hook Form will be introduced.

The @repo/validators Package

Zod schemas shared between client and server live in packages/validators/. These are used as tRPC procedure input schemas and will eventually be used as React Hook Form resolvers when that library is added.

Current Schemas

common.ts -- reusable building blocks:

export const idSchema = z.string().min(1);

export const paginationSchema = z.object({
  limit: z.number().int().min(1).max(100).default(20),
  cursor: z.string().optional(),
});

pat.ts -- personal access token operations:

export const createPatSchema = z.object({
  name: z.string().min(1).max(100),
  expiresInDays: z.number().int().min(1).max(365).optional(),
});

export const revokePatSchema = z.object({
  id: z.string().min(1),
});

organization.ts -- organization updates:

export const updateOrganizationSchema = z.object({
  name: z.string().min(1).max(100).optional(),
  slug: z
    .string()
    .min(1)
    .max(48)
    .regex(/^[a-z0-9-]+$/, "Slug must be lowercase alphanumeric with hyphens")
    .optional(),
});

member.ts -- membership management:

export const updateMemberRoleSchema = z.object({
  memberId: idSchema,
  role: z.enum(["admin", "member"]),
});

export const removeMemberSchema = z.object({
  memberId: idSchema,
});

How Validators Flow

@repo/validators (Zod schemas)
  |
  +-- @repo/api (tRPC procedure .input() validation)
  |     Server-side: rejects invalid input before the handler runs
  |
  +-- @repo/web (planned: React Hook Form zodResolver)
        Client-side: validates before the mutation fires

Today, validation runs server-side only. The tRPC procedure rejects invalid input and returns a typed error. The client displays this error via toast or inline message. This is acceptable for MVP admin forms but will need client-side validation for user-facing forms.

When to Add React Hook Form

React Hook Form will be added when any of these triggers are hit:

  1. Complex validation UX -- forms where users need inline field-level error messages before submission (not just a server rejection toast).
  2. Multi-step forms -- wizards or stepped workflows where form state must persist across steps.
  3. Cross-field validation -- rules like "end date must be after start date" that span multiple fields.
  4. Performance-sensitive forms -- forms with many fields where re-rendering every field on every keystroke becomes a measurable performance issue.

Planned React Hook Form Conventions

When React Hook Form is introduced:

  • Use zodResolver from @hookform/resolvers/zod with schemas from @repo/validators
  • Pair with shadcn/ui <Form> components for consistent field layout and error display
  • Keep the schema as the single source of truth -- do not duplicate validation logic
  • Use useFormContext sparingly; prefer passing the form object explicitly

Conventions

  1. All shared validation schemas go in @repo/validators, not in component files or route handlers. Component-specific validation (e.g., !name.trim()) stays local.
  2. Server-side validation is mandatory via tRPC procedure input schemas. Client-side validation is optional convenience.
  3. Forms reset on dialog close. Use a timeout to let the close animation finish before clearing state:
    function handleClose() {
      setOpen(false);
      setTimeout(() => {
        setName("");
        createMutation.reset();
      }, 200);
    }
  4. Use the two-state pattern for search inputs to decouple typing from query execution.
  5. Use onKeyDown for keyboard shortcuts -- Enter to submit, Ctrl/Cmd+Enter for multiline inputs.

On this page