关于 generate-frontend-forms
为 Sentry 开发者提供基于 TanStack React Form 和 Zod 的表单构建指南,强调 schema 验证、无障碍字段集成及自动保存模式。
主要功能
- 01使用 useScrapsForm 构建表单
- 02通过 Zod 定义验证 schema
- 03支持 AutoSaveForm 自动保存
- 04集成自定义字段与布局
- 05处理字段及服务端错误
适用场景
- 01为新功能创建经过验证的表单
- 02构建按字段自动保存的设置页
- 03将后端验证错误映射到字段
- 04添加自定义输入控件
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ getsentry/ sentry --skill generate-frontend-forms安装后在 Claude Code 中输入/generate-frontend-forms确认已加载。
详细介绍
表单系统指南
本技能介绍了使用基于 TanStack React Form 和 Zod 验证构建的 Sentry 新表单系统来构建表单的模式。
核心原则
-
新表单始终使用新表单系统(
useScrapsForm、AutoSaveForm)。切勿使用旧版 JsonForm 或基于 Reflux 的系统创建新表单。 -
所有表单都应基于架构。请勿创建没有架构验证的表单。
导入
所有表单组件都从 @sentry/scraps/form 导出:
import {z} from 'zod';
import {
AutoSaveForm,
defaultFormOptions,
DisplayField,
setFieldErrors,
useScrapsForm,
} from '@sentry/scraps/form';
重要:请勿从更深层的路径导入,例如 '@sentry/scraps/form/field'。你只能使用 @sentry/scraps/form 中 index 文件所提供的公共接口。
表单 Hook:useScrapsForm
用于创建表单并处理验证与提交的主要 Hook。
基本用法
import {z} from 'zod';
import {defaultFormOptions, useScrapsForm} from '@sentry/scraps/form';
const schema = z.object({
email: z.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>
);
}
重要:始终先展开
defaultFormOptions。它会配置验证,使其最初在提交时运行,并在首次提交后于每次更改时运行。这就是验证器要定义为onDynamic的原因,也是实现一致用户体验的方式。
返回的属性
| 属性 | 说明 |
|---|---|
AppForm | 根包装组件(提供表单上下文并渲染 <form> 元素)。必须接收 form={form} 属性。 |
AppField | 字段渲染器组件 |
FieldGroup | 带标题的分区分组 |
SubmitButton | 预先配置好的提交按钮 |
Subscribe | 订阅表单状态变化 |
reset() | 将表单重置为默认值 |
handleSubmit() | 手动触发表单提交 |
字段组件
已注册的字段通过 field 渲染属性访问,并遵循一致的模式。对于不含输入框的只读信息,请按下文所述使用 DisplayField。
输入字段(文本)
<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>
数字字段
<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>
选择字段(单选)
使用 field.Select 进行表单选项选择。不要使用基础 Menu 组件组合表单选择器;这些组件是供共享控件使用的内部展示原语。
<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>
选择字段(多选)
<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>
开关字段(布尔值)
<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>
复选框组(多项选择)
使用 field.Checkbox.Group 和 field.Checkbox.Item,将多项选择存储为一个字符串数组。请阅读复选框组文档,了解示例、布局、验证、禁用状态和自动保存行为。
文本区域字段
<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>
范围字段(滑块)
<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>
单选按钮字段
单选按钮字段使用可组合的 API,包括 Radio.Group 和 Radio.Item。Radio.Group 提供组上下文,以更改标签的渲染方式,从而遵循正确的无障碍语义。
重要:布局(及其标签)必须渲染在
Radio.Group内部。组上下文由Radio.Group提供,因此将布局放在外部会导致无障碍语义不正确。
<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>
若要水平排列单选项,请在布局内部使用 Flex 或 Stack 包装器:
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>;
使用 BaseField 创建自定义字段
对于没有内置组件的一次性字段(例如颜色选择器或任何自定义输入框),请使用 field.Base。它提供一个渲染属性,其中包含所有必需的无障碍和表单集成属性(ref、disabled、aria-invalid、aria-describedby、onBlur、name、id),你可以将这些属性展开并传递给原生元素。
<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>
渲染属性会接收两个参数:
baseProps— 要展开并传递给元素的无障碍和表单集成属性(ref、disabled、aria-invalid、aria-describedby、onBlur、name、id){indicator}— 自动保存状态指示器(微调器/勾号),以 React 节点的形式提供,你可以根据自定义布局将它放在合适的位置
元素类型会根据传入的 ref 推断;因此,如果你没有传入 ref,就必须手动将其标注为 <field.Base<HTMLInputElement>>。
field.Base 会自动处理以下事项:
- 合并 refs(用于滚动到哈希目标和转发外部 ref)
- 自动保存待处理时禁用字段
- 根据验证状态设置
aria-invalid - 通过
aria-describedby关联提示文本
请使用 field.Base,而不要构建重复实现这些逻辑的自定义包装器。它适用于任何原生 HTML 元素或接受标准属性的第三方组件。
布局
注册字段请使用 field.Layout.Stack 或 field.Layout.Row。这些布局需要字段上下文,并提供标签与输入框的关联。对于仅用于展示的信息,请改用 DisplayField.Stack 或 DisplayField.Row。
Stack 布局(垂直)
标签在上,字段在下。最适合标签较长的表单或移动端布局。
<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 布局(水平)
标签在左侧(约占 50%),字段在右侧。适用于设置页面的紧凑布局。
<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>
紧凑变体
Stack 和 Row 布局都支持 variant="compact" 属性。在紧凑模式下,提示文本会作为工具提示显示在标签上,而不是显示在下方。这样可以节省垂直空间,同时仍能提供提示信息。
// 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>
何时使用紧凑模式:
- 字段较多且垂直空间有限的设置页面
- 提示文本仅作补充、并非必不可少的表单
- 高度受限的仪表板或面板
自定义布局
必要时,你可以创建新布局,也可以完全不使用任何布局。不使用布局时,为了满足无障碍要求,_应当_渲染 field.meta.Label,并可选择渲染 field.meta.HintText。
<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>
布局属性
| 属性 | 类型 | 描述 |
|---|---|---|
label | string | 字段标签文本 |
hintText | string | 辅助文本(默认显示在标签下方;紧凑模式下显示为工具提示) |
required | boolean | 显示必填指示器 |
variant | "compact" | 将提示文本显示为工具提示,而不是显示在标签下方 |
仅显示信息
对于日期、状态文本和其他无需输入的信息,请使用 DisplayField.Row 或 DisplayField.Stack。从 @sentry/scraps/form 导入 DisplayField,并直接在 FieldGroup 中或与已注册字段并列渲染:
<DisplayField.Row label={t('Created')}>
<Text>
<DateTime date={data.dateCreated} />
</Text>
</DisplayField.Row>
Row使用与field.Layout.Row相同的响应式布局:存在查询容器时,在md容器断点以下会堆叠显示。没有查询容器时,则保持在同一行。Stack始终将值放在术语下方。- 两者都接受
label、children、hintText、padding和variant="compact"。紧凑模式会将提示放在工具提示中;否则,提示会在行布局中显示于术语下方,在堆叠布局中显示于值之后。 - 它们会渲染
dl/dt/dd,不包含<label>、输入关联、验证或错误焦点注册。无需表单或字段上下文。 - 请显式设置显示值的样式,例如使用
<Text>;DisplayField不会用Text包装其子元素。
不要使用 AppField 或 AutoSaveForm 注册仅用于显示的信息,也不要仅为了布局而将其加入架构或 defaultValues。对于从表单状态派生的值,请使用 form.Subscribe 在渲染期间计算,而不是通过 form.setFieldValue 存储第二个值:
<form.Subscribe selector={state => state.values.provider}>
{provider => (
<DisplayField.Stack label={t('Selected provider')}>
<Text>{provider ?? t('None selected')}</Text>
</DisplayField.Stack>
)}
</form.Subscribe>
只读输入仍是真正的输入。对于用户需要从输入中选择或复制的值,请保留 AppField、field.Layout.* 和 field.Input readOnly。在 field.Base 上保留处理表单状态的自定义控件;不要用 DisplayField 替换它们。
字段组
将相关字段按标题分组到各个区块中。
<form.FieldGroup title="Personal Information">
<form.AppField name="firstName">{/* ... */}</form.AppField>
<form.AppField name="lastName">{/* ... */}</form.AppField>
</form.FieldGroup>
<form.FieldGroup title="Contact Information">
<form.AppField name="email">{/* ... */}</form.AppField>
<form.AppField name="phone">{/* ... */}</form.AppField>
</form.FieldGroup>
禁用状态
字段的 disabled 可以是布尔值或字符串。提供字符串时,该字符串会显示为工具提示,解释字段被禁用的原因。
// ❌ Don't disable without explanation
<field.Input disabled value={field.state.value} onChange={field.handleChange} />
// ✅ Provide a reason when disabling
<field.Input
disabled="This feature requires a Business plan"
value={field.state.value}
onChange={field.handleChange}
/>
使用 Zod 进行验证
架构定义
import {z} from 'zod';
const userSchema = z.object({
email: z.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'),
}),
});
使用 Refine 处理可空字段
当字段初始值为 null(例如,没有初始选项的必填选择字段)时,请在架构中使用 .nullable().refine()。这会使架构的 输入 类型(允许 null)与 输出 类型(不允许 null)有所区别。要正确处理这种情况:
- 将
defaultValues显式声明为z.input<typeof schema>类型——这样初始值可以是null。 - 在
onSubmit中调用schema.parse(value),将类型从z.input收窄为z.output,在传给变更操作前移除null。
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<typeof schema> = {
provider: null,
name: '',
};
// z.output<typeof schema> has provider as non-null after refine
type FormOutput = z.output<typeof schema>;
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(() => {});
},
});
重要:不要使用非空断言(
value.provider!)或类型转换来规避可空字段。使用schema.parse()的方式既类型安全,也会在运行时进行验证。
条件验证
当一个值的有效性取决于另一个值时(例如,匹配密码),请使用 .refine()。不要使用表单级别的 .superRefine() 来要求仅在显示时才必填的字段;请将验证器放在条件字段上。
const schema = z
.object({
password: z.string(),
confirmPassword: z.string(),
})
.refine(data => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
});
条件字段
使用 form.Subscribe 根据其他字段的值显示或隐藏字段。让表单架构中的条件必填值保持宽松,然后在渲染出的 AppField 上验证,避免隐藏字段阻止表单提交:
const schema = z.object({
plan: z.enum(['standard', 'enterprise']),
billingEmail: z.string(),
});
<form.Subscribe selector={state => state.values.plan === 'enterprise'}>
{showBilling =>
showBilling ? (
<form.AppField
name="billingEmail"
validators={{
onDynamic: z.email('Enter a valid billing email'),
}}
>
{field => (
<field.Layout.Stack label="Billing Email" required>
<field.Input value={field.state.value} onChange={field.handleChange} />
</field.Layout.Stack>
)}
</form.AppField>
) : null
}
</form.Subscribe>;
对于可空数字字段,请在表单架构中保留 z.number().nullable(),并使用如下字段验证器:z.number().positive('Amount must be greater than zero').nullable().refine(value => value !== null, 'Amount is required')。当数字输入为空时,refine 会拒绝其产生的 null。
错误处理
服务端错误
使用 setFieldErrors 显示后端验证错误:
import {useMutation} from '@tanstack/react-query';
import {setFieldErrors} from '@sentry/scraps/form';
import {fetchMutation} from 'sentry/utils/queryClient';
import {RequestError} from 'sentry/utils/requestError/requestError';
import {requestErrorToFieldErrors} from 'sentry/utils/requestError/requestErrorToFieldErrors';
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) {
if (error instanceof RequestError) {
setFieldErrors(formApi, requestErrorToFieldErrors(error, formApi.state.values));
}
}
},
});
// ...
}
setFieldErrors 接受 Scraps 的 FieldErrors 契约,不接受 Sentry 的 RequestError。在应用边界使用 requestErrorToFieldErrors 来:
- 在调用位置缩小未知错误的类型后,接收
RequestError; - 仅保留表单值中存在的键;
- 将字符串和数组响应值转换为
{message: string}。
不要直接将 API 错误传给 Scraps:
// ❌ RequestError is an app type, not a Scraps form error
setFieldErrors(formApi, error);
// ✅ Narrow at the app call site, then convert to the Scraps contract
if (error instanceof RequestError) {
setFieldErrors(formApi, requestErrorToFieldErrors(error, formApi.state.values));
}
在表单代码中创建字段消息时,请使用直接对象:
setFieldErrors(formApi, {
email: {message: 'This email is already registered'},
username: {message: 'Username is taken'},
});
重要:
setFieldErrors支持使用点号表示法指定嵌套路径:'address.city': {message: 'City not found'}
错误显示
验证错误会自动在字段末尾区域显示为警告图标和工具提示。无需额外代码。
自动保存模式
对于每个字段单独保存的设置页面,请使用 AutoSaveForm。
基本自动保存表单
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 (
<AutoSaveForm
name="displayName"
schema={schema}
initialValue={user.displayName}
mutationOptions={{
mutationFn: data => {
return fetchMutation({
url: '/user/',
method: 'PUT',
data,
});
},
onSuccess: data => {
// Update React Query cache
queryClient.setQueryData(['user'], old => ({...old, ...data}));
},
}}
>
{field => (
<field.Layout.Row label="Display Name">
<field.Input value={field.state.value} onChange={field.handleChange} />
</field.Layout.Row>
)}
</AutoSaveForm>
);
}
不同字段类型的自动保存行为
| 字段类型 | 保存时机 |
|---|---|
| Input、TextArea | 失去焦点时(用户离开字段时) |
| Select(单选) | 选择更改后立即保存 |
| Select(多选) | 菜单关闭时,或菜单关闭状态下点击 X/清除时 |
| Switch | 切换后立即保存 |
| Radio | 选择更改后立即保存 |
| Range | 用户松开滑块时,或通过键盘操作时立即保存 |
自动保存状态指示器
表单系统会自动显示:
- 保存期间显示加载指示器(待处理)
- 成功后显示勾选标记(2 秒后淡出)
- 验证错误时显示警告图标(带工具提示)
重要:不要使用 Toast 提示自动保存状态。内置的行内指示器(加载指示器、勾选标记、警告图标)才是正确的反馈机制。对于频繁随更改而保存的字段,Toast 提示会造成干扰。
自动保存请求错误
AutoSaveForm 从表单错误上下文获取特定于应用的错误映射。Sentry 通过 ScrapsProviders 安装此映射一次即可。不要在每个 AutoSaveForm 调用位置捕获 RequestError,也不要调用 requestErrorToFieldErrors。
Sentry 提供程序:
- 将失败的变更请求的类型缩小为
RequestError; - 使用
requestErrorToFieldErrors匹配后端字段错误; - 使用
getRequestErrorUserMessage获取请求详情和状态消息; - 对其他错误使用
Failed to save作为回退消息。
Scraps 仍独立于 Sentry 的 API 错误类型。在 Sentry 应用之外,除非宿主应用提供自己的映射器,否则表单错误上下文会使用通用回退消息。
确认对话框
对于危险操作(安全设置、权限),请使用 confirm 属性,在保存前显示确认模态框。confirm 属性接受字符串或函数。
<AutoSaveForm
name="require2FA"
schema={schema}
initialValue={false}
confirm={value =>
value
? 'This will remove all members without 2FA. Continue?'
: 'Are you sure you want to allow members without 2FA?'
}
mutationOptions={{...}}
>
{field => (
<field.Layout.Row label="Require Two-Factor Auth">
<field.Switch checked={field.state.value} onChange={field.handleChange} />
</field.Layout.Row>
)}
</AutoSaveForm>
确认配置选项:
| 类型 | 描述 |
|---|---|
string | 保存前始终显示此消息 |
(value) => string | undefined | 根据新值返回消息的函数;返回 undefined 则跳过确认 |
注意:确认对话框始终将焦点放在“取消”按钮上,以确保安全,防止意外确认危险操作。
示例:
// ✅ 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}
表单提交
重要:表单提交时务必使用 TanStack Query mutations(
useMutation)。这能确保正确处理加载状态、错误和缓存。
使用 Mutations
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(() => {});
},
});
// ...
}
保存后重置
表单提交后仍留在页面上时(例如设置页面),请在 mutation 成功后调用 form.reset()。这会将表单与更新后的 defaultValues 重新同步,使其恢复为未修改状态——任何依赖表单是否已修改的 UI(例如条件显示的“保存”/“取消”按钮)都能正确更新。
onSubmit: ({value}) =>
mutation
.mutateAsync(value)
.then(() => form.reset())
.catch(() => {}),
注意:
AutoSaveForm会自动处理此操作。只有使用useScrapsForm时才需要添加这一步。
提交按钮
<Flex gap="md" justify="end">
<form.ResetButton>Reset</form.ResetButton>
<form.SubmitButton>Save Changes</form.SubmitButton>
</Flex>
SubmitButton 会自动:
- 在提交待处理时禁用
- 在提交前触发表单验证
应做事项与禁忌
表单系统选择
// ❌ Don't use legacy JsonForm for new forms
<JsonForm fields={[{name: 'email', type: 'text'}]} />;
// ✅ Use useScrapsForm with Zod validation
const form = useScrapsForm({
...defaultFormOptions,
defaultValues: {email: ''},
validators: {onDynamic: schema},
});
默认选项
// ❌ Don't forget defaultFormOptions
const form = useScrapsForm({
defaultValues: {name: ''},
});
// ✅ Always spread defaultFormOptions first
const form = useScrapsForm({
...defaultFormOptions,
defaultValues: {name: ''},
});
可空默认值
// ❌ 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<typeof schema> = {provider: null, name: ''};
const form = useScrapsForm({
...defaultFormOptions,
defaultValues,
validators: {onDynamic: schema},
onSubmit: ({value}) => {
return mutation.mutateAsync(schema.parse(value)).catch(() => {});
},
});
表单提交
// ❌ 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(() => {});
};
字段值处理
// ❌ Don't use field.state.value directly when it might be undefined
<field.Input value={field.state.value} />
// ✅ Provide fallback for optional fields
<field.Input value={field.state.value ?? ''} />
验证消息
// ❌ Don't use generic error messages
z.string().min(1);
// ✅ Provide helpful, specific error messages
z.string().min(1, 'Email address is required');
自动保存反馈
// ❌ 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
},
}}
自动保存缓存更新
务必在 onSuccess 中更新数据存储或缓存。否则,将字段切换回原始值时不会触发保存——TanStack Form 会与 defaultValues(根据 initialValue 推导)进行比较,并在值匹配时跳过提交。
// ❌ 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}));
},
}}
自动保存 Mutation 类型
mutationFn 应使用 API 的数据类型,而不是 Zod schema 类型。schema 用于客户端字段验证,而 mutation 应接受 API 端点接受的任何类型。也不要使用 Record<string, unknown> 之类的泛型,因为这会破坏 TanStack Form 缩窄字段类型的能力。
绝不要向 mutationOptions、useMutation 或任何 TanStack Query 函数传入调用位置泛型。 必须推导类型,而不是断言类型。完整规则请参阅 static/AGENTS.md 中的 “TanStack Query Type Inference”。
// ❌ NEVER pass generics to mutationOptions/useMutation
mutationOptions<unknown, RequestError, Variables, Context>({...})
useMutation<Response, RequestError, Variables>({...})
// ❌ Don't use generic types - breaks field type narrowing
const opts = mutationOptions({
mutationFn: (data: Record<string, unknown>) => fetchMutation({...}),
});
// ❌ Don't tie mutation type to the zod schema
const opts = mutationOptions({
mutationFn: (data: Partial<z.infer<typeof preferencesSchema>>) => 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<UserDetails>) =>
fetchMutation<UserDetails>({...}),
});
确保 Zod schema 的类型与 API 类型兼容。例如,如果 API 需要 'off' | 'low' | 'high' 这样的字符串联合类型,请使用 z.enum(['off', 'low', 'high']),而不是 z.string()。
保存后重置表单
TanStack Form 中的 defaultValues 是响应式的。当表单挂载后查询数据到达时,通过 defaultValues 传入该数据即可更新尚未修改的表单。不要添加仅为填充异步查询结果而调用 form.reset() 的 effect。表单仍处于挂载状态且已修改时,成功保存后重置仍然有用。
// ❌ 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(() => {});
};
布局选择
// ❌ Don't use Row layout when labels are very long
<field.Layout.Row label="Please enter the primary email address for your account">
// ✅ Use Stack layout for long labels
<field.Layout.Stack label="Please enter the primary email address for your account">
快速参考清单
创建新表单时:
- 从
@sentry/scraps/form和zod导入 - 定义带有明确错误消息的 Zod schema
- 使用
useScrapsForm和...defaultFormOptions - 设置与 schema 结构匹配的
defaultValues(如果 schema 使用.refine(),请使用z.input<typeof schema>) - 设置
validators: {onDynamic: schema} - 使用
<form.AppForm form={form}>包裹 - 每个已注册的输入项都使用
<form.AppField>,包括只读输入项 - 仅用于显示的信息使用
DisplayField.Row或DisplayField.Stack;不要将其纳入表单状态 - 选择合适的布局(Stack 或 Row)
- 在调用位置将未知错误缩窄为
RequestError,使用requestErrorToFieldErrors转换,然后调用setFieldErrors - 添加
<form.SubmitButton>以提交表单 - 如果表单提交后仍留在页面上,请在 mutation 成功后调用
form.reset()
创建自动保存字段时:
- 使用
<AutoSaveForm>组件 - 传入用于验证的
schema - 传入当前数据中的
initialValue - 使用
mutationFn配置mutationOptions - 在
onSuccess回调中更新缓存 - 让 Sentry 表单错误提供程序处理常见请求错误
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/formErrorContext.tsx | Host error-mapper contract |
static/app/scrapsProviders/formError.tsx | Sentry request-error mapper |
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/displayField.tsx | Display-only layouts |
static/app/components/core/form/form.stories.tsx | Usage examples |