# Forms and Validation

`@mantine/form` + Zod via `zodResolver`. Schemas live in `src/features/<x>/schemas/` and are shared between the form, the API client, and any client-side validation messaging.

## Install

Already covered in the parent [SKILL.md](../SKILL.md#3-install-dependencies). Requires `@mantine/form` and the resolver:

```bash
bun add @mantine/form @mantine/form-zod-resolver zod
```

## Shared Schema

Place Zod schemas in the feature's `schemas/` folder. The form uses them, the API client uses them to parse responses, and the API hook reuses them for client-side validation hints.

```ts
// src/features/users/schemas/user.schema.ts:1
import { z } from "zod";

export const nipSchema = z
  .string()
  .min(8, "NIP must be at least 8 characters")
  .max(20, "NIP must be at most 20 characters")
  .regex(/^[0-9]+$/, "NIP must contain digits only");

export const userFormSchema = z.object({
  nip: nipSchema,
  name: z.string().min(2, "Name is required"),
  email: z.string().email("Invalid email"),
  isActive: z.boolean().default(true),
});

export type UserFormValues = z.infer<typeof userFormSchema>;
```

## Basic Form

```tsx
// src/features/users/components/UserForm.tsx:1
"use client";

import { Button, Checkbox, Group, Stack, TextInput } from "@mantine/core";
import { useForm, zodResolver } from "@mantine/form";
import { z } from "zod";
import { userFormSchema, type UserFormValues } from "../schemas/user.schema";

type Props = {
  initialValues?: Partial<UserFormValues>;
  onSubmit: (values: UserFormValues) => Promise<void>;
  submitLabel?: string;
};

export function UserForm({ initialValues, onSubmit, submitLabel = "Save" }: Props) {
  const form = useForm<UserFormValues>({
    mode: "controlled",
    initialValues: {
      nip: "",
      name: "",
      email: "",
      isActive: true,
      ...initialValues,
    },
    validate: zodResolver(userFormSchema),
  });

  return (
    <form onSubmit={form.onSubmit(async (values) => {
      try {
        await onSubmit(values);
      } catch (err) {
        if (err instanceof Error) {
          form.setErrors({ name: err.message });
        }
      }
    })}>
      <Stack gap="md">
        <TextInput
          label="NIP"
          description="National employee identifier"
          withAsterisk
          {...form.getInputProps("nip")}
        />
        <TextInput
          label="Name"
          withAsterisk
          {...form.getInputProps("name")}
        />
        <TextInput
          label="Email"
          type="email"
          withAsterisk
          {...form.getInputProps("email")}
        />
        <Checkbox
          label="Active"
          description="Inactive users cannot sign in"
          {...form.getInputProps("isActive", { type: "checkbox" })}
        />
        <Group justify="flex-end">
          <Button type="submit" loading={form.isSubmitting}>
            {submitLabel}
          </Button>
        </Group>
      </Stack>
    </form>
  );
}
```

## Field Components

For consistency, wrap `TextInput`, `Select`, etc. in feature-specific components that always include `label` and `description`:

```tsx
// src/components/forms/TextField.tsx:1
"use client";

import { TextInput, type TextInputProps } from "@mantine/core";
import { useFormContext } from "./FormContext";

export function TextField({ name, ...rest }: TextInputProps & { name: string }) {
  const form = useFormContext();
  return <TextInput withAsterisk {...form.getInputProps(name)} {...rest} />;
}
```

Use a form context when nesting:

```tsx
// src/components/forms/FormContext.ts:1
"use client";

import { createFormContext } from "@mantine/form";
import type { UserFormValues } from "@/features/users/schemas/user.schema";

export const [FormProvider, useFormContext, useForm] =
  createFormContext<UserFormValues>();
```

## Async Validation

For fields that need server-side checks (e.g. NIP uniqueness), wrap the Zod schema:

```ts
// src/features/users/schemas/user.schema.ts (excerpt):14
export const userFormSchema = z.object({
  nip: nipSchema.refine(
    async (nip) => {
      if (nip.length < 8) return true; // skip while too short
      try {
        await api.head(`/api/users/check-nip/${nip}`);
        return true;
      } catch (err) {
        if (err instanceof ApiError && err.status === 404) return true;
        return false;
      }
    },
    { message: "NIP already exists" },
  ),
  // ...
});
```

`useForm` with `mode: "controlled"` and Zod async resolvers handles this automatically — validation runs on blur and submit.

## Select, MultiSelect, Combobox

```tsx
import { Select, MultiSelect } from "@mantine/core";

<Select
  label="Department"
  data={[
    { value: "eng", label: "Engineering" },
    { value: "ops", label: "Operations" },
  ]}
  searchable
  {...form.getInputProps("department")}
/>

<MultiSelect
  label="Roles"
  data={[
    { value: "admin", label: "Admin" },
    { value: "user", label: "User" },
  ]}
  {...form.getInputProps("roles")}
/>
```

For searchable single-select with custom filtering, use Mantine's `Combobox` primitives. The `mantine-combobox` skill covers patterns in depth.

## Server Actions vs API Calls

Use **Server Actions** for form submissions that mutate server state and benefit from progressive enhancement:

```tsx
// src/features/users/actions/createUser.ts:1
"use server";

import { revalidatePath } from "next/cache";
import { getAccessToken } from "@/lib/auth/server";
import { api } from "@/lib/axios-server";
import { userFormSchema } from "../schemas/user.schema";

export async function createUserAction(formData: FormData) {
  const parsed = userFormSchema.parse(Object.fromEntries(formData));
  const token = await getAccessToken();

  await api.post("/api/users", parsed, {
    headers: { Authorization: `Bearer ${token}` },
  });

  revalidatePath("/dashboard/users");
}
```

Use **API calls (axios)** for forms that need optimistic updates, granular error handling per field, or rich client-side feedback (e.g. inline notifications).

Default to API calls for internal dashboards — Server Actions add complexity for marginal UX gains in a fully client-rendered app.

## Error Display

Per-field errors come from `form.errors.<field>`. Submit-level errors set via `form.setErrors({ field: msg })`. Network errors surface via `ApiError` — show in a top-level `Alert` and let the user retry:

```tsx
{submitError && (
  <Alert color="red" icon={<IconAlertCircle />} title="Submit failed">
    {submitError}
  </Alert>
)}
```

## Resetting Between Records

When reusing a form for edit, reset on `key` change:

```tsx
<UserForm
  key={user.id}
  initialValues={user}
  onSubmit={async (values) => {
    await updateUser({ id: user.id, patch: values });
    notifications.show({ message: "Saved", color: "green" });
  }}
/>
```

The `key` forces React to remount the form with new `initialValues`, avoiding stale-field bugs.