- name
- generate-frontend-forms
- description
- Guide for creating forms using Sentry's new form system. Use when implementing forms, form fields, validation, or auto-save functionality.
# Form System Guide
This skill provides patterns for building forms using Sentry's new form system built on TanStack React Form and Zod validation.
## Core Principle
- Always use the new form system (`useScrapsForm`, `AutoSaveForm`) for new forms. Never create new forms with the legacy JsonForm or Reflux-based systems.
- All forms should be schema based. DO NOT create a form without schema validation.
## Imports
All form components are exported from `@sentry/scraps/form`:
```tsx
import {z} from 'zod';
import {
AutoSaveForm,
defaultFormOptions,
setFieldErrors,
useScrapsForm,
} from '@sentry/scraps/form';
```
> **Important**: DO NOT import from deeper paths, like '@sentry/scraps/form/field'. You can only use what is part of the PUBLIC interface in the index file in @sentry/scraps/form.
---
## Form Hook: `useScrapsForm`
The main hook for creating forms with validation and submission handling.
### Basic Usage
```tsx
import {z} from 'zod';
import {defaultFormOptions, useScrapsForm} from '@sentry/scraps/form';
const schema = z.object({
email: z.string().email('Invalid email'),
name: z.string().min(2, 'Name must be at least 2 characters'),
});
function MyForm() {
const form = useScrapsForm({
...defaultFormOptions,
defaultValues: {
email: '',
name: '',
},
validators: {
onDynamic: schema,
},
onSubmit: ({value, formApi}) => {
// Handle submission
console.log(value);
},
});
return (
<form.AppForm form={form}>
<form.AppField name="email">
{field => (
<field.Layout.Stack label="Email" required>
<field.Input value={field.state.value} onChange={field.handleChange} />
</field.Layout.Stack>
)}
</form.AppField>
<form.SubmitButton>Submit</form.SubmitButton>
</form.AppForm>
);
}
```
> **Important**: Always spread `defaultFormOptions` first. It configures validation to run on submit initially, then on every change after the first submission. This is why validators are defined as `onDynamic`, and it's what provides a consistent UX.
### Returned Properties
| Property | Description |
| ---------------- | ------------------------------------------------------------------------------------------------------------- |
| `AppForm` | Root wrapper component (provides form context and renders `<form>` element). Must receive `form={form}` prop. |
| `AppField` | Field renderer component |
| `FieldGroup` | Section grouping with title |
| `SubmitButton` | Pre-wired submit button |
| `Subscribe` | Subscribe to form state changes |
| `reset()` | Reset form to default values |
| `handleSubmit()` | Manually trigger submission |
---
## Field Components
All fields are accessed via the `field` render prop and follow consistent patterns.
### Input Field (Text)
```tsx
<form.AppField name="firstName">
{field => (
<field.Layout.Stack label="First Name" required>
<field.Input
value={field.state.value}
onChange={field.handleChange}
placeholder="Enter your name"
/>
</field.Layout.Stack>
)}
</form.AppField>
```
### Number Field
```tsx
<form.AppField name="age">
{field => (
<field.Layout.Stack label="Age" required>
<field.Number
value={field.state.value}
onChange={field.handleChange}
min={0}
max={120}
step={1}
/>
</field.Layout.Stack>
)}
</form.AppField>
```
### Select Field (Single)
```tsx
<form.AppField name="country">
{field => (
<field.Layout.Stack label="Country">
<field.Select
value={field.state.value}
onChange={field.handleChange}
options={[
{value: 'us', label: 'United States'},
{value: 'uk', label: 'United Kingdom'},
]}
/>
</field.Layout.Stack>
)}
</form.AppField>
```
### Select Field (Multiple)
```tsx
<form.AppField name="tags">
{field => (
<field.Layout.Stack label="Tags">
<field.Select
multiple
value={field.state.value}
onChange={field.handleChange}
options={[
{value: 'bug', label: 'Bug'},
{value: 'feature', label: 'Feature'},
]}
clearable
/>
</field.Layout.Stack>
)}
</form.AppField>
```
### Switch Field (Boolean)
```tsx
<form.AppField name="notifications">
{field => (
<field.Layout.Stack label="Enable notifications">
<field.Switch checked={field.state.value} onChange={field.handleChange} />
</field.Layout.Stack>
)}
</form.AppField>
```
### TextArea Field
```tsx
<form.AppField name="bio">
{field => (
<field.Layout.Stack label="Bio">
<field.TextArea
value={field.state.value}
onChange={field.handleChange}
rows={4}
placeholder="Tell us about yourself"
/>
</field.Layout.Stack>
)}
</form.AppField>
```
### Range Field (Slider)
```tsx
<form.AppField name="volume">
{field => (
<field.Layout.Stack label="Volume">
<field.Range
value={field.state.value}
onChange={field.handleChange}
min={0}
max={100}
step={10}
/>
</field.Layout.Stack>
)}
</form.AppField>
```
### Radio Field
Radio fields use a composable API with `Radio.Group` and `Radio.Item`. `Radio.Group` provides group context that changes how the label is rendered for proper accessibility semantics.
> **Important**: The layout (and its label) **must** be rendered _inside_ `Radio.Group`. The group context is provided by `Radio.Group`, so placing the layout outside will result in incorrect accessibility semantics.
```tsx
<form.AppField name="priority">
{field => (
<field.Radio.Group value={field.state.value} onChange={field.handleChange}>
<field.Layout.Stack label="Priority">
<field.Radio.Item value="low">Low</field.Radio.Item>
<field.Radio.Item value="medium">Medium</field.Radio.Item>
<field.Radio.Item value="high" description="Urgent issues">
High
</field.Radio.Item>
</field.Layout.Stack>
</field.Radio.Group>
)}
</form.AppField>
```
For horizontal arrangement of radio items, use a `Flex` or `Stack` wrapper inside the layout:
```tsx
import {Flex} from '@sentry/scraps/layout';
<field.Radio.Group value={field.state.value} onChange={field.handleChange}>
<field.Layout.Row label="Priority">
<Flex gap="lg">
<field.Radio.Item value="low">Low</field.Radio.Item>
<field.Radio.Item value="high">High</field.Radio.Item>
</Flex>
</field.Layout.Row>
</field.Radio.Group>;
```
### Custom Fields with BaseField
For one-off fields that don't have a built-in component (e.g. a color picker, or any custom input), use `field.Base`. It provides a render prop with all the necessary accessibility and form integration props (`ref`, `disabled`, `aria-invalid`, `aria-describedby`, `onBlur`, `name`, `id`) that you spread onto your native element.
```tsx
<form.AppField name="color">
{field => (
<field.Layout.Row label="Brand Color">
<field.Base<HTMLInputElement>>
{(baseProps, {indicator}) => (
<Flex flexGrow={1}>
<input
{...baseProps}
type="color"
value={field.state.value}
onChange={e => field.handleChange(e.target.value)}
/>
{indicator}
</Flex>
)}
</field.Base>
</field.Layout.Row>
)}
</form.AppField>
```
The render prop receives two arguments:
1. **`baseProps`** — accessibility and form integration props (`ref`, `disabled`, `aria-invalid`, `aria-describedby`, `onBlur`, `name`, `id`) to spread onto your element
2. **`{indicator}`** — the auto-save status indicator (spinner/checkmark) as a React node, which you can place wherever makes sense in your custom layout
The element type is inferred from the passed `ref`, so if you don't pass one, you have to manually annotate it with `<field.Base<HTMLInputElement>>`.
`field.Base` automatically handles:
- Merging refs (for scroll-to-hash and external ref forwarding)
- Disabling the field when auto-save is pending
- Setting `aria-invalid` based on validation state
- Linking to hint text via `aria-describedby`
Use `field.Base` instead of building custom wrappers that duplicate this logic. It works with any native HTML element or third-party component that accepts standard props.
---
## Layouts
Two layout options are available for positioning labels and fields.
### Stack Layout (Vertical)
Label above, field below. Best for forms with longer labels or mobile layouts.
```tsx
<field.Layout.Stack
label="Email Address"
hintText="We'll never share your email"
required
>
<field.Input value={field.state.value} onChange={field.handleChange} />
</field.Layout.Stack>
```
### Row Layout (Horizontal)
Label on left (~50%), field on right. Compact layout for settings pages.
```tsx
<field.Layout.Row label="Email Address" hintText="We'll never share your email" required>
<field.Input value={field.state.value} onChange={field.handleChange} />
</field.Layout.Row>
```
### Compact Variant
Both Stack and Row layouts support a `variant="compact"` prop. In compact mode, the hint text appears as a tooltip on the label instead of being displayed below. This saves vertical space while still providing the hint information.
```tsx
// Default: hint text appears below the label
<field.Layout.Row label="Email" hintText="We'll never share your email">
<field.Input ... />
</field.Layout.Row>
// Compact: hint text appears in tooltip when hovering the label
<field.Layout.Row label="Email" hintText="We'll never share your email" variant="compact">
<field.Input ... />
</field.Layout.Row>
// Also works with Stack layout
<field.Layout.Stack label="Email" hintText="We'll never share your email" variant="compact">
<field.Input ... />
</field.Layout.Stack>
```
**When to Use Compact**:
- Settings pages with many fields where vertical space is limited
- Forms where hint text is supplementary, not essential
- Dashboards or panels with constrained height
### Custom Layouts
You are allowed to create new layouts if necessary, or not use any layouts at all. Without a layout, you _should_ render `field.meta.Label` and optionally `field.meta.HintText` for a11y.
```tsx
<form.AppField name="firstName">
{field => (
<Flex gap="md">
<field.Meta.Label required>First Name:</field.Meta.Label>
<field.Input value={field.state.value ?? ''} onChange={field.handleChange} />
</Flex>
)}
</form.AppField>
```
### Layout Props
| Prop | Type | Description |
View on GitHub