Skip to main content

Chapter 22: Forms with TanStack Form + Effect Schema

Forms are the primary way users create and edit data. This chapter covers TanStack Form for state management and Effect Schema for validation — a combination that provides type-safe, validated forms with minimal boilerplate.

TanStack Form Basics

import { useForm } from "@tanstack/react-form";

function CreateProjectForm({ onSuccess }: { onSuccess: (project: Project) => void }) {
const createProject = useCreateProject();

const form = useForm({
defaultValues: {
name: "",
description: "",
priority: "medium" as const,
},
onSubmit: async ({ value }) => {
await createProject.mutateAsync(value);
onSuccess(value);
},
});

return (
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}
>
<div className="space-y-4">
<form.Field name="name">
{(field) => (
<div className="space-y-2">
<Label htmlFor="name">Project Name</Label>
<Input
id="name"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
placeholder="Enter project name"
/>
{field.state.meta.errors.length > 0 && (
<p className="text-sm text-destructive">
{field.state.meta.errors[0]}
</p>
)}
</div>
)}
</form.Field>

<form.Field name="description">
{(field) => (
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
placeholder="Describe your project"
rows={4}
/>
</div>
)}
</form.Field>

<form.Field name="priority">
{(field) => (
<div className="space-y-2">
<Label>Priority</Label>
<Select value={field.state.value} onValueChange={field.handleChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="low">Low</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="high">High</SelectItem>
</SelectContent>
</Select>
</div>
)}
</form.Field>

<div className="flex justify-end gap-3 pt-4">
<Button type="button" variant="outline" onClick={() => form.reset()}>
Reset
</Button>
<form.Subscribe selector={(state) => [state.canSubmit, state.isSubmitting]}>
{([canSubmit, isSubmitting]) => (
<Button type="submit" disabled={!canSubmit || isSubmitting}>
{isSubmitting ? "Creating..." : "Create Project"}
</Button>
)}
</form.Subscribe>
</div>
</div>
</form>
);
}

Integrating Effect Schema for Validation

Field-Level Validation

import { Schema, Either } from "effect";

// Create a validator function from an Effect Schema
function schemaValidator<A>(schema: Schema.Schema<A>) {
return (value: unknown): string | undefined => {
const result = Schema.decodeUnknownEither(schema)(value);
if (Either.isLeft(result)) {
// Extract first error message from ParseError
const error = result.left;
return formatParseError(error);
}
return undefined;
};
}

// Reusable schema validators
const nameSchema = Schema.String.pipe(
Schema.minLength(1, { message: () => "Name is required" }),
Schema.maxLength(100, { message: () => "Name must be under 100 characters" }),
Schema.trimmed({ message: () => "Name cannot start or end with spaces" }),
);

function CreateProjectForm() {
const form = useForm({
defaultValues: { name: "", description: "", priority: "medium" as const },
onSubmit: async ({ value }) => { /* ... */ },
});

return (
<form onSubmit={(e) => { e.preventDefault(); form.handleSubmit(); }}>
<form.Field
name="name"
validators={{
onChange: ({ value }) => schemaValidator(nameSchema)(value),
onBlur: ({ value }) => schemaValidator(nameSchema)(value),
}}
>
{(field) => (
<FormField
label="Project Name"
error={field.state.meta.errors[0]}
>
<Input
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
/>
</FormField>
)}
</form.Field>
{/* ... more fields */}
</form>
);
}

Async Validation

Check server-side constraints (e.g., unique name):

<form.Field
name="name"
validators={{
// Synchronous validation on change
onChange: ({ value }) => schemaValidator(nameSchema)(value),

// Async validation on blur (debounced by TanStack Form)
onBlurAsync: async ({ value }) => {
if (!value) return undefined;

const exists = await AppRuntime.runPromise(
Effect.gen(function* () {
const repo = yield* ProjectRepository;
const existing = yield* repo.findByName(value, organizationId);
return existing !== null;
}).pipe(Effect.catchAll(() => Effect.succeed(false)))
);

return exists ? `"${value}" is already taken` : undefined;
},
onBlurAsyncDebounceMs: 500,
}}
>
{(field) => (
<FormField
label="Project Name"
error={field.state.meta.errors[0]}
>
<Input
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
/>
{field.state.meta.isValidating && (
<p className="text-xs text-muted-foreground">Checking availability...</p>
)}
</FormField>
)}
</form.Field>

Complex Forms

Dynamic Field Arrays

function TaskForm() {
const form = useForm({
defaultValues: {
title: "",
subtasks: [{ title: "", completed: false }],
},
onSubmit: async ({ value }) => { /* ... */ },
});

return (
<form onSubmit={(e) => { e.preventDefault(); form.handleSubmit(); }}>
<form.Field name="title">
{(field) => (
<FormField label="Task Title">
<Input
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
</FormField>
)}
</form.Field>

<div className="space-y-3">
<Label>Subtasks</Label>
<form.Field name="subtasks" mode="array">
{(field) => (
<>
{field.state.value.map((_, index) => (
<div key={index} className="flex gap-2">
<form.Field name={`subtasks[${index}].title`}>
{(subField) => (
<Input
value={subField.state.value}
onChange={(e) => subField.handleChange(e.target.value)}
placeholder={`Subtask ${index + 1}`}
/>
)}
</form.Field>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => field.removeValue(index)}
>
<TrashIcon className="h-4 w-4" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => field.pushValue({ title: "", completed: false })}
>
Add Subtask
</Button>
</>
)}
</form.Field>
</div>

<Button type="submit">Create Task</Button>
</form>
);
}

Reusable Form Field Component

Create a standardized form field wrapper:

// shared/components/forms/form-field.tsx
interface FormFieldProps {
label: string;
description?: string;
error?: string;
required?: boolean;
children: React.ReactNode;
}

export function FormField({
label,
description,
error,
required,
children,
}: FormFieldProps) {
const id = useId();

return (
<div className="space-y-2">
<Label htmlFor={id} className={cn(error && "text-destructive")}>
{label}
{required && <span className="text-destructive ml-1">*</span>}
</Label>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
<div>{children}</div>
{error && (
<p className="text-sm text-destructive" role="alert">
{error}
</p>
)}
</div>
);
}

Summary

  • TanStack Form provides type-safe form state management
  • Effect Schema integration for field-level validation
  • Async validation for server-side checks (unique names, etc.)
  • Dynamic arrays for repeating field groups
  • Form submission with loading states and error handling
  • Reusable FormField component for consistent form layout