Skip to main content

migrate-frontend-forms

Guide for migrating forms from the legacy JsonForm/FormModel system to the new TanStack-based form system.

Source facts

Repository
getsentry/sentry
Last source activity
August 28, 2026 at 08:09
Detected SKILL.md language
English
Stars
44,880
Forks
4,869

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
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,
View on GitHub
This SKILL.md is very large, so SkillsMP previews the first section here. View on GitHub