generate-frontend-forms について
TanStack React Form と Zod を使う Sentry 開発者向けのフォーム構築ガイドです。スキーマ検証、アクセシブルなフィールド連携、自動保存のパターンを扱います。
主な機能
- 01useScrapsForm によるフォーム作成
- 02Zod による検証スキーマ定義
- 03AutoSaveForm による自動保存
- 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 ファイルで公開されているインターフェースの一部だけを使用できます。
フォームフック:useScrapsForm
バリデーションと送信処理を備えたフォームを作成するためのメインフックです。
基本的な使い方
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として定義されており、これによって一貫した UX が実現します。
返されるプロパティ
| プロパティ | 説明 |
|---|---|
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>
チェックボックスグループ(複数選択)
1 つの文字列配列として保存される複数選択には、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>
ラジオフィールド
ラジオフィールドでは、Radio.Group と Radio.Item を使う合成可能な API を使用します。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 を使用します。これにより、ネイティブ要素に展開する、アクセシビリティとフォーム連携に必要なすべての props(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>
レンダープロップは、次の2つの引数を受け取ります。
baseProps— 要素に展開する、アクセシビリティとフォーム連携用の props(ref、disabled、aria-invalid、aria-describedby、onBlur、name、id){indicator}— 自動保存の状態インジケーター(スピナー/チェックマーク)を React ノードとして受け取ります。カスタムレイアウト内の適切な場所に配置できます。
要素の型は渡された ref から推論されるため、ref を渡さない場合は、<field.Base<HTMLInputElement>> のように型を手動で注釈する必要があります。
field.Base は次の処理を自動的に行います。
- ref のマージ(ハッシュへのスクロールと外部への ref 転送)
- 自動保存の保留中にフィールドを無効化
- 検証状態に基づく
aria-invalidの設定 aria-describedbyによるヒントテキストとの関連付け
このロジックを重複して実装するカスタムラッパーを作る代わりに、field.Base を使用してください。標準 props を受け入れるネイティブ 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>
Compact バリアント
Stack と Row のどちらのレイアウトも、variant="compact" prop をサポートしています。コンパクトモードでは、ヒントテキストは下に表示されず、ラベルのツールチップとして表示されます。ヒント情報を提供しながら、縦方向のスペースを節約できます。
// 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>
Compact を使用する場面:
- 縦方向のスペースが限られている、多数のフィールドを含む設定ページ
- ヒントテキストが必須ではなく、補足情報として使われるフォーム
- 高さに制約があるダッシュボードやパネル
カスタムレイアウト
必要に応じて新しいレイアウトを作成したり、レイアウトをまったく使用しなかったりできます。レイアウトを使用しない場合は、アクセシビリティのために 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>
レイアウトの Props
| Prop | 型 | 説明 |
|---|---|---|
label | string | フィールドのラベルテキスト |
hintText | string | 補助テキスト(デフォルトではラベルの下、compact モードではツールチップに表示) |
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"を受け取ります。コンパクトモードではヒントがツールチップに表示されます。それ以外の場合、ヒントは行では用語の下に、スタックでは値の後に表示されます。 <label>、入力との関連付け、バリデーション、エラーフォーカス登録を伴わずに、dl/dt/ddをレンダリングします。フォームやフィールドのコンテキストは必要ありません。- 表示する値には、たとえば
<Text>を使って明示的にスタイルを設定します。DisplayFieldは子要素をTextで囲みません。
レイアウトだけを目的として、表示専用情報を AppField や AutoSaveForm に登録したり、スキーマや defaultValues に追加したりしないでください。フォームの状態から導出される値の場合は、form.setFieldValue で値を別途保存せず、レンダリング時に form.Subscribe を使って計算します。
<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 を使った Nullable フィールド
フィールドの初期値が 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(() => {});
},
});
重要:Nullable フィールドへの対処に、非 null アサーション(
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>;
Nullable な数値フィールドでは、フォームスキーマで z.number().nullable() を維持し、次のようなフィールドバリデーターを使用します。
z.number().positive('Amount must be greater than zero').nullable().refine(value => value !== null, 'Amount is required')。
数値入力が空のときに出力される null は、refine によって拒否されます。
エラー処理
サーバー側のエラー
バックエンドのバリデーションエラーを表示するには、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 (single) | 選択内容が変更されたときに即時 |
| Select (multiple) | メニューを閉じたとき、またはメニューを閉じた状態で X/クリアをクリックしたとき |
| Switch | 切り替え時に即時 |
| Radio | 選択内容が変更されたときに即時 |
| Range | ユーザーがスライダーから手を離したとき、またはキーボード操作時に即時 |
自動保存ステータスインジケーター
フォームシステムは次の項目を自動的に表示します。
- 保存中は スピナー(処理待ち)
- 成功時は チェックマーク(2 秒後にフェードアウト)
- バリデーションエラー時は 警告アイコン(ツールチップ付き)
重要:自動保存のステータスを伝えるためにトーストを使用しないでください。組み込みのインラインインジケーター(スピナー、チェックマーク、警告アイコン)が適切なフィードバック手段です。変更のたびに頻繁に保存されるフィールドにトーストを使うと、煩わしく操作を妨げます。
自動保存時のリクエストエラー
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 のミューテーション(
useMutation)を使用してください。これにより、適切な読み込み状態、エラーハンドリング、キャッシュ管理が実現します。
ミューテーションの使用
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(() => {});
},
});
// ...
}
保存後のリセット
送信後もフォームがページ上に残る場合(設定ページなど)は、ミューテーションが成功した後に form.reset() を呼び出してください。これにより、フォームが更新された defaultValues と再同期され、再び pristine な状態になります。フォームが dirty かどうかに応じて表示される 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: ''},
});
null 許容のデフォルト値
// ❌ 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}));
},
}}
自動保存ミューテーションの型指定
mutationFn には zod スキーマの型ではなく、API のデータ型を指定してください。スキーマはクライアント側でフィールドを検証するためのものです。ミューテーションは、API エンドポイントが受け付ける値を受け取るようにしてください。また、Record<string, unknown> のようなジェネリック型も使用しないでください。TanStack Form がフィールドの型を絞り込めなくなります。
mutationOptions、useMutation、または TanStack Query の関数に、呼び出し側でジェネリック型を指定することは絶対にしないでください。 型は明示するのではなく、推論される必要があります。「TanStack Query Type Inference」に記載されているルール全文は static/AGENTS.md を参照してください。
// ❌ 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 スキーマの型が API の型と互換性があることを確認してください。たとえば、API が 'off' | 'low' | 'high' のような文字列のユニオン型を期待する場合は、z.string() ではなく z.enum(['off', 'low', 'high']) を使用してください。
保存後のフォームリセット
TanStack Form では defaultValues はリアクティブです。フォームのマウント後にクエリデータが取得された場合、そのデータを defaultValues に渡すと、未編集のフォームが更新されます。非同期のクエリ結果を反映するだけの目的で form.reset() を呼び出すエフェクトを追加しないでください。フォームがマウントされたままで dirty の場合、保存成功後のリセットは引き続き有用です。
// ❌ 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 スキーマを定義する
-
...defaultFormOptionsを指定してuseScrapsFormを使用する - スキーマの形状に一致する
defaultValuesを設定する(スキーマに.refine()がある場合はz.input<typeof schema>を使用する) -
validators: {onDynamic: schema}を設定する -
<form.AppForm form={form}>でラップする - 読み取り専用の入力を含め、登録する各入力に
<form.AppField>を使用する - 表示専用の情報には
DisplayField.RowまたはDisplayField.Stackを使用し、フォームの状態には含めない - 適切なレイアウト(Stack または Row)を選択する
- 呼び出し箇所で unknown 型のエラーを
RequestErrorに絞り込み、requestErrorToFieldErrorsで変換してからsetFieldErrorsを呼び出す - 送信には
<form.SubmitButton>を追加する - フォームがページ上に残る場合は、ミューテーション成功後に
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 |