3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

はじめに

業務アプリでは、フォームのバリデーション設計がUI/UXの質や保守性に直結します。本記事では、私が実際に設計・実装した「汎用的なバリデーション設計例」を紹介します。

背景

  • フォームの項目が多くなると、各項目にバリデーションロジックが散在しやすくなる。
  • バリデーションロジックを再利用したく、フォーム開発を楽になりたい。
  • 保守・拡張がしやすい共通的な仕組みが必要。

設計方針

項目 内容
バリデーションのルール 配列として各項目に持たせ、柔軟に追加できるように。
バリデーション実行 汎用関数 formItemsVali で一括管理。
バリデーション結果 Form.ItemvalidateStatus, help に即反映。
他項目依存バリデーション バリデーション関数の引数に全体オブジェクトを渡す。

実装例

  • バリデーション関数
    validation.ts
    // 空値チェック(文字列・数値対応)
    export const required = (value: string | number | null | undefined): boolean => {
        if (value === null || value === undefined) return false;
        if (typeof value === 'string') return value.trim() !== '';
        return true; 
    };
    
    // 冊数の整合性(他項目依存)
    export const fewerOfCount = (
        value: number,
        valueObj: {count: number, countOnBorrowed: number}
    ): boolean => {
        const { count } = valueObj;
        return count >= value;
    }
    

  • バリデーション実行ロジック
    validation.ts
    export const formItemsVali = (
        formItems: CommFormItemProps[],
        valueObj: { [key: string]: any }
    ) => {
        let valiRes = true;
        const newFormItems = formItems.map((item) => {
            if (item.validations && item.validations.length > 0) {
                const { itemProps } = item;
                const value = valueObj[itemProps.name];
                for(const rule of item.validations) {
                    const isValid = rule.vali(value, valueObj);
                    if (!isValid) {
                        item.itemProps.validateStatus = "error";
                        item.itemProps.help = rule.msg;
                        valiRes = false;
                        break;
                    }
                }
            }
            return item;
        });
        return {
            valiRes,
            formItems: newFormItems,
        }
    }
    

formItemsVali の役割

  • 与えられたフォーム項目の配列(formItems)に対し、各項目のバリデーションルールを実行。
  • バリデーション結果に応じて validateStatus や help を更新。
  • フォーム全体が有効かどうか(valiRes)と更新後のフォーム項目を返す。

  • UIの工夫
    CommFormItem.tsx
    const CommFormItem: React.FC<CommFormItemProps> = (props) => {
        const { itemProps, Comp, compProps } = props;
        
        return (
            <Form.Item
                className="comm-form-item-container"
                {...itemProps}
            >
                <Comp {...compProps} />
            </Form.Item>
        )
    }
    

CommFormItem設計の考え

  • 汎用性が高い
    • Comp と compProps を分けることで、Input, Select, DatePicker, InputNumber など、どんなコンポーネントでも同じパターンで描画できます。
    • 新しい部品(例: Upload, Switch)が必要になったときも、Comp にそのコンポーネントを指定するだけで済みます。
  • バリデーションを一元管理
    • バリデーション結果(validateStatus, help)も itemProps 経由で柔軟に制御可能。
  • 保守性が高い
    • もし将来<Form.Item>に追加の共通設定やデザイン変更が必要になっても、CommFormItem の内部だけを修正すれば全フォームに反映される。

  • 使う例
BookEdit.tsx
// 抜粋
const BookEdit: React.FC<BookEditProps> = (props) => {
    
    const [form] = Form.useForm();
    // 動的に生成したフォーム項目定義を保持する state
    const [commFormItems, setCommFormItems] = useState<CommFormItemProps[]>([]);
    // 書籍情報(props.bookInfo)やカテゴリリスト(props.categoryOptions)の変更でフォーム項目を再生成
    useEffect(() => {
        form.setFieldsValue(bookInfo);
        setCommFormItems([{
            itemProps: {
                label: "書籍タイトル",
                name: "title",
                className: "f-item comm-form-item-container"
            },
            validations: [{
                vali: required,
                msg: "入力が必要です。",
            }],
            Comp: Input,
            compProps: {
                value: bookInfo.title,
                maxLength: 200,
                showCount: true,
                onChange: (e: React.ChangeEvent<HTMLInputElement>) => {
                    onItemChange("title", e.target.value);
                }
            },
        }, {
            itemProps: {
                label: "カテゴリ",
                name: "category",
                className: "category-item f-item"
            },
            validations: [{
                vali: required,
                msg: "入力が必要です。",
            }],
            Comp: Select,
            compProps: {
                value: bookInfo.category,
                options: categoryOptions,
                onChange: (value: number) => onItemChange("category", value),
            },
        }, {
            itemProps: {
                label: "サマリー",
                name: "summary",
                className: "f-item comm-form-item-container"
            },
            validations: [],
            Comp: Input.TextArea,
            compProps: {
                maxLength: 500,
                showCount: true,
                value: bookInfo.summary,
                onChange: (e: React.ChangeEvent<HTMLInputElement>) => {
                    onItemChange("summary", e.target.value);
                }
            },
        }, {
            itemProps: {
                label: "著者名",
                name: "author",
                className: "f-item comm-form-item-container"
            },
            validations: [],
            Comp: Input,
            compProps: {
                maxLength: 100,
                showCount: true,
                value: bookInfo.author,
                onChange: (e: React.ChangeEvent<HTMLInputElement>) => {
                    onItemChange("author", e.target.value);
                }
            },
        }, {
            itemProps: {
                label: "ISBNコード",
                name: "isbn",
                className: "f-item comm-form-item-container"
            },
            validations: [],
            Comp: Input,
            compProps: {
                maxLength: 20,
                showCount: true,
                value: bookInfo.isbn,
                onChange: (e: React.ChangeEvent<HTMLInputElement>) => {
                    onItemChange("isbn", e.target.value);
                }
            },
        }, {
            itemProps: {
                label: "出版社名",
                name: "publisher",
                className: "f-item comm-form-item-container"
            },
            validations: [],
            Comp: Input,
            compProps: {
                maxLength: 200,
                showCount: true,
                value: bookInfo.publisher,
                onChange: (e: React.ChangeEvent<HTMLInputElement>) => {
                    onItemChange("publisher", e.target.value);
                }
            },
        }, {
            itemProps: {
                label: "出版日付",
                name: "publishDate",
                className: "count-item f-item"
            },
            validations: [],
            Comp: DatePicker,
            compProps: {
                value: bookInfo.publishDate,
                onChange: (value: string) => {
                    onItemChange("publishDate", value);
                }
            },
        }, {
            itemProps: {
                label: "冊数",
                name: "count",
                className: "count-item"
            },
            validations: [{
                vali: required,
                msg: "入力が必要です。"
            }],
            Comp: InputNumber,
            compProps: {
                value: bookInfo.count,
                onChange: (value: number) => {
                    onItemChange("count", value);
                }
            },
        }, {
            itemProps: {
                label: "貸出中冊数",
                name: "countOnBorrowed",
                className: "count-item"
            },
            validations: [{
                vali: required,
                msg: "入力が必要です。"
            }, {
                vali: fewerOfCount,
                msg: "貸出中の冊数が、冊数を超えてはいけません。",
            }],
            Comp: InputNumber,
            compProps: {
                value: bookInfo.countOnBorrowed,
                onChange: (value: number) => {
                    onItemChange("countOnBorrowed", value);
                }
            },
        }]);
    }, [bookInfo]);
    
    // 入力値の変更時に呼ばれる
    const onItemChange = (key: string, value: string | number) => {
        setBookInfo(prevBookInfo => ({
            ...prevBookInfo,
            [key]: value,
        }));
    }
    
    // 確定ボタンクリック時の処理
    const onSubmit = () => {
        const { valiRes, formItems } = formItemsVali(commFormItems, bookInfo);
        if (!valiRes) {
            setCommFormItems(formItems);
            return;
        }
        // バリデーションOKの場合、サーバ送信などの後続処理
        ...
    }

    return (
        <section className="comm-book-edit-container">
            <Form name="loginForm" form={form}>
                {
                    commFormItems.map((items, index) => (
                        <CommFormItem key={index} {...items} />
                    ))
                }
            </Form>
            
            <Button type="primary" onClick={onSubmit}>
               確定
            </Button>
        </section>
    )
}

まとめ

本記事では、業務アプリにおける 汎用的かつ保守性の高いバリデーション設計 の具体例を紹介しました。今回の設計のポイントは以下の通りです。

  • バリデーションロジックの再利用性が高い
    • バリデーション関数を汎用化し、どのフォーム項目にも簡単に適用可能。
    • 他項目依存バリデーションも対応でき、実業務で求められる複雑なチェックにも強い。
  • コードの見通しが良く、保守が楽
    • 項目定義・バリデーション・UI描画がデータ駆動型で統一。
    • 個別の if 文や分岐処理が散在せず、追加や変更がしやすい。
  • UI/UX の質が向上
    • バリデーション結果が即座に画面に反映され、ユーザーが入力ミスに気づきやすい。
    • フォーム部品の描画も汎用化され、デザインや振る舞いの変更が簡単。
  • 将来の拡張性が高い
    • 新しいバリデーションルールやコンポーネントも既存の枠組みに乗せやすい。
    • 共通の CommFormItem を介することで、大規模フォームでも一貫性を保てる。

最後に

フォームバリデーションは業務アプリのUI品質に直結し、かつ保守負荷の高い領域です。
今回の設計はその負荷を軽減し、チームでの開発や運用をスムーズにする一つのアプローチです。

皆さんのプロジェクトにもぜひ取り入れていただき、より良いフォーム開発のヒントになれば幸いです。

3
2
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?