MCPInstall

generate-frontend-forms

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

Installs
86
Last 24h
—
Trending rank
—
Security
Not audited

Install

Works with every agent the skills CLI supports.

npx skills add https://github.com/getsentry/sentry --skill generate-frontend-forms
  1. 01

    Install

    Run the command in your project. The CLI asks which agents to install for.

  2. 02

    Describe the task

    Ask your agent for something this skill covers. It loads generate-frontend-forms automatically when relevant.

  3. 03

    Review the result

    The agent follows the SKILL.md steps, scripts and references bundled with the skill.

SKILL.md

The instructions your agent reads when the skill is triggered.

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:

import {z} from 'zod';

import {
  AutoSaveForm,
  defaultFormOptions,
  DisplayField,
  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

import {z} from 'zod';

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

const schema = z.object({
  email: z.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

PropertyDescription
AppFormRoot wrapper component (provides form context and renders <form> element). Must receive form={form} prop.
AppFieldField renderer component
FieldGroupSection grouping with title
SubmitButtonPre-wired submit button
SubscribeSubscribe to form state changes
reset()Reset form to default values
handleSubmit()Manually trigger submission

Field Components

Registered fields are accessed via the field render prop and follow consistent patterns. For display-only information without an input, use DisplayField as described below.

Input Field (Text)

<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

<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)

Use field.Select for form choices. Do not compose a form select from bare Menu components; those are internal presentation primitives for shared controls.

<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)

<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)

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

Checkbox Group (Multiple Selections)

Use field.Checkbox.Group and field.Checkbox.Item for multiple selections stored as one string array. Read the checkbox group docs for the example, layout, validation, disabled state, and auto-save behavior.

TextArea Field

<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)

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

<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:

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.

<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

Use field.Layout.Stack or field.Layout.Row for registered fields. These layouts require field context and provide label/input association. For display-only information, use DisplayField.Stack or DisplayField.Row instead.

Stack Layout (Vertical)

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

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

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

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

<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

PropTypeDescription
labelstringField label text
hintTextstringHelper text (below label by default, tooltip in compact mode)
requiredbooleanShows required indicator
variant"compact"Shows hint text in tooltip instead of below label

Display-Only Information

Use DisplayField.Row or DisplayField.Stack for dates, status text, and other information without an input. Import DisplayField from @sentry/scraps/form and render it directly in a FieldGroup or alongside registered fields:

<DisplayField.Row label={t('Created')}>
  <Text>
    <DateTime date={data.dateCreated} />
  </Text>
</DisplayField.Row>
  • Row uses the same responsive layout as field.Layout.Row: it stacks below the md container breakpoint when a query container is present. Without a query container, it stays in a row.
  • Stack always places the value below the term.
  • Both accept label, children, hintText, padding, and variant="compact". Compact mode puts the hint in a tooltip; otherwise it appears below the term in a row and after the value in a stack.
  • They render dl/dt/dd, without a <label>, input association, validation, or error-focus registration. No form or field context is required.
  • Style the displayed value explicitly, for example with <Text>; DisplayField does not wrap its children in Text.

Do not register display-only information with AppField or AutoSaveForm, or add it to the schema or defaultValues just for layout. For a value derived from form state, calculate it during render with form.Subscribe rather than storing a second value with form.setFieldValue:

<form.Subscribe selector={state => state.values.provider}>
  {provider => (
    <DisplayField.Stack label={t('Selected provider')}>
      <Text>{provider ?? t('None selected')}</Text>
    </DisplayField.Stack>
  )}
</form.Subscribe>

A read-only input is still a real input. Keep AppField, field.Layout.*, and field.Input readOnly for values that users select or copy from an input. Keep custom controls that handle form state on field.Base; do not replace them with DisplayField.

Field Groups

Group related fields into sections with a title.

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

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

import {z} from 'zod';

const userSchema = z.object({
  email: z.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.
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() when the validity of one value depends on another value, such as matching passwords. Do not use a form-level .superRefine() to require a field only while it is displayed; put that validator on the conditional field instead.

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. Keep conditionally required values permissive in the form schema, then validate them on the rendered AppField so hidden fields do not block submission:

const schema = z.object({
  plan: z.enum(['standard', 'enterprise']),
  billingEmail: z.string(),
});

<form.Subscribe selector={state => state.values.plan === 'enterprise'}>
  {showBilling =>
    showBilling ? (
      <form.AppField
        name="billingEmail"
        validators={{
          onDynamic: z.email('Enter a valid billing email'),
        }}
      >
        {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>;

For a nullable number field, keep z.number().nullable() in the form schema and use a field validator such as z.number().positive('Amount must be greater than zero').nullable().refine(value => value !== null, 'Amount is required'). The refine rejects the null emitted when the number input is empty.


Error Handling

Server-Side Errors

Use setFieldErrors to display backend validation errors:

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:

// ❌ 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:

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

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 TypeWhen it saves
Input, TextAreaOn blur (when user leaves field)
Select (single)Immediately when selection changes
Select (multiple)When menu closes, or when X/clear clicked while menu closed
SwitchImmediately when toggled
RadioImmediately when selection changes
RangeWhen 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.

<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:

TypeDescription
stringAlways show this message before saving
(value) => string | undefinedFunction 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:

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

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.

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

<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

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

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

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

Nullable Default Values

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

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

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

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

// ❌ 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.

// ❌ 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."

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

defaultValues are reactive in TanStack Form. When query data arrives after the form mounts, passing that data through defaultValues updates an untouched form. Do not add an effect that calls form.reset() just to populate async query results. A reset is still useful after a successful save when the form remains mounted and is dirty.

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

// ❌ 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 registered input, including read-only inputs
  • Use DisplayField.Row or DisplayField.Stack for display-only information; keep it out of form state
  • 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

FilePurpose
static/app/components/core/form/scrapsForm.tsxMain form hook
static/app/components/core/form/autoSaveForm.tsxAuto-save wrapper
static/app/components/core/form/formErrorContext.tsxHost error-mapper contract
static/app/scrapsProviders/formError.tsxSentry request-error mapper
static/app/components/core/form/field/*.tsxIndividual field components
static/app/components/core/form/layout/index.tsxLayout components
static/app/components/core/form/displayField.tsxDisplay-only layouts
static/app/components/core/form/form.stories.tsxUsage examples

FAQ

What does the generate-frontend-forms skill do?

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

How do I install generate-frontend-forms?

Run npx skills add https://github.com/getsentry/sentry --skill generate-frontend-forms in your project. The skills CLI detects the agents you use and copies the skill into the right folder. For Claude Code you can also place the skill folder at ~/.claude/skills/generate-frontend-forms/ (personal) or .claude/skills/generate-frontend-forms/ (project).

Which AI agents can use generate-frontend-forms?

Any agent that supports the open Agent Skills format (a folder with a SKILL.md file), including Claude Code, Codex, Cursor, GitHub Copilot, Gemini CLI, Windsurf, Cline, OpenCode and more.

Is generate-frontend-forms safe to install?

No partner security audit is available yet. Skills can run commands and scripts, so read the SKILL.md and any bundled files before installing.

Who maintains generate-frontend-forms?

It is published by getsentry in getsentry/sentry, and it is an official skill from the maker of the technology it covers. It has 86 recorded installs.

More from getsentry/sentry

View all →

design-system

getsentry/sentry

Guide for using Sentry's layout and text primitives. Use when implementing UI components, layouts, or typography. Enforces use of core components over styled components.

OfficialFrontend & Design111 installs

sentry-security

getsentry/sentry

Sentry-specific security review based on real vulnerability history. Use when reviewing Sentry endpoints, serializers, or views for security issues. Trigger keywords: "sentry security review", "check for IDOR", "access control review", "org scoping", "cross-org", "security audit endpoint".

OfficialSecurity100 installs

sentry-javascript-bugs

getsentry/sentry

Review Sentry React and TypeScript changes for bug patterns drawn from real production issues. Use when reviewing a frontend diff or PR, checking Warden findings, auditing the current branch, reviewing production-error patterns, or looking for common regressions in static/.

OfficialDevOps & Cloud92 installs

generate-migration

getsentry/sentry

Generate or review Django database migrations for Sentry. Use when creating or reviewing migrations and data migrations, adding/removing columns or tables, adding indexes, or resolving migration conflicts.

OfficialCode Quality & Workflow90 installs

sentry-backend-bugs

getsentry/sentry

Open the skill to read its SKILL.md instructions.

OfficialDevOps & Cloud84 installs

migrate-frontend-forms

getsentry/sentry

Guide for migrating forms from the legacy JsonForm/FormModel system to the new TanStack-based form system.

OfficialFrontend & Design82 installs

Popular frontend & design skills

View category →

setup-matt-pocock-skills

mattpocock/skills

Configure this repo for the engineering skills: set up its issue tracker, triage label vocabulary, and domain doc layout. Run once before first use of the other engineering skills.

Frontend & Design1.1M installs

frontend-design

anthropics/skills

Guidance for distinctive, intentional visual design when building new UI or reshaping an existing one. Helps with aesthetic direction, typography, and making choices that don't read as templated defaults.

OfficialFrontend & Design1M installs

hyperframes-animation

heygen-com/hyperframes

All animation knowledge for HyperFrames — atomic motion rules, multi-phase scene blueprints, scene transitions, broader motion-design techniques, AND the seven runtime adapters (GSAP default, plus Lottie, Three.js, Anime.js, CSS keyframes, Web Animations API, TypeGPU). Use for any motion or animation task: pick 2-4 rules and compose, or load a blueprint, or look up runtime-specific API (e.g. GSAP eases / Lottie player / Three.js mixer). Also covers auditing an existing composition's choreography (animation map) and 24 named text-animation effects. HyperFrames-native: single paused timeline, seek-safe, deterministic.

prototype

mattpocock/skills

Build a throwaway prototype to answer a design question. Use when the user wants to sanity-check whether a state model or logic feels right, or explore what a UI should look like.

Frontend & Design999.2K installs

hyperframes-core

heygen-com/hyperframes

The HyperFrames composition contract — build one renderable project. Use for composition structure, the data- timing attributes, class="clip", tracks, sub-compositions, variables, framework-owned media playback, deterministic-render rules, and validation. Read before writing composition HTML.

Frontend & Design930.5K installs

vercel-react-best-practices

vercel-labs/agent-skills

React and Next.js performance optimization guidelines from Vercel Engineering. This skill should be used when writing, reviewing, or refactoring React/Next.js code to ensure optimal performance patterns. Triggers on tasks involving React components, Next.js pages, data fetching, bundle optimization, or performance improvements.

OfficialFrontend & Design843.1K installs