Skip to content
Back to skills

Generate Frontend Forms

ASecurity

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

  • 44,888 stars
  • 0 votes
  • 0 copies
  • 12 views
  • Added June 12, 2026
devopsreactnodegitapifrontendbackendsecurity

Works with

  • cli
  • api

Security analysis

A100/100

Scanned June 12, 2026

npx -y skills add getsentry/sentry --skill generate-frontend-forms --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Generate Frontend Forms?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Generate Frontend Forms
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/getsentry-generate-frontend-forms/badge)](https://www.skillsdirectory.com/skills/getsentry-generate-frontend-forms)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: generate-frontend-forms
description: Guide for creating forms using Sentry's new form system. Use when implementing forms, form fields, validation, or auto-save functionality.
---

# 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';

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) {
        // Set field-specific errors from backend
        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.

### 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)
- [ ] Handle server errors with `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

---

## 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/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              |

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…