- name
- migrate-frontend-forms
- description
- Guide for migrating forms from the legacy JsonForm/FormModel system to the new TanStack-based form system.
# Form Migration Guide
This skill helps migrate forms from Sentry's legacy form system (JsonForm, FormModel) to the new TanStack-based system.
## Feature Mapping
| Old System | New System | Notes |
| -------------------- | --------------------- | ---------------------------------------------------- |
| `saveOnBlur: true` | `AutoSaveForm` | Default behavior |
| `confirm` | `confirm` prop | `string \| ((value) => string \| undefined)` |
| `showHelpInTooltip` | `variant="compact"` | On layout components |
| `disabledReason` | `disabled="reason"` | String shows tooltip |
| `extraHelp` | JSX in layout | Render `<Text>` below field |
| `getData` | `mutationFn` | Transform data in mutation function |
| `mapFormErrors` | Request error adapter | Explicit for regular forms; provided for auto-save |
| `saveMessage` | `onSuccess` | Show toast in mutation onSuccess callback |
| `formatMessageValue` | `onSuccess` | Control toast content in onSuccess callback |
| `resetOnError` | `onError` | Call form.reset() in mutation onError |
| `saveOnBlur: false` | `useScrapsForm` | Use regular form with explicit Save button |
| (automatic) | `form.reset()` | Call after successful mutation if form stays on page |
| `help` | `hintText` | On layout components |
| `label` | `label` | On layout components |
| `required` | `required` | On layout + Zod schema |
## Feature Details
### confirm → `confirm` prop
**Old:**
```tsx
{
name: 'require2FA',
type: 'boolean',
confirm: {
true: 'Enable 2FA for all members?',
false: 'Allow members without 2FA?',
},
isDangerous: true,
}
```
**New:**
```tsx
<AutoSaveForm
name="require2FA"
confirm={value =>
value
? 'Enable 2FA for all members?'
: 'Allow members without 2FA?'
}
{...}
>
```
### showHelpInTooltip → `variant="compact"`
**Old:**
```tsx
{
name: 'field',
help: 'This is help text',
showHelpInTooltip: true,
}
```
**New:**
```tsx
<field.Layout.Row
label="Field"
hintText="This is help text"
variant="compact"
>
```
### disabledReason → `disabled="reason"`
**Old:**
```tsx
{
name: 'field',
disabled: true,
disabledReason: 'Requires Business plan',
}
```
**New:**
```tsx
<field.Input
disabled="Requires Business plan"
{...}
/>
```
### extraHelp → JSX
**Old:**
```tsx
{
name: 'sensitiveFields',
help: 'Main help text',
extraHelp: 'Note: These fields apply org-wide',
}
```
**New:**
```tsx
<field.Layout.Stack label="Sensitive Fields" hintText="Main help text">
<field.TextArea {...} />
<Text size="sm" variant="muted">
Note: These fields apply org-wide
</Text>
</field.Layout.Stack>
```
### getData → `mutationFn`
The `getData` function transformed field data before sending to the API. In the new system, handle this in the `mutationFn`.
**Old:**
```tsx
// Wrap field value in 'options' key
{
name: 'sentry:csp_ignored_sources_defaults',
type: 'boolean',
getData: data => ({options: data}),
}
// Or extract/transform specific fields
{
name: 'slug',
getData: (data: {slug?: string}) => ({slug: data.slug}),
}
```
**New:**
```tsx
<AutoSaveForm
name="sentry:csp_ignored_sources_defaults"
schema={schema}
initialValue={project.options['sentry:csp_ignored_sources_defaults']}
mutationOptions={{
mutationFn: data => {
// Transform data before API call (equivalent to getData)
const transformed = {options: data};
return fetchMutation({
url: `/projects/${organization.slug}/${project.slug}/`,
method: 'PUT',
data: transformed,
});
},
}}
>
{field => (
<field.Layout.Row label="Use default ignored sources">
<field.Switch checked={field.state.value} onChange={field.handleChange} />
</field.Layout.Row>
)}
</AutoSaveForm>
```
**Simpler pattern** - If you just need to wrap the value:
```tsx
mutationOptions={{
mutationFn: fieldData => {
return fetchMutation({
url: `/projects/${org}/${project}/`,
method: 'PUT',
data: {options: fieldData}, // getData equivalent
});
},
}}
```
**Important: Typing mutations correctly**
The `mutationFn` should be typed with the API's data type (e.g., `Partial<Organization>`, `Partial<Project>`), **not** the schema-inferred type. The schema is for client-side field validation only — the mutation receives whatever the API endpoint accepts. Tying the mutation to the schema couples two unrelated concerns and can cause type errors when the schema types don't exactly match the API types.
```tsx
// ❌ Don't use generic types - breaks field type narrowing
mutationOptions={{
mutationFn: (data: Record<string, unknown>) => {
return fetchMutation({url: '/user/', method: 'PUT', data: {options: data}});
},
}}
// ❌ Don't tie mutation type to the zod schema
mutationOptions={{
mutationFn: (data: Partial<z.infer<typeof preferencesSchema>>) => {
return fetchMutation({url: '/user/', method: 'PUT', data: {options: data}});
},
}}
// ✅ Use the API's data type
mutationOptions={{
mutationFn: (data: Partial<UserDetails>) => {
return fetchMutation({url: '/user/', method: 'PUT', data: {options: data}});
},
}}
```
Make sure the zod schema's types are compatible with (i.e., assignable to) 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()`.
**NEVER pass call-site generics to `useMutation`, `mutationOptions`, or any TanStack Query function.** This applies to ALL generics — data, error, variables, AND context. Types must be inferred, not asserted. See the full rules in `static/AGENTS.md` under "TanStack Query Type Inference."
```tsx
// ❌ Generics on useMutation — NEVER do this
const mutation = useMutation<CodeOwner, RequestError, [Payload]>({
mutationFn: ([payload]) => fetchMutation({url, method: 'POST', data: payload}),
});
// ❌ Generics on mutationOptions — NEVER do this either
mutationOptions<unknown, RequestError, Variables, MyContext>({...})
// ❌ Explicit context type — inferred from onMutate return
type MyContext = {changeId: string};
// ❌ RequestError as error generic — it's a type assertion in disguise
// Other things can go wrong that would NOT yield a RequestError
// ✅ Type the mutationFn payload; fetchMutation<T> carries the return type
const mutation = useMutation({
mutationFn: (payload: {codeMappingId: string; raw: string}) =>
fetchMutation<CodeOwner>({
url: `/projects/${org}/${project}/codeowners/`,
method: 'POST',
data: payload,
}),
});
// ✅ Context is inferred from onMutate, error is Error by default
mutationOptions({
mutationFn: (variables: MyVars) => fetchMutation<MyResponse>({...}),
onMutate: async () => {
return {changeId: uniqueId()}; // context type inferred from this
},
onError: (_error, _vars, context) => {
// context?.changeId is typed automatically
// _error is Error — use runtime narrowing for RequestError
},
})
```
### mapFormErrors → `requestErrorToFieldErrors` + `setFieldErrors`
The `mapFormErrors` function transformed API error responses into field-specific
errors. In the new system, convert Sentry API errors with
`requestErrorToFieldErrors`, then pass the Scraps `FieldErrors` result to
`setFieldErrors`.
Do not pass `RequestError` directly to `setFieldErrors`. Scraps does not depend
on Sentry's API client types.
**Old:**
```tsx
// Form-level error transformer
function mapMonitorFormErrors(responseJson?: any) {
if (responseJson.config === undefined) {
return responseJson;
}
// Flatten nested config errors to dot notation
const {config, ...rest} = responseJson;
const configErrors = Object.fromEntries(
Object.entries(config).map(([key, value]) => [`config.${key}`, value])
);
return {...rest, ...configErrors};
}
<Form mapFormErrors={mapMonitorFormErrors} {...}>
```
**New:**
```tsx
import {setFieldErrors} from '@sentry/scraps/form';
import {RequestError} from 'sentry/utils/requestError/requestError';
const form = useScrapsForm({
...defaultFormOptions,
defaultValues: {...},
validators: {onDynamic: schema},
onSubmit: async ({value, formApi}) => {
try {
await mutation.mutateAsync(value);
} catch (error) {
if (!(error instanceof RequestError)) {
return;
}
// Keep custom mapping only when the legacy form reshaped the response.
const responseJson = error.responseJSON;
if (responseJson?.config) {
// Flatten nested errors to dot notation
const {config, ...rest} = responseJson;
const errors: Record<string, {message: string}> = {};
for (const [key, value] of Object.entries(rest)) {
errors[key] = {message: Array.isArray(value) ? value[0] : String(value)};
}
for (const [key, value] of Object.entries(config)) {
errors[`config.${key}`] = {message: Array.isArray(value) ? value[0] : String(value)};
}
setFieldErrors(formApi, errors);
}
}
},
});
```
**Simpler pattern** - For flat error responses:
```tsx
import {setFieldErrors} from '@sentry/scraps/form';
import {RequestError} from 'sentry/utils/requestError/requestError';
import {requestErrorToFieldErrors} from 'sentry/utils/requestError/requestErrorToFieldErrors';
onSubmit: async ({value, formApi}) => {
try {
await mutation.mutateAsync(value);
} catch (error) {
if (!(error instanceof RequestError)) {
addErrorMessage(t('Unable to save changes.'));
return;
}
const handled = setFieldErrors(
formApi,
requestErrorToFieldErrors(error, formApi.state.values)
);
if (!handled) {
addErrorMessage(t('Unable to save changes.'));
}
}
},
```
`requestErrorToFieldErrors` accepts `RequestError`. Narrow unknown errors at the
Sentry call site before conversion. The adapter filters response keys against
`formApi.state.values` and returns the Scraps field-error shape. Use a direct
`FieldErrors` object only when the migration needs custom response reshaping,
such as the nested `config` example above.
For `AutoSaveForm`, standard request error handling is automatic. The Sentry
form error provider uses `requestErrorToFieldErrors` for field errors and
`getRequestErrorUserMessage` for request detail or status messages. Do not add
the regular-form catch block to each auto-save field.
> **Note**: `setFieldErrors` supports nested paths with dot notation: `'config.schedule': {message: 'Invalid schedule'}`
### saveMessage → `onSuccess`
The `saveMessage` showed a custom toast/alert after successful save. In the new system, handle this in the mutation's `onSuccess` callback.
**Old:**
```tsx
{
name: 'fingerprintingRules',
saveOnBlur: false,
Ver en GitHub