業務アプリのための Vue 3 フォーム設計 — 再利用可能なパターン
業務アプリ向けの Vue 3 フォーム設計:再利用可能なフィールドコンポーネント、明確な検証ルール、各入力にサーバーエラーを表示する実践的な方法。

なぜ実業務アプリでフォームコードは壊れやすいのか
業務アプリのフォームはめったに小さいままではありません。最初は「ちょっとした入力だけ」でしたが、やがて数十のフィールド、条件付きセクション、権限、バックエンドロジックと整合させるルールが加わります。プロダクトが数回変わるうちにフォームは動くけれどコードが脆く感じられるようになります。
Vue 3 のフォーム設計が重要なのは、フォームが「その場しのぎの修正」の溜まり場になりやすいからです:もう一つの watcher、もう一つの特例、コピーされたコンポーネント。今日動いていても、信頼しにくく変更しにくくなっていきます。
警告サインはお馴染みでしょう:ページ間で繰り返される入力の振る舞い(ラベル、フォーマット、必須マーク、ヒント)、一貫しないエラー表示、コンポーネントに散らばった検証ルール、そしてバックエンドエラーがユーザーに何を直せばいいか教えない汎用のトーストに縮小されていること。
これらの不整合は単なるコードスタイルの問題ではありません。UX の問題になります:ユーザーがフォームを再送信し、サポートチケットが増え、チームはどこかに壊れやすい隠れた端点があるかもしれないとフォームに触りたがらなくなります。
良いセットアップはフォームを「退屈」にします。予測可能な構造があれば、フィールドを追加し、ルールを変え、サーバー応答を扱うときに全てを付け直す必要はありません。
求めるのは再利用(どの場所でも同じ振る舞いをするフィールド)、明快さ(ルールとエラー処理が見やすい)、予測可能な動作(touched、dirty、reset、submit)、そしてよりよいフィードバック(サーバー側のエラーが注意を要する正確な入力に表示されること)です。以下のパターンは、再利用可能なフィールドコンポーネント、読みやすい検証、サーバーエラーを特定の入力にマッピングする実践的な方法に焦点を当てています。
フォーム構造のシンプルな心的モデル
長持ちするフォームは、小さなシステムであって、明確なパートに分かれており、バラバラの入力の山ではありません。
一方向にやり取りする四つのレイヤーで考えてください:UI が入力を集め、フォーム状態がそれを保持し、検証が何が間違っているかを説明し、API レイヤーが読み込みと保存を行います。
四つのレイヤー(それぞれが持つ責務)
- Field UI コンポーネント:入力、ラベル、ヒント、エラーテキストを描画。値の変更を emit する。
- フォーム状態:値とエラー(および touched と dirty フラグ)を保持する。
- 検証ルール:値を読み、エラーメッセージを返す純粋関数。
- API 呼び出し:初期データの読み込み、変更の送信、サーバーレスポンスをフィールドエラーに翻訳する。
この分離により変更が局所化されます。新しい要件が来たら一つのレイヤーを更新するだけで他を壊さずに済みます。
フィールドに置くものと親フォームに置くもの
再利用可能なフィールドコンポーネントは地味であるべきです。API、データモデル、検証ルールについて知らないほうが良い。表示すべきは値とエラーだけです。
親フォームはその他すべてを調整します:どのフィールドがあるか、値がどこに保存されるか、いつ検証するか、どう送信するかなど。
簡単なルールが助けになります:もしロジックが他のフィールドに依存するなら(例:「State は Country が US のときだけ必須」)、それはフィールドコンポーネントの中ではなく親フォームか検証レイヤーに置きます。
新しいフィールドの追加が本当に低コストなら、通常はデフォルトかスキーマ、フィールドを置くマークアップ、そしてそのフィールドの検証ルールだけに触れます。1つの入力を追加するたびに無関係のコンポーネントを変えなければならないなら、境界が曖昧です。
再利用可能なフィールドコンポーネント:標準化すべきこと
フォームが大きくなると、最速で効果が出るのは「各入力を個別に作るのをやめる」ことです。フィールドコンポーネントは予測可能に感じられるべきで、それが使いやすさとレビューのしやすさを生みます。
実用的な構成要素のセット:
- BaseField:ラッパー。ラベル、ヒント、エラーテキスト、間隔、アクセシビリティ属性を管理。
- 入力コンポーネント:TextInput、SelectInput、DateInput、Checkbox など。各コンポーネントはコントロールに専念する。
- FormSection:関連するフィールドをタイトル、短い説明文、一貫した間隔でグループ化する。
プロップは小さく保ち、どこでもそれを使うようにします。40個のフォームで prop 名を変えるのは辛いです。
すぐに効果が出るものの例:
modelValueとupdate:modelValue(v-model用)labelrequireddisablederror(単一メッセージ、または配列でも可)hint
スロットは一貫性を壊さずに柔軟性を許可する場所です。BaseField のレイアウトは安定させつつ、右側のアクション(「コードを送る」)や先頭のアイコンのような小さなバリエーションを許容します。あるバリエーションが二度出るなら、コンポーネントを分岐するのではなくスロットにします。
レンダリング順を標準化します(ラベル、コントロール、ヒント、エラー)。ユーザーは速くスキャンでき、テストは単純になり、サーバーエラーのマッピングも容易になります。理由は各フィールドにメッセージを表示する明白な場所があるからです。
フォーム状態:values、touched、dirty、reset
業務アプリでのほとんどのフォームバグは入力そのものの問題ではなく、状態が散らばっていることから来ます:ある場所に値、別の場所にエラー、そして Reset ボタンが半分しか効かない、など。クリーンな Vue 3 フォーム設計は一貫した状態形から始まります。
まず、フィールドキーの命名規則を決めてそれに従ってください。最も簡単なルールは:フィールドキーは API のペイロードキーと同じにすること。サーバーが first_name を期待するなら、フォームのキーも first_name にします。この小さな選択で検証、保存、サーバーエラーのマッピングがずっと楽になります。
フォーム状態は一か所にまとめ(composable、Pinia ストア、または親コンポーネント)、各フィールドはその状態を通して読み書きします。フラットな構造はほとんどの画面で十分です。API が本当にネストしている場合のみネストを使ってください。
const state = reactive({
values: { first_name: '', last_name: '', email: '' },
touched: { first_name: false, last_name: false, email: false },
dirty: { first_name: false, last_name: false, email: false },
errors: { first_name: '', last_name: '', email: '' },
defaults: { first_name: '', last_name: '', email: '' }
})
フラグの実務的な考え方:
touched:ユーザーはこのフィールドに触れたか?dirty:値はデフォルト(または最後に保存された値)と異なるか?errors:今ユーザーが見るべきメッセージは何か?defaults:どの値にリセットするか?
リセットの振る舞いは予測可能であるべきです。既存レコードを読み込むときは values と defaults を同じソースから設定します。そうすれば reset() は defaults を values にコピーし、touched、dirty、errors をクリアします。
例:顧客プロフィールフォームが email をサーバーから読み込むとします。ユーザーが編集すれば dirty.email は true になります。Reset を押せばメールは空文字ではなく読み込まれた値に戻り、画面は再びクリーンに見えます。
読みやすい検証ルール
読みやすい検証はライブラリの問題だけでなく、ルールをどう表現するかの問題です。フィールドを一目見て数秒でルールが理解できればフォームコードは保守しやすくなります。
継続できるルールスタイルを選ぶ
多くのチームは次のどれかに落ち着きます:
- フィールド単位のルール:ルールがフィールドの近くにある。スキャンしやすく、中〜小のフォームに向く。
- スキーマベースのルール:ルールが一つのオブジェクトやファイルにある。同じモデルを多くの画面で使うときに有効。
- ハイブリッド:単純なルールはフィールド近くに、共有や複雑なルールは中央スキーマに置く。
どれを選ぶにせよ、ルール名とメッセージは予測可能に保ちます。一般的なルール(required、length、format、range)をいくつか用意する方が、無数のワンオフヘルパーより優れます。
ルールはプレーンな英語(日本語では平易な文章)で書く
良いルールは文のように読めます:「メールは必須で、メール形式であること」。意図を隠すような巧妙な一行は避けてください。
多くの業務フォームでは、フィールドあたり一度に一つのメッセージ(最初の失敗)を返す方が UI は落ち着き、ユーザーは直すべき点が分かりやすくなります。
一般的でユーザーフレンドリーなルール:
- 必須:本当に入力が必要なときだけ。
- 文字数:実数で指定(例:2〜50 文字)。
- 形式:メール、電話、郵便番号など。実際の入力を拒否しないように過度に厳しい正規表現は避ける。
- 範囲:未来日でないこと、数量は 1〜999 など。
非同期チェックは分かりやすくする
「ユーザー名が既に使われている」のような非同期検証は、無音で走ると混乱します。
blur 時か短いインターバル後にチェックを発火し、明確な「確認中」状態を表示し、ユーザーが入力を続ける場合は古いリクエストをキャンセルまたは無視します。
いつ検証を走らせるかを決める
タイミングはルールと同じくらい重要です。ユーザーフレンドリーな設定例:
- on change:パスワード強度のようにライブフィードバックが役立つフィールドに限定してやさしく行う。
- on blur:ほとんどのフィールドに適切。ユーザーがタイプ中にエラーで妨げられない。
- on submit:フォーム全体の最終チェックとして必ず行う。
サーバーエラーを正しい入力にマッピングする
クライアント側のチェックは話の半分にすぎません。業務アプリではサーバーが保存を拒否する理由はユニークです:重複、権限チェック、データの古さ、状態遷移など。良いフォーム UX はそのレスポンスを適切な入力の横に明確に表示することにかかっています。
エラーを一つの内部形に正規化する
バックエンドはエラーフォーマットで合意していることは稀です。あるものは単一オブジェクト、あるものはリスト、あるものはフィールド名キーのネストマップを返します。受け取ったものは何であれ、フォームが描画できる一つの内部形に変換してください。
// what your form code consumes
{
fieldErrors: { "email": ["Already taken"], "address.street": ["Required"] },
formErrors: ["You do not have permission to edit this customer"]
}
いくつかのルールを一貫させます:
- フィールドエラーは配列として保存する(メッセージが一つでも配列にする)。
- 異なるパススタイルを一つのスタイルに変換する(ドットパスは扱いやすい:
address.street)。 - フィールドでないエラーは
formErrorsとして別に保つ。 - ログ用に生のサーバーペイロードを保持してもよいが、それを直接描画してはいけない。
サーバーのパスをフォームのキーにマッピングする
難しいのは、サーバーがいう「パス」とフォームのフィールドキーを合わせることです。各フィールドコンポーネントにキーを決め(例:email、profile.phone、contacts.0.type)、それに従ってください。
次に、一般的なケースを扱う小さなマッパーを書きます:
address.street(ドット表記)address[0].street(配列用のブラケット記法)/address/street(JSON Pointer スタイル)
正規化したあと、<Field name="address.street" /> が fieldErrors["address.street"] を特別扱いなしに読めるようにします。
必要ならエイリアスをサポートします。バックエンドが customer_email を返し UI が email を使うなら、正規化中に { customer_email: "email" } のようなマッピングを保持します。
フィールドエラー、フォームレベルのエラー、フォーカス移動
すべてのエラーが単一の入力に属するわけではありません。サーバーが「プラン上限に達した」や「支払いが必要」と返す場合、それはフォーム上部にフォームレベルのメッセージとして表示します。
フィールド固有のエラーは該当入力の横に表示し、ユーザーを最初の問題箇所に導きます:
- サーバーエラーを設定した後、
fieldErrorsの中でレンダリングされているフォームに存在する最初のキーを探す。 - それを表示領域にスクロールしてフォーカスする(各フィールドに ref を持たせ、
nextTickで行う)。 - ユーザーがそのフィールドを編集したらサーバーエラーをクリアする。
実際にアーキテクチャを組み立てる手順
フォームが落ち着いているのは、早い段階で何をフォーム状態、UI、検証、API に置くかを決め、それらをいくつかの小さな関数でつなぐからです。
業務アプリで多くの場合うまくいく順序:
- ひとつのフォームモデルと安定したフィールドキーで始めます。これらのキーはコンポーネント、バリデータ、サーバーエラー間の契約になります。
- ラベル、ヘルプテキスト、必須マーク、エラー表示のための BaseField ラッパーを作り、入力コンポーネントは小さく一貫させます。
- フィールドごとに走らせられる検証レイヤーを追加し、submit 時に全体検証ができるようにします。
- API に送信し、失敗したらサーバーエラーを
{ [fieldKey]: message }の形に翻訳して該当入力に正しいメッセージを表示します。 - 成功時の処理(リセット、トースト、画面遷移)は分離しておき、コンポーネントやバリデータに漏れないようにします。
状態の簡単な出発点:
const values = reactive({ email: '', name: '', phone: '' })
const touched = reactive({ email: false, name: false, phone: false })
const errors = reactive({}) // { email: '...', name: '...' }
BaseField は label、error、場合によっては touched を受け取り、メッセージを一箇所に描画します。各入力コンポーネントはバインディングと更新の emit のみを担当します。
検証は同じキーを使ってモデル近くに置きます:
const rules = {
email: v => (!v ? 'Email is required' : /@/.test(v) ? '' : 'Enter a valid email'),
name: v => (v.length < 2 ? 'Name is too short' : ''),
}
function validateAll() {
Object.keys(rules).forEach(k => {
const msg = rules[k](values[k])
if (msg) errors[k] = msg
else delete errors[k]
touched[k] = true
})
return Object.keys(errors).length === 0
}
サーバーがエラーで { "field": "email", "message": "Already taken" } のように返したら、errors.email = 'Already taken' を設定してそのフィールドを touched にします。グローバルなエラーならフォーム上部に表示します。
例:顧客プロフィールの編集
サポート担当が顧客プロフィールを編集する内部管理画面を想像してください。フォームは name、email、phone、role(Customer、Manager、Admin)の四つのフィールドを持ちます。小さいですがよくある問題を示しています。
クライアント側ルールは明確にします:
- Name:必須、最小文字数。
- Email:必須、有効なメール形式。
- Phone:任意だが入力があるなら受け入れられる形式に合致すること。
- Role:必須で、場合によっては条件付き(Admin を割り当てられるのは権限のあるユーザーのみ)。
一貫したコンポーネント契約が役立ちます:各フィールドは現在の値、現在のエラーテキスト(もしあれば)、touched や disabled のようなブール値を受け取ります。ラベル、必須マーク、間隔、エラーのスタイルは画面ごとに再発明してはいけません。
UX フローは次のようになります。担当が email を編集してタブで抜けると、メール形式が間違っていれば Email の下にインラインメッセージが表示されます。修正して Save を押すと、サーバーは次のように応答するかもしれません:
- email already exists:Email の下にメッセージを表示しそのフィールドにフォーカスする。
- phone invalid:Phone の下に表示する。
- permission denied:フォーム上部にフォームレベルのメッセージを表示する。
email、phone、role のようにフィールド名でエラーを管理すればマッピングは単純です。フィールドエラーは入力の横に、フォームレベルのエラーは専用エリアに表示します。
よくある間違いと回避法
ロジックを一か所にまとめる
バリデーションルールを各画面にコピーするのは速く感じますが、パスワード規則、必須の税 ID、許可されるドメインなどポリシーが変わったときに修正場所が散らばって苦労します。ルールは集中化(スキーマ、ルールファイル、共有関数)し、フォームは同じルールセットを利用するようにしましょう。
また低レベルの入力にあれこれやらせすぎないでください。<TextField> が API を呼び、失敗時にリトライし、サーバーエラーのペイロードを解析するようになると再利用できなくなります。フィールドは描画、値の emit、エラー表示だけを行い、API 呼び出しとマッピングロジックはフォームコンテナか composable に置きます。
関心の分離が混ざっている兆候:
- 同じ検証メッセージが複数箇所に書かれている。
- フィールドコンポーネントが API クライアントをインポートしている。
- エンドポイントを変えると無関係なフォームが壊れる。
- テストで一つの入力をチェックするのにアプリの半分をマウントしなければならない。
UX とアクセシビリティの落とし穴
「何かがうまくいかなかった」という一行のエラーバナーは十分ではありません。どのフィールドが間違っているか、次に何をすればいいかを人は知る必要があります。ネットワークダウンや権限エラーのようなグローバル失敗にはバナーを使い、サーバーエラーは該当する入力にマップしてユーザーがすぐに修正できるようにします。
ロード中や二重送信の問題は混乱を招きます。送信中は送信ボタンを無効化し、保存中に変えてはいけないフィールドを無効化し、明確な忙しい状態を示します。Reset と Cancel がフォームをきれいに復元することを確認してください。
アクセシビリティの基本はカスタムコンポーネントで見落とされがちですが、いくつかの選択で大きな問題を防げます:
- すべての入力に視覚的なラベルを付ける(プレースホルダだけにしない)。
- エラーは適切な aria 属性でフィールドに結び付ける。
- 送信後は最初の無効フィールドにフォーカスを移動する。
- 無効化されたフィールドは本当に操作不能で、正しくアナウンスされる。
- キーボード操作が一貫して動く。
クイックチェックリストと次のステップ
新しいフォームを出す前にクイックチェックリストを実行してください。後でサポートチケットになる小さな穴を見つけられます。
- すべてのフィールドにペイロードとサーバーレスポンスに一致する安定したキーがあるか(
billing.address.zipのようなネストパスを含む)? - 一貫したフィールドコンポーネント API(値入力、イベント出力、エラーとヒントの受け取り)で任意のフィールドを描画できるか?
- 送信時に一度だけ検証し、二重送信を防ぎ、最初の無効フィールドにフォーカスしてユーザーがどこから始めるか分かるか?
- エラーを正しい場所に表示できるか:フィールドごと(入力の横)とフォームレベル(必要な一般メッセージ)?
- 成功時に状態を正しくリセットできるか(values、touched、dirty)ので次の編集がクリーンに始まるか?
どれか一つでも「いいえ」なら、まずそこを直してください。最も一般的なフォームの痛みはミスマッチです:フィールド名が API からずれていたり、サーバーエラーが UI が配置できない形で戻ってくることです。
社内ツールを早く作りたいなら、AppMaster (appmaster.io) は同じ基本に従っています:フィールド UI を一貫させ、ルールとワークフローを集中管理し、サーバー応答がユーザーが行動できる場所に表示されるようにします。
よくある質問
ページ間で同じラベル、ヒント、必須マーク、余白、エラースタイルが繰り返し出てくるのを見かけたら標準化を検討するタイミングです。「小さな」変更が多くのファイル編集を必要とするなら、共有の BaseField ラッパーといくつかの一貫した入力コンポーネントがすぐに時間を節約してくれます。
フィールドコンポーネントはダムに保ちます:ラベル、コントロール、ヒント、エラーを描画し、値の更新を emit するだけにします。クロスフィールドのロジック、条件付きルール、他の値に依存する処理は親フォームや検証レイヤーに置いて、フィールドが再利用可能であり続けるようにします。
デフォルトでは API ペイロードと一致する安定したキーを使います(例:first_name、billing.address.zip)。こうすることで検証やサーバーエラーのマッピングが簡単になりますし、層ごとに名前を翻訳する必要がなくなります。
シンプルなデフォルトは、values、errors、touched、dirty、defaults を持つ一つの状態オブジェクトです。すべてが同じ形を読み書きすると、リセットや送信の振る舞いが予測可能になり、「半分しかリセットされない」バグを避けられます。
読み込み時に values と defaults を同じ元データで設定します。reset() は defaults を values にコピーし、touched、dirty、errors をクリアするようにします。こうすると UI はサーバーから最後に返された値と一致してクリーンに見えます。
フォームが成長しても読みやすさを保つには、まず同じフィールド名(フォーム状態のキー)を使い、簡潔な関数としてルールを書くことです。各フィールドはひとつの明確なメッセージ(最初に失敗したもの)だけを返すようにすると、UI が落ち着きユーザーが直すべき点が分かりやすくなります。
多くの場合は、ほとんどのフィールドを on blur で検証し、最終チェックとして on submit で全体を検証するのがユーザーフレンドリーです。入力中にエラーを頻繁に出すとユーザーが嫌がるため、パスワード強度のように本当に効果がある場合のみ on change を使います。
非同期チェックは blur 時か短いディバウンス後に実行し、明示的な「確認中」状態を表示します。入力が続く間は古いリクエストをキャンセルするか無視して、遅い応答が新しい入力を上書きしないようにします。
バックエンドごとにフォーマットが違うため、まずはどんな形式でも内部で { fieldErrors: { key: [messages] }, formErrors: [messages] } のような統一形に正規化します。パス表現はドット表記(address.street)など一つに揃えると、fieldErrors['address.street'] のように常に読み取れます。
フォーム全体のエラー(例:プラン上限、支払いが必要)はフォーム上部に表示し、フィールド固有のエラーは該当する入力の横に表示します。送信失敗時は、表示されているフォームの中で最初に該当するフィールドをフォーカスしてスクロールし、ユーザーが編集するとそのフィールドのサーバーエラーは消すようにします。


