Forms
Sisyphos UI stays neutral on form state. Drop the inputs into any form library — here's how we do it with react-hook-form + Zod, TanStack Form, and plain FormData.
Philosophy
The library ships controlled + uncontrolled inputs and a <FormControl> wrapper for labels, descriptions, and error text. It deliberately does not bring its own form state manager — you bring react-hook-form, TanStack Form, Formik, or just useState, and the inputs plug in.
Every input forwards ref, accepts the usual name, value,onChange, onBlur, and surfaces error/errorMessage props so the integration is a one-liner.
react-hook-form + Zod
The most common combination. react-hook-form handles state and validation calls; zod + @hookform/resolvers handle the schema.
pnpm add react-hook-form zod @hookform/resolvers"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button, Input, FormControl, toast } from "@sisyphos-ui/ui";
const schema = z.object({
email: z.string().email("Enter a valid email"),
password: z.string().min(8, "At least 8 characters"),
});
type SignupValues = z.infer<typeof schema>;
export function SignupForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<SignupValues>({
resolver: zodResolver(schema),
defaultValues: { email: "", password: "" },
});
return (
<form
onSubmit={handleSubmit(async (values) => {
await api.signup(values);
toast.success("Account created");
})}
className="space-y-4"
>
<FormControl label="Email" error={errors.email?.message}>
<Input type="email" autoComplete="email" {...register("email")} />
</FormControl>
<FormControl
label="Password"
description="At least 8 characters"
error={errors.password?.message}
>
<Input type="password" autoComplete="new-password" {...register("password")} />
</FormControl>
<Button type="submit" loading={isSubmitting}>
Create account
</Button>
</form>
);
}Async submit with toast.promise
Pair the form with toast.promise so the user sees a loading → success/error transition without writing three separate toast calls:
const onSubmit = handleSubmit(async (values) => {
await toast.promise(api.signup(values), {
loading: "Creating account…",
success: (user) => `Welcome, ${user.name}`,
error: (err) => (err instanceof Error ? err.message : "Signup failed"),
});
});TanStack Form
TanStack Form gives you a controlled, type-safe form API with first-class async validation. Pair it with Zod for schema-driven validation.
pnpm add @tanstack/react-form zod"use client";
import { useForm } from "@tanstack/react-form";
import { z } from "zod";
import { Button, Input, FormControl } from "@sisyphos-ui/ui";
const emailSchema = z.string().email("Enter a valid email");
export function InviteForm() {
const form = useForm({
defaultValues: { email: "" },
onSubmit: async ({ value }) => {
await api.invite(value.email);
},
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
className="space-y-4"
>
<form.Field
name="email"
validators={{
onBlur: ({ value }) => {
const r = emailSchema.safeParse(value);
return r.success ? undefined : r.error.issues[0]?.message;
},
}}
>
{(field) => (
<FormControl
label="Teammate email"
error={field.state.meta.errors?.[0] as string | undefined}
>
<Input
type="email"
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
</FormControl>
)}
</form.Field>
<Button type="submit" loading={form.state.isSubmitting}>
Send invite
</Button>
</form>
);
}Plain FormData
For server-action-first apps (React 19, Next.js), the simplest path is to skip client-side state entirely and let the browser submit FormData:
import { Button, Input, Textarea, FormControl } from "@sisyphos-ui/ui";
async function sendMessage(formData: FormData) {
"use server";
const subject = String(formData.get("subject") ?? "");
const body = String(formData.get("body") ?? "");
await mail.send({ subject, body });
}
export function ContactForm() {
return (
<form action={sendMessage} className="space-y-4">
<FormControl label="Subject">
<Input name="subject" required />
</FormControl>
<FormControl label="Message">
<Textarea name="body" rows={6} required />
</FormControl>
<Button type="submit">Send</Button>
</form>
);
}Use the useFormStatus hook to flip the button into its loading state without writing any client state:
"use client";
import { useFormStatus } from "react-dom";
import { Button } from "@sisyphos-ui/ui";
export function SubmitButton() {
const { pending } = useFormStatus();
return (
<Button type="submit" loading={pending}>
Send
</Button>
);
}Tips
- Error styling is automatic. Pass
error(a string or boolean) toFormControland the inner input picks up the error border + focus ring via CSS. - Keep server errors close to inputs. Zod's
issuesarray carries apath; map it back into your form'ssetErrorafter the server responds. - Don't re-wrap for the sake of it. If your form is 1 input + 1 button, skip
react-hook-form— plainuseStateor FormData is simpler and smaller. - Checkbox & Radio: both forward
refand acceptonChange, soregisterworks unchanged.