--- 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 `
` 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 {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 => ( Low Medium High )} ``` For horizontal arrangement of radio items, use a `Flex` or `Stack` wrapper inside the layout: ```tsx import {Flex} from '@sentry/scraps/layout'; Low High ; ``` ### 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 Reset Save 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 |