migrate-frontend-forms
Original:🇺🇸 English
Translated
Guide for migrating forms from the legacy JsonForm/FormModel system to the new TanStack-based form system.
2installs
Sourcegetsentry/sentry
Added on
NPX Install
npx skill4agent add getsentry/sentry migrate-frontend-formsTags
Translated version includes tags in frontmatterSKILL.md Content
View Translation Comparison →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 |
|---|---|---|
| | Default behavior |
| | |
| | On layout components |
| | String shows tooltip |
| JSX in layout | Render |
| | Transform data in mutation function |
| | Transform API errors in catch block |
| | Show toast in mutation onSuccess callback |
| | Control toast content in onSuccess callback |
| | Call form.reset() in mutation onError |
| | Use regular form with explicit Save button |
| | On layout components |
| | On layout components |
| | On layout + Zod schema |
Feature Details
confirm → confirm
prop
confirmOld:
tsx
{
name: 'require2FA',
type: 'boolean',
confirm: {
true: 'Enable 2FA for all members?',
false: 'Allow members without 2FA?',
},
isDangerous: true,
}New:
tsx
<AutoSaveField
name="require2FA"
confirm={value =>
value
? 'Enable 2FA for all members?'
: 'Allow members without 2FA?'
}
{...}
>showHelpInTooltip → variant="compact"
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"
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
mutationFnThe function transformed field data before sending to the API. In the new system, handle this in the .
getDatamutationFnOld:
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
<AutoSaveField
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>
)}
</AutoSaveField>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 with mixed-type schemas
When using with schemas that have mixed types (e.g., strings and booleans), the mutation function must be typed using the schema-inferred type. Using generic types like breaks TanStack Form's ability to narrow field types based on the field name.
AutoSaveFieldRecord<string, unknown>tsx
const preferencesSchema = z.object({
theme: z.string(),
language: z.string(),
notifications: z.boolean(),
});
type Preferences = z.infer<typeof preferencesSchema>;
// ❌ Don't use generic types - breaks field type narrowing
mutationOptions={{
mutationFn: (data: Record<string, unknown>) => {
return fetchMutation({url: '/user/', method: 'PUT', data: {options: data}});
},
}}
// ✅ Use schema-inferred type for proper type narrowing
mutationOptions={{
mutationFn: (data: Partial<Preferences>) => {
return fetchMutation({url: '/user/', method: 'PUT', data: {options: data}});
},
}}mapFormErrors → setFieldErrors
setFieldErrorsThe function transformed API error responses into field-specific errors. In the new system, handle this in the catch block using .
mapFormErrorssetFieldErrorsOld:
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';
const form = useScrapsForm({
...defaultFormOptions,
defaultValues: {...},
validators: {onDynamic: schema},
onSubmit: async ({value, formApi}) => {
try {
await mutation.mutateAsync(value);
} catch (error) {
// Transform API errors and set on fields (equivalent to mapFormErrors)
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
onSubmit: async ({value, formApi}) => {
try {
await mutation.mutateAsync(value);
} catch (error) {
// API returns {email: ['Already taken'], username: ['Invalid']}
const errors = error.responseJSON;
if (errors) {
setFieldErrors(formApi, {
email: {message: errors.email?.[0]},
username: {message: errors.username?.[0]},
});
}
}
},Note:supports nested paths with dot notation:setFieldErrors'config.schedule': {message: 'Invalid schedule'}
saveMessage → onSuccess
onSuccessThe showed a custom toast/alert after successful save. In the new system, handle this in the mutation's callback.
saveMessageonSuccessOld:
tsx
{
name: 'fingerprintingRules',
saveOnBlur: false,
saveMessageAlertVariant: 'info',
saveMessage: t('Changing fingerprint rules will apply to future events only.'),
}New:
tsx
import {addSuccessMessage} from 'sentry/actionCreators/indicator';
<AutoSaveField
name="fingerprintingRules"
schema={schema}
initialValue={project.fingerprintingRules}
mutationOptions={{
mutationFn: data => fetchMutation({...}),
onSuccess: () => {
// Custom success message (equivalent to saveMessage)
addSuccessMessage(t('Changing fingerprint rules will apply to future events only.'));
},
}}
>formatMessageValue → onSuccess
onSuccessThe controlled how the changed value appeared in success toasts. Setting it to disabled showing the value entirely (useful for large text fields). In the new system, you control this directly in .
formatMessageValuefalseonSuccessOld:
tsx
{
name: 'fingerprintingRules',
saveMessage: t('Rules updated'),
formatMessageValue: false, // Don't show the (potentially huge) value in toast
}New:
tsx
mutationOptions={{
mutationFn: data => fetchMutation({...}),
onSuccess: () => {
// Just show the message, no value (equivalent to formatMessageValue: false)
addSuccessMessage(t('Rules updated'));
},
}}
// Or if you want to show a formatted value:
onSuccess: (data) => {
addSuccessMessage(t('Slug changed to %s', data.slug));
},resetOnError → onError
onErrorThe option reverted fields to their previous value when a save failed. In the new system, call in the mutation's callback.
resetOnErrorform.reset()onErrorOld:
tsx
// Form-level reset on error
<Form resetOnError apiEndpoint="/auth/" {...}>
// Or field-level (BooleanField always resets on error)
<FormField resetOnError name="enabled" {...}>New (with useScrapsForm):
tsx
const form = useScrapsForm({
...defaultFormOptions,
defaultValues: {password: ''},
validators: {onDynamic: schema},
onSubmit: async ({value}) => {
try {
await mutation.mutateAsync(value);
} catch (error) {
// Reset form to previous values on error (equivalent to resetOnError)
form.reset();
throw error; // Re-throw if you want error handling to continue
}
},
});New (with AutoSaveField):
tsx
<AutoSaveField
name="enabled"
schema={schema}
initialValue={settings.enabled}
mutationOptions={{
mutationFn: data => fetchMutation({...}),
onError: () => {
// The field automatically shows error state via TanStack Query
// If you need to reset the value, you can pass a reset callback
},
}}
>Note: AutoSaveField with TanStack Query already handles error states gracefully - the mutation'sstate is reflected in the UI. Manual reset is typically only needed for specific UX requirements like password fields.isError
saveOnBlur: false → useScrapsForm
useScrapsFormFields with showed an inline alert with Save/Cancel buttons instead of auto-saving. This was used for dangerous operations (slug changes) or large text edits (fingerprint rules).
saveOnBlur: falseIn the new system, use a regular form with and an explicit Save button. This preserves the UX of showing warnings before committing.
useScrapsFormOld:
tsx
{
name: 'slug',
type: 'string',
saveOnBlur: false,
saveMessageAlertVariant: 'warning',
saveMessage: t("Changing a project's slug can break your build scripts!"),
}New:
tsx
import {Alert} from '@sentry/scraps/alert';
import {Button} from '@sentry/scraps/button';
import {defaultFormOptions, useScrapsForm} from '@sentry/scraps/form';
const slugSchema = z.object({
slug: z.string().min(1, 'Slug is required'),
});
function SlugForm({project}: {project: Project}) {
const mutation = useMutation({
mutationFn: (data: {slug: string}) =>
fetchMutation({url: `/projects/${org}/${project.slug}/`, method: 'PUT', data}),
});
const form = useScrapsForm({
...defaultFormOptions,
defaultValues: {slug: project.slug},
validators: {onDynamic: slugSchema},
onSubmit: ({value}) => mutation.mutateAsync(value).catch(() => {}),
});
return (
<form.AppForm form={form}>
<form.AppField name="slug">
{field => (
<field.Layout.Stack label="Project Slug">
<field.Input value={field.state.value} onChange={field.handleChange} />
</field.Layout.Stack>
)}
</form.AppField>
{/* Warning shown before saving (equivalent to saveMessage) */}
<Alert variant="warning">
{t("Changing a project's slug can break your build scripts!")}
</Alert>
<Flex gap="sm" justify="end">
<Button onClick={() => form.reset()}>Cancel</Button>
<form.SubmitButton>Save</form.SubmitButton>
</Flex>
</form.AppForm>
);
}When to use this pattern:
- Dangerous operations where users should see a warning before committing (slug changes, security tokens)
- Large multiline text fields where you want to finish editing before saving (fingerprint rules, filters)
- Any field where auto-save doesn't make sense
Preserving Form Search Functionality
Sentry's SettingsSearch allows users to search for individual settings fields. When migrating forms, you must preserve this searchability by wrapping migrated forms with .
FormSearchThe FormSearch
Component
FormSearchFormSearchroutetsx
import {FormSearch} from 'sentry/components/core/form';
<FormSearch route="/settings/account/details/">
<FieldGroup title={t('Account Details')}>
<AutoSaveField name="name" schema={schema} initialValue={user.name} mutationOptions={...}>
{field => (
<field.Layout.Row label={t('Name')} hintText={t('Your full name')} required>
<field.Input />
</field.Layout.Row>
)}
</AutoSaveField>
</FieldGroup>
</FormSearch>Props:
| Prop | Type | Description |
|---|---|---|
| | The settings route for this form (e.g., |
| | The form content — rendered unchanged at runtime. |
Rules:
- The must match the settings page URL exactly (including trailing slash).
route - Wrap the entire form section with a single , not individual fields.
FormSearch - Every or
<AutoSaveField>inside a<form.AppField>will be indexed. Make sureFormSearchandlabelare plain string literals orhintTextcalls — computed/dynamic strings will be skipped by the extractor.t()
The Form Field Registry
After adding or updating wrappers, regenerate the field registry so that search results stay up to date:
FormSearchbash
pnpm run extract-form-fieldsThis script () scans all TSX files, finds components, extracts field metadata (, , , ), and writes the generated registry to . Commit this generated file alongside your migration PR — it is part of the source tree.
scripts/extractFormFields.ts<FormSearch>namelabelhintTextroutestatic/app/components/core/form/generatedFieldRegistry.tsRun the command after any change to forms inside awrapper (adds, removals, label changes). The generated file is checked in and should not be edited manually.FormSearch
Migration: Old Forms Already Searchable
If the legacy being migrated was already indexed by SettingsSearch (i.e., it had entries in ), you must add a wrapper to the new form so search functionality is preserved. The old and new sources coexist — new registry entries take precedence over old ones for the same route + field combination — but once you remove the legacy form the old entries will disappear.
JsonFormsentry/data/formsFormSearchIntentionally Not Migrated
| Feature | Usage | Reason |
|---|---|---|
| 3 forms | Undo in toasts adds complexity with minimal benefit. Use simple error toasts instead. |
Migration Checklist
- Replace JsonForm/FormModel with useScrapsForm or AutoSaveField
- Convert field config objects to JSX AppField components
- Replace →
helpon layoutshintText - Replace →
showHelpInTooltipvariant="compact" - Replace →
disabledReasondisabled="reason string" - Replace → additional JSX in layout
extraHelp - Convert object to function:
confirm(value) => message | undefined - Handle in mutationFn
getData - Handle with setFieldErrors in catch
mapFormErrors - Handle in onSuccess callback
saveMessage - Convert fields to regular forms with Save button
saveOnBlur: false - Verify cache updates merge with existing data (use updater function) — some API endpoints may return partial objects
onSuccess - Wrap the migrated form with if the old form was searchable in SettingsSearch
<FormSearch route="..."> - Run and commit the updated
pnpm run extract-form-fieldsgeneratedFieldRegistry.ts