edbnedbn/uiBeta
Pricing
Sign in
Explorer
/
  • Get started
    • Overview
    • Submitting forms
  • Foundation
    • Form
    • Fields
    • OTP Field
    • Date Field
    • File Upload
    • Address Fields
    • Wizard
  • Authentication
    • Sign In
    • Sign Up
    • Forgot Password
    • Reset Password
    • Verify Code
  • Account
    • Profile
    • Notification Settings
    • Delete Account
  • Team
    • Invite Members
    • Create Project
  • Onboarding
    • Onboarding
  • Commerce
    • Checkout
  • Support
    • Contact
    • Support Ticket

Submitting forms

Every form and block takes one onSubmit function. It receives the validated values and returns what happened, and the form does the rest: pending state, errors beside their fields, and focus on the first problem. It works the same with a server action, a fetch to your API, or any other client.

With a server action

Validate on the server with validateForm and the block’s own schema. When it fails, return it as it is: its errors are already on the paths the form uses.

Each block keeps its schema in a file of its own, such as sign-up-schema.ts, without "use client". Import it from there: a schema imported from the form’s file reaches the server as a client reference, and validating with it fails.

actions.ts
21 lines
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// app/sign-up/actions.ts
"use server";

import { signUpSchema } from "@/components/forms/blocks/sign-up-schema";
import type { FormResult } from "@/components/forms/core/form-result";
import { validateForm } from "@/components/forms/core/server";

export async function signUp(input: unknown): Promise<FormResult> {
  // The same schema the form uses, so the errors land on the same fields.
  const parsed = await validateForm(signUpSchema, input);
  if (!parsed.ok) return parsed;

  if (await db.user.exists(parsed.data.email)) {
    return {
      ok: false,
      fieldErrors: { email: "That email already has an account." },
    };
  }
  await db.user.create(parsed.data);
  return { ok: true };
}
page.tsx
7 lines
1
2
3
4
5
6
7
// app/sign-up/page.tsx
import { SignUpForm } from "@/components/forms/blocks/sign-up-form";
import { signUp } from "./actions";

export default function SignUpPage() {
  return <SignUpForm onSubmit={signUp} />;
}

What onSubmit returns

Nothing, or { ok: true }
Success. onSuccess runs with the result, so you can redirect, reset, or show a message.
{ ok: true, data }
Success with something to hand back, such as the new project's id for a redirect.
{ ok: false, fieldErrors }
Errors shown beside their fields, keyed by path: "email", "shipping.postalCode", "invites.2.email". Focus moves to the first one.
{ ok: false, formError }
One message above the form, for problems that belong to no field: a refused sign-in, a rate limit, an expired link.
A thrown error
A dropped connection or a crash: the form says something went wrong and onError receives the error for your reporting.

A field error for a path that isn’t on screen, such as a field on another step of a wizard, is shown with the form error rather than dropped.

Your own forms

The blocks are built from the same parts you can use directly: useZodForm, Form, the field components, and FormSubmit. Give the schema its own file, as the blocks do, so your action can validate with it.

newsletter-schema.ts
6 lines
1
2
3
4
5
6
// No "use client", so the action can import it too.
import { z } from "zod";

export const newsletterSchema = z.object({
  email: z.email("Enter an email address like name@company.com."),
});
newsletter-form.tsx
18 lines
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
"use client";

import { Form, FormAlert, useZodForm } from "@/components/forms/core/form";
import { FormSubmit } from "@/components/forms/core/form-submit";
import { EmailField } from "@/components/forms/fields/text-field";
import { subscribe } from "./actions";
import { newsletterSchema } from "./newsletter-schema";

export function NewsletterForm() {
  const form = useZodForm(newsletterSchema, { defaultValues: { email: "" } });
  return (
    <Form form={form} onSubmit={subscribe} onSuccess={() => form.reset()}>
      <FormAlert />
      <EmailField control={form.control} name="email" label="Email" />
      <FormSubmit pendingLabel="Subscribing…">Subscribe</FormSubmit>
    </Form>
  );
}

On the server

  • Validate every submit on the server with the same schema. Client checks, live availability checks included, are a convenience that anyone can skip.
  • Answer a wrong email or password with one formError that doesn't say which was wrong, and a password reset request with success whether or not the email has an account. Anything else tells strangers who your users are.
  • Put rate limits and lockouts in formError, with when to try again.
  • Check the contact form's honeypot (website) on the server. A filled one is a bot: answer with success and drop it.
  • Collect card details with your payment provider's hosted fields in the checkout's payment slot, never with your own inputs.
  • Send dates from the date field with toDateString(), not toISOString(), so the day doesn't move across time zones.