Guide for creating forms using Sentry's new form system. Use when implementing forms, form fields, validation, or auto-save functionality.
This skill provides patterns for building forms using Sentry's new form system built on TanStack React Form and Zod validation.
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.
All form components are exported from @sentry/scraps/form:
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.
useScrapsFormThe main hook for creating forms with validation and submission handling.
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
defaultFormOptionsfirst. It configures validation to run on submit initially, then on every change after the first submission. This is why validators are defined asonDynamic, and it's what provides a consistent UX.
| 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 |
All fields are accessed via the field render prop and follow consistent patterns.
<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>
<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>
<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>
<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>
<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>
<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>
<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 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 byRadio.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>;
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:
baseProps — accessibility and form integration props (ref, disabled, aria-invalid, aria-describedby, onBlur, name, id) to spread onto your element{indicator} — the auto-save status indicator (spinner/checkmark) as a React node, which you can place wherever makes sense in your custom layoutThe 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:
aria-invalid based on validation statearia-describedbyUse 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.
Two layout options are available for positioning labels and fields.
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>
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>
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:
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>
| 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 |
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>
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}
/>
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'),
}),
});
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:
defaultValues explicitly as z.input<typeof schema> — this allows null as an initial value.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. Theschema.parse()approach is both type-safe and validates at runtime.
Use .refine() for cross-field validation:
const schema = z
.object({
password: z.string(),
confirmPassword: z.string(),
})
.refine(data => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
});
Use form.Subscribe to show/hide fields based on other field values:
<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>
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';
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:
setFieldErrorssupports nested paths with dot notation:'address.city': {message: 'City not found'}
Validation errors automatically show as a warning icon with tooltip in the field's trailing area. No additional code needed.
For settings pages where each field saves independently, use AutoSaveForm.
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>
);
}
| 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 |
The form system automatically shows:
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.
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:
| 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:
// ✅ 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}
Important: Always use TanStack Query mutations (
useMutation) for form submissions. This ensures proper loading states, error handling, and cache management.
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(() => {});
},
});
// ...
}
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:
AutoSaveFormhandles this automatically. You only need to add this when usinguseScrapsForm.
<Flex gap="md" justify="end">
<Button onClick={() => form.reset()}>Reset</Button>
<form.SubmitButton>Save Changes</form.SubmitButton>
</Flex>
The SubmitButton automatically:
// ❌ 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},
});
// ❌ Don't forget defaultFormOptions
const form = useScrapsForm({
defaultValues: {name: ''},
});
// ✅ Always spread defaultFormOptions first
const form = useScrapsForm({
...defaultFormOptions,
defaultValues: {name: ''},
});
// ❌ 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(() => {});
},
});
// ❌ 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(() => {});
};
// ❌ 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 ?? ''} />
// ❌ Don't use generic error messages
z.string().min(1);
// ✅ Provide helpful, specific error messages
z.string().min(1, 'Email address is required');
// ❌ 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
},
}}
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}));
},
}}
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.
// ❌ 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({...}),
});
// ✅ Use the API's data type
const opts = mutationOptions({
mutationFn: (data: Partial<UserDetails>) => 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().
// ❌ 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(() => {});
};
// ❌ 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">
When creating a new form:
@sentry/scraps/form and zoduseScrapsForm with ...defaultFormOptionsdefaultValues matching schema shape (use z.input<typeof schema> if schema has .refine())validators: {onDynamic: schema}<form.AppForm form={form}><form.AppField> for each fieldsetFieldErrors<form.SubmitButton> for submissionform.reset() after successful mutation if the form stays on the pageWhen creating auto-save fields:
<AutoSaveForm> componentschema for validationinitialValue from current datamutationOptions with mutationFnonSuccess callback| 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 |