---
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 (
{field => (
)}
Submit
);
}
```
> **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 `
{field => (
)}
```
### Number Field
```tsx
{field => (
)}
```
### Select Field (Single)
```tsx
{field => (
)}
```
### Select Field (Multiple)
```tsx
{field => (
)}
```
### Switch Field (Boolean)
```tsx
{field => (
)}
```
### TextArea Field
```tsx
{field => (
)}
```
### Range Field (Slider)
```tsx
{field => (
)}
```
### 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
{field => (
LowMedium
High
)}
```
For horizontal arrangement of radio items, use a `Flex` or `Stack` wrapper inside the layout:
```tsx
import {Flex} from '@sentry/scraps/layout';
LowHigh;
```
### 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
{field => (
>
{(baseProps, {indicator}) => (
field.handleChange(e.target.value)}
/>
{indicator}
)}
)}
```
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` 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
```
### Row Layout (Horizontal)
Label on left (~50%), field on right. Compact layout for settings pages.
```tsx
```
### 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
// Compact: hint text appears in tooltip when hovering the label
// Also works with Stack layout
```
**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
{field => (
First Name:
)}
```
### 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
{/* ... */}{/* ... */}{/* ... */}{/* ... */}
```
---
## 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
// ✅ Provide a reason when disabling
```
---
## 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` — 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 = {
provider: null,
name: '',
};
// z.output has provider as non-null after refine
type FormOutput = z.output;
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
state.values.plan === 'enterprise'}>
{showBilling =>
showBilling ? (
{field => (
)}
) : null
}
```
---
## 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 (
{
return fetchMutation({
url: '/user/',
method: 'PUT',
data,
});
},
onSuccess: data => {
// Update React Query cache
queryClient.setQueryData(['user'], old => ({...old, ...data}));
},
}}
>
{field => (
)}
);
}
```
### 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
value
? 'This will remove all members without 2FA. Continue?'
: 'Are you sure you want to allow members without 2FA?'
}
mutationOptions={{...}}
>
{field => (
)}
```
**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
ResetSave Changes
```
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
;
// ✅ 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 = {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
// ✅ Provide fallback for optional fields
```
### 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` 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({...})
useMutation({...})
// ❌ Don't use generic types - breaks field type narrowing
const opts = mutationOptions({
mutationFn: (data: Record) => fetchMutation({...}),
});
// ❌ Don't tie mutation type to the zod schema
const opts = mutationOptions({
mutationFn: (data: Partial>) => 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) =>
fetchMutation({...}),
});
```
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
// ✅ Use Stack layout for long labels
```
---
## 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` if schema has `.refine()`)
- [ ] Set `validators: {onDynamic: schema}`
- [ ] Wrap with ``
- [ ] Use `` for each field
- [ ] Choose appropriate layout (Stack or Row)
- [ ] Handle server errors with `setFieldErrors`
- [ ] Add `` for submission
- [ ] Call `form.reset()` after successful mutation if the form stays on the page
When creating auto-save fields:
- [ ] Use `` 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 |