Skip to content

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.

snippet.bashbash
pnpm add react-hook-form zod @hookform/resolvers
app/signup-form.tsxtsx
"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:

snippet.tsxtsx
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.

snippet.bashbash
pnpm add @tanstack/react-form zod
app/invite-form.tsxtsx
"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:

app/contact-form.tsxtsx
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:

snippet.tsxtsx
"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) to FormControl and the inner input picks up the error border + focus ring via CSS.
  • Keep server errors close to inputs. Zod's issues array carries a path; map it back into your form's setError after the server responds.
  • Don't re-wrap for the sake of it. If your form is 1 input + 1 button, skip react-hook-form — plain useState or FormData is simpler and smaller.
  • Checkbox & Radio: both forward ref and accept onChange, so register works unchanged.
Was this page helpful?