📝

Frontend Component - Form編 ①

2025/01/23に公開

はじめに

Webエンジニアをしているcocoです。
普段はフリーランス的に活動しており、余った時間で個人開発をしています。

今回はFrontend Component開発時に意識していること・考えていることの共有になります。
Form編①ではFormの中でもinputやselectなど実際に値を入れるUIについて話していきます。

この記事ではformという単語がform全体をイメージさせ、混同を避けるためにinputなどの各項目のことをfieldと呼ぶことにします。
htmlのformの一部のグルーピングを表す要素であるfieldsetを参考に命名しました。

IF

reactにおいて一番基本的なfieldの使用は以下のようなものかと思います

export default function Form() {
    const [title, setTitle] = useState('')
    return (
        <input
            value={title} // 画面に出力する今の値
            onChange={e => setTitle(e.target.value)} // 値の更新
        />
    )
}

文字列入力であるinputに現状の値をvalueで渡し、onChangeで変更を受け取ったのちに値の更新を行なっています。

当たり前の話ですが、画面上に出力される値は必ずvalueで渡した値になり、値の更新は使用側のコンポーネントで行なっています。
inputはユーザーが画面で操作を行う対象であるだけで、その対象の状態・更新は使用側に依存するということです。

このIFメンタルモデルは以下のようなメリットをもたらすため、極力全てのfieldコンポーネントに適用するべきだと考えています。

  • 全てのfieldでIFの形式・データフローを統一することで各fieldコンポーネントの内部実装を見なくとも挙動を理解することができる
  • 依存先が最小限であり再利用性が高い
  • propsの値から現状画面に出ているものが明確である

次の実例のセクションでイメージしてもらえればと思います。

実例

以下のコードをリファクタしながら考えていきましょう

// 記事の公開ステータス
type ArticleStatus = 'draft' | 'public' | 'private' // 下書き or 公開 or プライベート公開
// 記事フォームの状態
type ArticleFormState = {
    title: string,
    status: ArticleStatus
}
// 記事フォームの初期値
const defaultArticleFormState = (): ArticleFormState => ({
    title: '',
    status: 'draft',
})

export default function ArticleForm() {
    const [formState, setFormState] = useState<ArticleFormState>(defaultArticleFormState())

    const onChangeState = (e: React.ChangeEvent<HTMLSelectElement>) => {
        const val = e.target.value
        if (!['draft', 'public', 'private'].includes(val)) {
            return
        }
        setFormState(current => ({
            ...current,
            status: val
        }))
    }

    return (
        <form>
            // 記事タイトル用のfield
            <ArticleTitleInput value={formState} onChange={setFormState} />

             // 記事公開ステータス用のfield
            <select
                value={formState.status}
                onChange={onChangeState}
            >
                <option value="draft">Draft</option>
                <option value="public">Public</option>
                <option value="private">Private</option>
            </select>
        </form>
    )
}

// 記事タイトル用のfield
function ArticleTitleInput(props: {
    value: ArticleFormState,
    onChange: (value: ArticleFormState) => void
}) {
    const { value, onChange } = props
    return (
        <input
            value={value.title}
            onChange={e => onChange({
                ...value,
                title: e.target.value
            })}
        />
    )
}

記事タイトル用のfieldのリファクタ

まず、記事タイトル用のfieldについてです。一見#IFでお話ししたメンタルモデルに即しているように思えます。

しかしこのArticleTitleInputは必要以上にArticleFormState依存していて、利用側からすると、onChangeでどこのpropertyがどのように変化したのかわかりづらい状態にあります。
意図していないタイミングでtitleが更新されないか?、なんならtitleじゃないものが変更されたりしないか?など考えることが増えますね。

さらに再利用性が低く、記事の作成と更新でArticleCraeteFormState, ArticleUpdateFormStateのようにFormStateが分かれる場合にはArticleTitleInputの内部実装は単純なコンポーネントであるはずが必要以上に複雑化されていきます。

これは大きいフォームをリファクタリングしたときによく見られるコードで、依存先を必要最低限まで減らすことで改善することができます。

// 記事タイトル用のfield
function ArticleTitleInput(props: {
    value: string,
    onChange: (value: string) => void
}) {
    const { value, onChange } = props
    return (
        <input value={value} onChange={e => onChange(e.target.value)} />
    )
}

記事公開ステータス用のfieldのリファクタ

次に記事公開ステータス用のfieldについてです。これはformの中にベタがきでfieldの実装がされています。

一見何も問題はないですが、formの項目が増えてくるとtemplate部分が肥大化していき、表示されるUIが想像しづらく、作成用・更新用でフォームが分かれる場合には両方に同じコードを書くことになります。
これについても#IFで述べた方針でコンポーネント分けをすることで改善することができます。

// 記事公開ステータス用のfield
function ArticleStatusSelect(props: {
    value: ArticleStatus,
    onChange: (value: ArticleStatus) => void
}) {
    const { value } = props

    const onChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
        const val = e.target.value
        if (!['draft', 'public', 'private'].includes(val)) {
            return
        }
        props.onChange(val)
    }

    return (
        <select
            value={value}
            onChange={e => {
                const val = e.target.value
                if (!['draft', 'public', 'private'].includes(val)) {
                    return
                }
                onChange={onChange}
            }}
        >
            <option value="draft">Draft</option>
            <option value="public">Public</option>
            <option value="private">Private</option>
        </select>
    )
}

リファクタ後のForm

これらを使用したformは最終的に次のようになります

export default function ArticleForm() {
    const [formState, setFormState] = useState<ArticleFormState>(defaultArticleFormState())

    return (
        <form>
            // 記事タイトル用のfield
            <ArticleTitleInput
                value={formState.title}
                onChange={val => setFormState(current => ({
                    ...current,
                    title: val
                }))}
            />

            // 記事公開ステータス用のfield
            <ArticleStatusSelect
                value={form.status}
                onChange={val => setFormState(current => ({
                    ...current,
                    state: val
                }))}
            />
        </form>
    )
}

最初のものと比較するとフォームの状態を変更しうるロジックがArticleFormに集約され、かつ各コンポーネントの挙動がコンポーネント名とIFから想像が容易なものになったかと思います。
さらに、それぞれが適切なコンポーネント名を持つことでtemplate部分に表示されるUIが想像しやすくなったかと思います。

振り返り

今回はfieldのIFとそのメンタルモデルを紹介し、実例をもとにリファクタリングを行いました。その結果

  • 各コンポーネントの挙動が予測できるようになる
  • UIの状態の想像のしやすさの向上
  • 適切な場所にロジックが集約され、データコントロールが容易になった

と多くのメリットが得られたかと思います。(私のように脳内のメモリが少ないタイプにはこのように適切な単位で切り出されたコンポーネントは脳内メモリの削減につながって大変助かります)

今回は各fieldの(データフローの)IFについてのみ紹介にあげたのでvalidationなどについては触れませんでしたが今後触れていきたいと思います。

Discussion