# Generate Frontend Forms

> Guide for creating forms using Sentry's new form system. Use when implementing forms, form fields, validation, or auto-save functionality.

- Skill: `tuyv/generate-frontend-forms` (Agent Skill, multi-file: 4 files)
- Install (CLI): `npx skillmds@latest add tuyv/generate-frontend-forms`
- Raw SKILL.md: https://api.skillmd.com/api/skills/tuyv/generate-frontend-forms/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: tuyv (https://skillmd.com/u/tuyv)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/tuyv/generate-frontend-forms

---


# Form System Guide

This skill provides patterns for building forms using Sentry's new form system built on TanStack React Form and Zod validation.

## Core Principle

- Always use the new form system (`useScrapsForm`, `AutoSaveForm`) for new forms. Never create new forms with the legacy JsonForm or Reflux-based systems.

- All forms should be schema based. DO NOT create a form without schema validation.

## Imports

All form components are exported from `@sentry/scraps/form`:

```tsx
import {z} from 'zod';

import {
  AutoSaveForm,
  defaultFormOptions,
  setFieldErrors,
  useScrapsForm,
} from '@sentry/scraps/form';
```

> **Important**: DO NOT import from deeper paths, like '@sentry/scraps/form/field'. You can only use what is part of the PUBLIC interface in the index file in @sentry/scraps/form.

---

## Form Hook: `useScrapsForm`

The main hook for creating forms with validation and submission handling.

### Basic Usage

```tsx
import {z} from 'zod';

import {defaultFormOptions, useScrapsForm} from '@sentry/scraps/form';

const schema = z.object({
  email: z.string().email('Invalid email'),
  name: z.string().min(2, 'Name must be at least 2 characters'),
});

function MyForm() {
  const form = useScrapsForm({
    ...defaultFormOptions,
    defaultValues: {
      email: '',
      name: '',
    },
    validators: {
      onDynamic: schema,
    },
    onSubmit: ({value, formApi}) => {
      // Handle submission
      console.log(value);
    },
  });

  return (
    <form.AppForm form={form}>
      <form.AppField name="email">
        {field => (
          <field.Layout.Stack label="Email" required>
            <field.Input value={field.state.value} onChange={field.handleChange} />
          </field.Layout.Stack>
        )}
      </form.AppField>

      <form.SubmitButton>Submit</form.SubmitButton>
    </form.AppForm>
  );
}
```

> **Important**: Always spread `defaultFormOptions` first. It configures validation to run on submit initially, then on every change after the first submission. This is why validators are defined as `onDynamic`, and it's what provides a consistent UX.

### Returned Properties

| Property         | Description                                                                                                   |
| ---------------- | ------------------------------------------------------------------------------------------------------------- |
| `AppForm`        | Root wrapper component (provides form context and renders `<form>` element). Must receive `form={form}` prop. |
| `AppField`       | Field renderer component                                                                                      |
| `FieldGroup`     | Section grouping with title                                                                                   |
| `SubmitButton`   | Pre-wired submit button                                                                                       |
| `Subscribe`      | Subscribe to form state changes                                                                               |
| `reset()`        | Reset form to default values                                                                                  |
| `handleSubmit()` | Manually trigger submission                                                                                   |

---

## Field Components

All fields are accessed via the `field` render prop and follow consistent patterns.

### Input Field (Text)

```tsx
<form.AppField name="firstName">
  {field => (
    <field.Layout.Stack label="First Name" required>
      <field.Input
        value={field.state.value}
        onChange={field.handleChange}
        placeholder="Enter your name"
      />
    </field.Layout.Stack>
  )}
</form.AppField>
```

### Number Field

```tsx
<form.AppField name="age">
  {field => (
    <field.Layout.Stack label="Age" required>
      <field.Number
        value={field.state.value}
        onChange={field.handleChange}
        min={0}
        max={120}
        step={1}
      />
    </field.Layout.Stack>
  )}
</form.AppField>
```

### Select Field (Single)

```tsx
<form.AppField name="country">
  {field => (
    <field.Layout.Stack label="Country">
      <field.Select
        value={field.state.value}
        onChange={field.handleChange}
        options={[
          {value: 'us', label: 'United States'},
          {value: 'uk', label: 'United Kingdom'},
        ]}
      />
    </field.Layout.Stack>
  )}
</form.AppField>
```

### Select Field (Multiple)

```tsx
<form.AppField name="tags">
  {field => (
    <field.Layout.Stack label="Tags">
      <field.Select
        multiple
        value={field.state.value}
        onChange={field.handleChange}
        options={[
          {value: 'bug', label: 'Bug'},
          {value: 'feature', label: 'Feature'},
        ]}
        clearable
      />
    </field.Layout.Stack>
  )}
</form.AppField>
```

### Switch Field (Boolean)

```tsx
<form.AppField name="notifications">
  {field => (
    <field.Layout.Stack label="Enable notifications">
      <field.Switch checked={field.state.value} onChange={field.handleChange} />
    </field.Layout.Stack>
  )}
</form.AppField>
```

### TextArea Field

```tsx
<form.AppField name="bio">
  {field => (
    <field.Layout.Stack label="Bio">
      <field.TextArea
        value={field.state.value}
        onChange={field.handleChange}
        rows={4}
        placeholder="Tell us about yourself"
      />
    </field.Layout.Stack>
  )}
</form.AppField>
```

### Range Field (Slider)

```tsx
<form.AppField name="volume">
  {field => (
    <field.Layout.Stack label="Volume">
      <field.Range
        value={field.state.value}
        onChange={field.handleChange}
        min={0}
        max={100}
        step={10}
      />
    </field.Layout.Stack>
  )}
</form.AppField>
```

### Radio Field

Radio fields use a composable API with `Radio.Group` and `Radio.Item`. `Radio.Group` provides group context that changes how the label is rendered for proper accessibility semantics.

> **Important**: The layout (and its label) **must** be rendered _inside_ `Radio.Group`. The group context is provided by `Radio.Group`, so placing the layout outside will result in incorrect accessibility semantics.

```tsx
<form.AppField name="priority">
  {field => (
    <field.Radio.Group value={field.state.value} onChange={field.handleChange}>
      <field.Layout.Stack label="Priority">
        <field.Radio.Item value="low">Low</field.Radio.Item>
        <field.Radio.Item value="medium">Medium</field.Radio.Item>
        <field.Radio.Item value="high" description="Urgent issues">
          High
        </field.Radio.Item>
      </field.Layout.Stack>
    </field.Radio.Group>
  )}
</form.AppField>
```

For horizontal arrangement of radio items, use a `Flex` or `Stack` wrapper inside the layout:

```tsx
import {Flex} from '@sentry/scraps/layout';

<field.Radio.Group value={field.state.value} onChange={field.handleChange}>
  <field.Layout.Row label="Priority">
    <Flex gap="lg">
      <field.Radio.Item value="low">Low</field.Radio.Item>
      <field.Radio.Item value="high">High</field.Radio.Item>
    </Flex>
  </field.Layout.Row>
</field.Radio.Group>;
```

### Custom Fields with BaseField

For one-off fields that don't have a built-in component (e.g. a color picker, or any custom input), use `field.Base`. It provides a render prop with all the necessary accessibility and form integration props (`ref`, `disabled`, `aria-invalid`, `aria-describedby`, `onBlur`, `name`, `id`) that you spread onto your native element.

```tsx
<form.AppField name="color">
  {field => (
    <field.Layout.Row label="Brand Color">
      <field.Base<HTMLInputElement>>
        {(baseProps, {indicator}) => (
          <Flex flexGrow={1}>
            <input
              {...baseProps}
              type="color"
              value={field.state.value}
              onChange={e => field.handleChange(e.target.value)}
            />
            {indicator}
          </Flex>
        )}
      </field.Base>
    </field.Layout.Row>
  )}
</form.AppField>
```

The render prop receives two arguments:

1. **`baseProps`** — accessibility and form integration props (`ref`, `disabled`, `aria-invalid`, `aria-describedby`, `onBlur`, `name`, `id`) to spread onto your element
2. **`{indicator}`** — the auto-save status indicator (spinner/checkmark) as a React node, which you can place wherever makes sense in your custom layout

The element type is inferred from the passed `ref`, so if you don't pass one, you have to manually annotate it with `<field.Base<HTMLInputElement>>`.

`field.Base` automatically handles:

- Merging refs (for scroll-to-hash and external ref forwarding)
- Disabling the field when auto-save is pending
- Setting `aria-invalid` based on validation state
- Linking to hint text via `aria-describedby`

Use `field.Base` instead of building custom wrappers that duplicate this logic. It works with any native HTML element or third-party component that accepts standard props.

---

## Layouts

Two layout options are available for positioning labels and fields.

### Stack Layout (Vertical)

Label above, field below. Best for forms with longer labels or mobile layouts.

```tsx
<field.Layout.Stack
  label="Email Address"
  hintText="We'll never share your email"
  required
>
  <field.Input value={field.state.value} onChange={field.handleChange} />
</field.Layout.Stack>
```

### Row Layout (Horizontal)

Label on left (~50%), field on right. Compact layout for settings pages.

```tsx
<field.Layout.Row label="Email Address" hintText="We'll never share your email" required>
  <field.Input value={field.state.value} onChange={field.handleChange} />
</field.Layout.Row>
```

### Compact Variant

Both Stack and Row layouts support a `variant="compact"` prop. In compact mode, the hint text appears as a tooltip on the label instead of being displayed below. This saves vertical space while still providing the hint information.

```tsx
// Default: hint text appears below the label
<field.Layout.Row label="Email" hintText="We'll never share your email">
    <field.Input ... />
</field.Layout.Row>

// Compact: hint text appears in tooltip when hovering the label
<field.Layout.Row label="Email" hintText="We'll never share your email" variant="compact">
    <field.Input ... />
</field.Layout.Row>

// Also works with Stack layout
<field.Layout.Stack label="Email" hintText="We'll never share your email" variant="compact">
    <field.Input ... />
</field.Layout.Stack>
```

**When to Use Compact**:

- Settings pages with many fields where vertical space is limited
- Forms where hint text is supplementary, not essential
- Dashboards or panels with constrained height

### Custom Layouts

You are allowed to create new layouts if necessary, or not use any layouts at all. Without a layout, you _should_ render `field.meta.Label` and optionally `field.meta.HintText` for a11y.

```tsx
<form.AppField name="firstName">
  {field => (
    <Flex gap="md">
      <field.Meta.Label required>First Name:</field.Meta.Label>
      <field.Input value={field.state.value ?? ''} onChange={field.handleChange} />
    </Flex>
  )}
</form.AppField>
```

### Layout Props

| Prop       | Type        | Description                                                   |
| ---------- | ----------- | ------------------------------------------------------------- |
| `label`    | `string`    | Field label text                                              |
| `hintText` | `string`    | Helper text (below label by default, tooltip in compact mode) |
| `required` | `boolean`   | Shows required indicator                                      |
| `variant`  | `"compact"` | Shows hint text in tooltip instead of below label             |

---

## Field Groups

Group related fields into sections with a title.

```tsx
<form.FieldGroup title="Personal Information">
    <form.AppField name="firstName">{/* ... */}</form.AppField>
    <form.AppField name="lastName">{/* ... */}</form.AppField>
</form.FieldGroup>

<form.FieldGroup title="Contact Information">
    <form.AppField name="email">{/* ... */}</form.AppField>
    <form.AppField name="phone">{/* ... */}</form.AppField>
</form.FieldGroup>
```

---

## Disabled State

Fields accept `disabled` as a boolean or string. When a string is provided, it displays as a tooltip explaining why the field is disabled.

```tsx
// ❌ Don't disable without explanation
<field.Input disabled value={field.state.value} onChange={field.handleChange} />

// ✅ Provide a reason when disabling
<field.Input
    disabled="This feature requires a Business plan"
    value={field.state.value}
    onChange={field.handleChange}
/>
```

---

## Validation with Zod

### Schema Definition

```tsx
import {z} from 'zod';

const userSchema = z.object({
  email: z.string().email('Please enter a valid email'),
  password: z.string().min(8, 'Password must be at least 8 characters'),
  age: z.number().gte(13, 'You must be at least 13 years old'),
  bio: z.string().optional(),
  tags: z.array(z.string()).optional(),
  address: z.object({
    street: z.string().min(1, 'Street is required'),
    city: z.string().min(1, 'City is required'),
  }),
});
```

### Nullable Fields with Refine

When a field starts as `null` (e.g., a required select with no initial selection), use `.nullable().refine()` in the schema. This creates a difference between the schema's _input_ type (which accepts `null`) and its _output_ type (which does not). To handle this correctly:

1. Type `defaultValues` explicitly as `z.input<typeof schema>` — this allows `null` as an initial value.
2. Call `schema.parse(value)` inside `onSubmit` to narrow from `z.input` to `z.output`, stripping the `null` before passing to your mutation.

```tsx
const schema = z.object({
  provider: z
    .enum(['GitHub', 'LaunchDarkly'])
    .nullable()
    .refine(v => v !== null, 'Provider is required'),
  name: z.string().min(1, 'Name is required'),
});

// z.input allows null for the provider field
const defaultValues: z.input<typeof schema> = {
  provider: null,
  name: '',
};

// z.output<typeof schema> has provider as non-null after refine
type FormOutput = z.output<typeof schema>;

const form = useScrapsForm({
  ...defaultFormOptions,
  defaultValues,
  validators: {onDynamic: schema},
  onSubmit: ({value}) => {
    // schema.parse narrows null away — mutation receives z.output
    return mutation.mutateAsync(schema.parse(value)).catch(() => {});
  },
});
```

> **Important**: Do NOT use non-null assertions (`value.provider!`) or type casts to work around nullable fields. The `schema.parse()` approach is both type-safe and validates at runtime.

### Conditional Validation

Use `.refine()` for cross-field validation:

```tsx
const schema = z
  .object({
    password: z.string(),
    confirmPassword: z.string(),
  })
  .refine(data => data.password === data.confirmPassword, {
    message: 'Passwords do not match',
    path: ['confirmPassword'],
  });
```

### Conditional Fields

Use `form.Subscribe` to show/hide fields based on other field values:

```tsx
<form.Subscribe selector={state => state.values.plan === 'enterprise'}>
  {showBilling =>
    showBilling ? (
      <form.AppField name="billingEmail">
        {field => (
          <field.Layout.Stack label="Billing Email" required>
            <field.Input value={field.state.value} onChange={field.handleChange} />
          </field.Layout.Stack>
        )}
      </form.AppField>
    ) : null
  }
</form.Subscribe>
```

---

## Error Handling

### Server-Side Errors

Use `setFieldErrors` to display backend validation errors:

```tsx
import {useMutation} from '@tanstack/react-query';

import {setFieldErrors} from '@sentry/scraps/form';

import {fetchMutation} from 'sentry/utils/queryClient';
import {RequestError} from 'sentry/utils/requestError/requestError';
import {requestErrorToFieldErrors} from 'sentry/utils/requestError/requestErrorToFieldErrors';

function MyForm() {
  const mutation = useMutation({
    mutationFn: (data: {email: string; username: string}) => {
      return fetchMutation({
        url: '/users/',
        method: 'POST',
        data,
      });
    },
  });

  const form = useScrapsForm({
    ...defaultFormOptions,
    defaultValues: {email: '', username: ''},
    validators: {onDynamic: schema},
    onSubmit: async ({value, formApi}) => {
      try {
        await mutation.mutateAsync(value);
      } catch (error) {
        if (error instanceof RequestError) {
          setFieldErrors(formApi, requestErrorToFieldErrors(error, formApi.state.values));
        }
      }
    },
  });

  // ...
}
```

`setFieldErrors` accepts the Scraps `FieldErrors` contract. It does not accept
Sentry's `RequestError`. Use `requestErrorToFieldErrors` at the app boundary to:

- accept a `RequestError` after the call site narrows the unknown error;
- keep only keys that exist in the form values;
- convert string and array response values to `{message: string}`.

Do not pass an API error to Scraps directly:

```tsx
// ❌ RequestError is an app type, not a Scraps form error
setFieldErrors(formApi, error);

// ✅ Narrow at the app call site, then convert to the Scraps contract
if (error instanceof RequestError) {
  setFieldErrors(formApi, requestErrorToFieldErrors(error, formApi.state.values));
}
```

Use a direct object when you create the field messages in the form code:

```tsx
setFieldErrors(formApi, {
  email: {message: 'This email is already registered'},
  username: {message: 'Username is taken'},
});
```

> **Important**: `setFieldErrors` supports nested paths with dot notation: `'address.city': {message: 'City not found'}`

### Error Display

Validation errors automatically show as a warning icon with tooltip in the field's trailing area. No additional code needed.

---

## Auto-Save Pattern

For settings pages where each field saves independently, use `AutoSaveForm`.

### Basic Auto-Save Form

```tsx
import {z} from 'zod';

import {AutoSaveForm} from '@sentry/scraps/form';

import {fetchMutation} from 'sentry/utils/queryClient';

const schema = z.object({
  displayName: z.string().min(1, 'Display name is required'),
});

function SettingsForm() {
  return (
    <AutoSaveForm
      name="displayName"
      schema={schema}
      initialValue={user.displayName}
      mutationOptions={{
        mutationFn: data => {
          return fetchMutation({
            url: '/user/',
            method: 'PUT',
            data,
          });
        },
        onSuccess: data => {
          // Update React Query cache
          queryClient.setQueryData(['user'], old => ({...old, ...data}));
        },
      }}
    >
      {field => (
        <field.Layout.Row label="Display Name">
          <field.Input value={field.state.value} onChange={field.handleChange} />
        </field.Layout.Row>
      )}
    </AutoSaveForm>
  );
}
```

### Auto-Save Behavior by Field Type

| Field Type        | When it saves                                               |
| ----------------- | ----------------------------------------------------------- |
| Input, TextArea   | On blur (when user leaves field)                            |
| Select (single)   | Immediately when selection changes                          |
| Select (multiple) | When menu closes, or when X/clear clicked while menu closed |
| Switch            | Immediately when toggled                                    |
| Radio             | Immediately when selection changes                          |
| Range             | When user releases the slider, or immediately with keyboard |

### Auto-Save Status Indicators

The form system automatically shows:

- **Spinner** while saving (pending)
- **Checkmark** on success (fades after 2s)
- **Warning icon** on validation error (with tooltip)

> **Important**: Do NOT use toasts to communicate auto-save status. The built-in inline indicators (spinner, checkmark, warning icon) are the correct feedback mechanism. Toasts are noisy and disruptive for fields that save frequently on every change.

### Auto-Save Request Errors

`AutoSaveForm` receives app-specific error mapping from the form error context.
Sentry installs this mapping once through `ScrapsProviders`. Do not catch
`RequestError` or call `requestErrorToFieldErrors` at each `AutoSaveForm` call
site.

The Sentry provider:

- narrows failed mutations to `RequestError`;
- uses `requestErrorToFieldErrors` for matching backend field errors;
- uses `getRequestErrorUserMessage` for request detail and status messages;
- keeps `Failed to save` as the fallback for other errors.

Scraps remains independent of Sentry's API error type. Outside the Sentry app,
the form error context uses the generic fallback unless the host app supplies
its own mapper.

### Confirmation Dialogs

For dangerous operations (security settings, permissions), use the `confirm` prop to show a confirmation modal before saving. The `confirm` prop accepts either a string or a function.

```tsx
<AutoSaveForm
  name="require2FA"
  schema={schema}
  initialValue={false}
  confirm={value =>
    value
      ? 'This will remove all members without 2FA. Continue?'
      : 'Are you sure you want to allow members without 2FA?'
  }
  mutationOptions={{...}}
>
  {field => (
    <field.Layout.Row label="Require Two-Factor Auth">
      <field.Switch checked={field.state.value} onChange={field.handleChange} />
    </field.Layout.Row>
  )}
</AutoSaveForm>
```

**Confirm Config Options:**

| Type                             | Description                                                                                 |
| -------------------------------- | ------------------------------------------------------------------------------------------- |
| `string`                         | Always show this message before saving                                                      |
| `(value) => string \| undefined` | Function that returns a message based on the new value, or `undefined` to skip confirmation |

> **Note**: Confirmation dialogs always focus the Cancel button for safety, preventing accidental confirmation of dangerous operations.

**Examples:**

```tsx
// ✅ Simple string - always confirm
confirm="Are you sure you want to change this setting?"

// ✅ Only confirm when ENABLING (return undefined to skip)
confirm={value => value ? 'Are you sure you want to enable this?' : undefined}

// ✅ Only confirm when DISABLING
confirm={value => !value ? 'Disabling this removes security protection.' : undefined}

// ✅ Different messages for each direction
confirm={value =>
  value
    ? 'Enable 2FA requirement for all members?'
    : 'Allow members without 2FA?'
}

// ✅ For select fields - confirm specific values
confirm={value => value === 'delete' ? 'This will permanently delete all data!' : undefined}
```

---

## Form Submission

> **Important**: Always use TanStack Query mutations (`useMutation`) for form submissions. This ensures proper loading states, error handling, and cache management.

### Using Mutations

```tsx
import {useMutation} from '@tanstack/react-query';

import {fetchMutation} from 'sentry/utils/queryClient';

function MyForm() {
  const mutation = useMutation({
    mutationFn: (data: FormData) => {
      return fetchMutation({
        url: '/endpoint/',
        method: 'POST',
        data,
      });
    },
    onSuccess: () => {
      // Handle success (e.g., show toast, redirect)
    },
  });

  const form = useScrapsForm({
    ...defaultFormOptions,
    defaultValues: {...},
    validators: {onDynamic: schema},
    onSubmit: ({value}) => {
      return mutation.mutateAsync(value).catch(() => {});
    },
  });

  // ...
}
```

### Resetting After Save

When a form stays on the page after submission (e.g., settings pages), call `form.reset()` after a successful mutation. This re-syncs the form with updated `defaultValues` so it becomes pristine again — any UI that depends on the form being dirty (like conditionally shown Save/Cancel buttons) will update correctly.

```tsx
onSubmit: ({value}) =>
  mutation
    .mutateAsync(value)
    .then(() => form.reset())
    .catch(() => {}),
```

> **Note**: `AutoSaveForm` handles this automatically. You only need to add this when using `useScrapsForm`.

### Submit Button

```tsx
<Flex gap="md" justify="end">
  <form.ResetButton>Reset</form.ResetButton>
  <form.SubmitButton>Save Changes</form.SubmitButton>
</Flex>
```

The `SubmitButton` automatically:

- Disables while submission is pending
- Triggers form validation before submit

---

## Do's and Don'ts

### Form System Choice

```tsx
// ❌ Don't use legacy JsonForm for new forms
<JsonForm fields={[{name: 'email', type: 'text'}]} />;

// ✅ Use useScrapsForm with Zod validation
const form = useScrapsForm({
  ...defaultFormOptions,
  defaultValues: {email: ''},
  validators: {onDynamic: schema},
});
```

### Default Options

```tsx
// ❌ Don't forget defaultFormOptions
const form = useScrapsForm({
  defaultValues: {name: ''},
});

// ✅ Always spread defaultFormOptions first
const form = useScrapsForm({
  ...defaultFormOptions,
  defaultValues: {name: ''},
});
```

### Nullable Default Values

```tsx
// ❌ Don't use non-null assertions or type casts
onSubmit: ({value}) => {
  return mutation.mutateAsync({...value, provider: value.provider!});
};

// ❌ Don't skip typing defaultValues when the schema has refine
const form = useScrapsForm({
  ...defaultFormOptions,
  defaultValues: {provider: null, name: ''}, // type is inferred but imprecise
});

// ✅ Use z.input for defaultValues and schema.parse in onSubmit
const defaultValues: z.input<typeof schema> = {provider: null, name: ''};

const form = useScrapsForm({
  ...defaultFormOptions,
  defaultValues,
  validators: {onDynamic: schema},
  onSubmit: ({value}) => {
    return mutation.mutateAsync(schema.parse(value)).catch(() => {});
  },
});
```

### Form Submissions

```tsx
// ❌ Don't call API directly in onSubmit
onSubmit: async ({value}) => {
  await api.post('/users', value);
};

// ❌ Don't use mutateAsync without .catch() - causes unhandled rejection
onSubmit: ({value}) => {
  return mutation.mutateAsync(value);
};

// ✅ Use mutations with fetchMutation and .catch(() => {})
const mutation = useMutation({
  mutationFn: data => fetchMutation({url: '/users/', method: 'POST', data}),
});

onSubmit: ({value}) => {
  // Return the promise to keep form.isSubmitting working
  // Add .catch(() => {}) to avoid unhandled rejection - error handling
  // is done by TanStack Query (onError callback, mutation.isError state)
  // Add .then(() => form.reset()) if the form stays on the page after save
  return mutation
    .mutateAsync(value)
    .then(() => form.reset())
    .catch(() => {});
};
```

### Field Value Handling

```tsx
// ❌ Don't use field.state.value directly when it might be undefined
<field.Input value={field.state.value} />

// ✅ Provide fallback for optional fields
<field.Input value={field.state.value ?? ''} />
```

### Validation Messages

```tsx
// ❌ Don't use generic error messages
z.string().min(1);

// ✅ Provide helpful, specific error messages
z.string().min(1, 'Email address is required');
```

### Auto-Save Feedback

```tsx
// ❌ Don't use toasts for auto-save status
mutationOptions={{
  mutationFn: (data) => fetchMutation({url: '/user/', method: 'PUT', data}),
  onSuccess: () => {
    addSuccessMessage('Saved!'); // ❌ noisy and disruptive
  },
}}

// ✅ Rely on built-in inline indicators (spinner, checkmark, warning icon)
mutationOptions={{
  mutationFn: (data) => fetchMutation({url: '/user/', method: 'PUT', data}),
  onSuccess: (data) => {
    queryClient.setQueryData(['user'], old => ({...old, ...data}));
    // No toast needed - AutoSaveForm shows a checkmark automatically
  },
}}
```

### Auto-Save Cache Updates

Always update the data store or cache in `onSuccess`. Without this, toggling a field back to its original value won't trigger a save — TanStack Form compares against `defaultValues` (derived from `initialValue`) and skips submission when the value matches.

```tsx
// ❌ Don't forget to update the cache after auto-save
mutationOptions={{
  mutationFn: (data) => fetchMutation({url: '/user/', method: 'PUT', data}),
}}

// ✅ Update React Query cache on success
mutationOptions={{
  mutationFn: (data) => fetchMutation({url: '/user/', method: 'PUT', data}),
  onSuccess: (data) => {
    queryClient.setQueryData(['user'], old => ({...old, ...data}));
  },
}}
```

### Auto-Save Mutation Typing

Type the `mutationFn` with the API's data type, **not** the zod schema type. The schema is for client-side field validation — the mutation should accept whatever the API endpoint accepts. Don't use generic types like `Record<string, unknown>` either, as that breaks TanStack Form's ability to narrow field types.

**NEVER pass call-site generics to `mutationOptions`, `useMutation`, or any TanStack Query function.** Types must be inferred, not asserted. See the full rules in `static/AGENTS.md` under "TanStack Query Type Inference."

```tsx
// ❌ NEVER pass generics to mutationOptions/useMutation
mutationOptions<unknown, RequestError, Variables, Context>({...})
useMutation<Response, RequestError, Variables>({...})

// ❌ Don't use generic types - breaks field type narrowing
const opts = mutationOptions({
  mutationFn: (data: Record<string, unknown>) => fetchMutation({...}),
});

// ❌ Don't tie mutation type to the zod schema
const opts = mutationOptions({
  mutationFn: (data: Partial<z.infer<typeof preferencesSchema>>) => fetchMutation({...}),
});

// ❌ Don't explicitly type context — it's inferred from onMutate return
type MyContext = {previousData: UserDetails};

// ❌ Don't use RequestError as the error generic — use runtime narrowing instead

// ✅ Use the API's data type on mutationFn, let everything else be inferred
const opts = mutationOptions({
  mutationFn: (data: Partial<UserDetails>) =>
    fetchMutation<UserDetails>({...}),
});
```

Make sure the zod schema's types are compatible with the API type. For example, if the API expects a string union like `'off' | 'low' | 'high'`, use `z.enum(['off', 'low', 'high'])` instead of `z.string()`.

### Form Reset After Save

```tsx
// ❌ Don't forget to reset forms that stay on the page after save
onSubmit: ({value}) => {
  return mutation.mutateAsync(value).catch(() => {});
};

// ✅ Call form.reset() after successful save to sync with updated defaultValues
onSubmit: ({value}) => {
  return mutation
    .mutateAsync(value)
    .then(() => form.reset())
    .catch(() => {});
};
```

### Layout Choice

```tsx
// ❌ Don't use Row layout when labels are very long
<field.Layout.Row label="Please enter the primary email address for your account">

// ✅ Use Stack layout for long labels
<field.Layout.Stack label="Please enter the primary email address for your account">
```

---

## Quick Reference Checklist

When creating a new form:

- [ ] Import from `@sentry/scraps/form` and `zod`
- [ ] Define Zod schema with helpful error messages
- [ ] Use `useScrapsForm` with `...defaultFormOptions`
- [ ] Set `defaultValues` matching schema shape (use `z.input<typeof schema>` if schema has `.refine()`)
- [ ] Set `validators: {onDynamic: schema}`
- [ ] Wrap with `<form.AppForm form={form}>`
- [ ] Use `<form.AppField>` for each field
- [ ] Choose appropriate layout (Stack or Row)
- [ ] Narrow unknown errors to `RequestError` at the call site, convert them with `requestErrorToFieldErrors`, then call `setFieldErrors`
- [ ] Add `<form.SubmitButton>` for submission
- [ ] Call `form.reset()` after successful mutation if the form stays on the page

When creating auto-save fields:

- [ ] Use `<AutoSaveForm>` component
- [ ] Pass `schema` for validation
- [ ] Pass `initialValue` from current data
- [ ] Configure `mutationOptions` with `mutationFn`
- [ ] Update cache in `onSuccess` callback
- [ ] Let the Sentry form error provider handle standard request errors

---

## File References

| File                                                   | Purpose                     |
| ------------------------------------------------------ | --------------------------- |
| `static/app/components/core/form/scrapsForm.tsx`       | Main form hook              |
| `static/app/components/core/form/autoSaveForm.tsx`     | Auto-save wrapper           |
| `static/app/components/core/form/formErrorContext.tsx` | Host error-mapper contract  |
| `static/app/scrapsProviders/formError.tsx`             | Sentry request-error mapper |
| `static/app/components/core/form/field/*.tsx`          | Individual field components |
| `static/app/components/core/form/layout/index.tsx`     | Layout components           |
| `static/app/components/core/form/form.stories.tsx`     | Usage examples              |

