Reactで作った自前バリデーションのカスタムフックを汎用的にリファクタリングする

目次

はじめに

前回の記事で「見やすく直しやすいコードを書きなさい」と言った手前、冗長な処理を残したままにするわけにはいきません。

というわけで、今回は自前バリデーションのカスタムフックを見やすく直しやすくしてみました。

実物はこちら

あわせて読みたい
React Recipe Reactアプリのレシピ集

リポジトリはこちら

GitHub
GitHub - hakoratory/react-recipe Contribute to hakoratory/react-recipe development by creating an account on GitHub.

概要

この記事を読むことで、以下のような画面が作れるようになります。

前回作成したコードのカスタムフックの部分を再掲します。
この二つのカスタムフックには、ほとんど同じという問題点があります。

const useTextInput = (): [{ value: string, errors: string[]}, Dispatch<SetStateAction<string>>] => {
    const [value, setValue] = useState<string>('')
    const [errors, setErrors] = useState<string[]>([])

    useEffect(() => {
        const validate = (): string[] => {
            const errors: string[] = [];
            if (value === '') {
                errors.push('入力してください')
            }
            if (value.length > 30) {
                errors.push('30字以下で入力してください')
            }
            return errors
        }

        setErrors(validate())

    }, [value]);

    return [{ value, errors }, setValue]
}

const useAlphaInput = (): [{ value: string, errors: string[]}, Dispatch<SetStateAction<string>>] => {
    const [value, setValue] = useState<string>('')
    const [errors, setErrors] = useState<string[]>([])

    useEffect(() => {
        const validate = (): string[] => {
            const errors: string[] = [];
            if (value === '') {
                errors.push('入力してください')
            }
            if (value.length > 30) {
                errors.push('30字以下で入力してください')
            }
            if (!value.match(/^[a-zA-Z ]*$/)) {
                errors.push('半角英字で入力してください')
            }
            return errors
        }

        setErrors(validate())

    }, [value]);

    return [{ value, errors }, setValue]
}

もし仕様変更が入り、「未入力のときのエラーメッセージを変えてほしい」と言われたとします。
いまのままだと、まったく同じ改修をハイライトした二箇所に対して行わないといけません。

人は間違えるものです。
今回はたまたま二箇所ですが、これが何個もあった日にはもう目も当てられません。
ここはタイポがある、ここは直し忘れている、ここは・・・というようにバグのもとです。

このようなときは「まとめる方法はないかな?」と見直してみましょう。

実装

先にコード全容です。

'use client'

import {Box, Button, Typography} from "@mui/material";
import {ChangeEvent, Dispatch, FormEvent, SetStateAction, useEffect, useState} from "react";
import './style.css'
import {ValidateRule, InputType} from "@/types/validate-custom-hook";

const defaultRule: ValidateRule = {
    required: true,
    maxLength: 30,
}

const kanaRule: ValidateRule = {
    required: false,
    maxLength: 50,
}

const useValidate = (value: string, type: InputType, rule: ValidateRule) => {
    const [errors, setErrors] = useState<string[]>([])

    useEffect(() => {
        const validate = (): string[] => {
            const errors: string[] = [];
            if (rule.required && value === '') {
                errors.push('入力してください')
            }
            if (value.length > rule.maxLength) {
                errors.push(`${rule.maxLength}字以下で入力してください`)
            }
            if (type === 'alpha' && !value.match(/^[a-zA-Z ]*$/)) {
                errors.push('半角英字で入力してください')
            }
            return errors
        }
        setErrors(validate())
        // rule を依存関係に含めていないため警告が出るが、含めたくないので抑止する。
        // eslint-disable-next-line react-hooks/exhaustive-deps
    }, [value]);

    return errors
}

const useTextInput = (rule: ValidateRule = defaultRule): [{ value: string, errors: string[]}, Dispatch<SetStateAction<string>>] => {
    const [value, setValue] = useState<string>('')
    const errors = useValidate(value, 'text', rule)

    return [{ value, errors }, setValue]
}

const useAlphaInput = (rule: ValidateRule = defaultRule): [{ value: string, errors: string[]}, Dispatch<SetStateAction<string>>] => {
    const [value, setValue] = useState<string>('')
    const errors = useValidate(value, 'alpha', rule)

    return [{ value, errors }, setValue]
}

export default function ValidateCustomHook2() {
    const [fullName, setFullName] = useTextInput()
    const [fullNameKana, setFullNameKana] = useTextInput(kanaRule)
    const [fullNameEnglish, setFullNameEnglish] = useAlphaInput({ required: true, maxLength: 40 })

    const onSubmit = (event: FormEvent<HTMLFormElement>) => {
        event.preventDefault()

        if (fullName.errors.length || fullNameKana.errors.length) {
            return
        }

        alert('No validate errors!')
    }

    const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
        switch (event.target.name) {
            case 'fullName': {
                setFullName(event.target.value)
                break
            }
            case 'fullNameKana': {
                setFullNameKana(event.target.value)
                break
            }
            case 'fullNameEnglish': {
                setFullNameEnglish(event.target.value)
                break
            }
            default:
        }
    }

    return (
        <main>
            <Box mt={2} ml={2}>
                <form onSubmit={onSubmit}>
                    <Box mb={2}>
                        <Box mb={1}>
                            <span>名前</span>
                        </Box>
                        <Box>
                            <input type="text" name="fullName" className={fullName.errors.length ? 'error' : ''} onChange={handleChange} />
                        </Box>
                        <Box height="1rem" sx={{color: 'red'}}>
                            {
                                fullName.errors.map((error, index) => <span key={`${index}-fullName`}>{error}</span>)
                            }
                        </Box>
                    </Box>
                    <Box mb={2}>
                        <Box mb={1}>
                            <span>かな</span>
                        </Box>
                        <Box>
                            <input type="text" name="fullNameKana" className={fullNameKana.errors.length ? 'error' : ''} onChange={handleChange} />
                        </Box>
                        <Box height="1rem" sx={{color: 'red'}}>
                            {
                                fullNameKana.errors.map((error, index) => <span key={`${index}-fullNameKana`}>{error}</span>)
                            }
                        </Box>
                    </Box>
                    <Box mb={2}>
                        <Box mb={1}>
                            <span>Full Name</span>
                        </Box>
                        <Box>
                            <input type="text" name="fullNameEnglish" className={fullNameEnglish.errors.length ? 'error' : ''} onChange={handleChange} />
                        </Box>
                        <Box height="1rem" sx={{color: 'red'}}>
                            {
                                fullNameEnglish.errors.map((error, index) => <span key={`${index}-fullNameEnglish`}>{error}</span>)
                            }
                        </Box>
                    </Box>
                    <Box>
                        <Button type="submit" variant="contained" disabled={fullName.errors.length > 0 || fullNameKana.errors.length > 0 || fullNameEnglish.errors.length > 0}>SUBMIT</Button>
                    </Box>
                </form>
            </Box>
            <Box mt={2} ml={2}>
                <Box>
                    <Typography variant="body2">
                        {fullNameKana.value !== '' && `${fullNameKana.value}さん、こんにちは。` }
                    </Typography>
                </Box>
                <Box>
                    <Typography variant="h4">
                        {fullName.value !== '' && `${fullName.value}さん、こんにちは。` }
                    </Typography>
                </Box>
            </Box>
        </main>
    )
}

各部分の解説

バリデーション部分を抜き出したカスタムフック useValidate

前回のコードから、エラーの state と、useEffect によるバリデーション処理を抜き出しています。

const useValidate = (value: string, type: InputType, rule: ValidateRule) => {
    const [errors, setErrors] = useState<string[]>([])

    useEffect(() => {
        const validate = (): string[] => {
            const errors: string[] = [];
            if (rule.required && value === '') {
                errors.push('入力してください')
            }
            if (value.length > rule.maxLength) {
                errors.push(`${rule.maxLength}字以下で入力してください`)
            }
            if (type === 'alpha' && !value.match(/^[a-zA-Z ]*$/)) {
                errors.push('半角英字で入力してください')
            }
            return errors
        }
        setErrors(validate())
        // rule を依存関係に含めていないため警告が出るが、含めたくないので抑止する。
        // eslint-disable-next-line react-hooks/exhaustive-deps
    }, [value]);

    return errors
}

せっかくなので、バリデーションのルールも呼び出し側で指定できるように改修もしています。
必須入力、最大桁数、文字種の型を用意しています。

export type ValidateRule = {
    required: boolean,
    maxLength: number,
}

export type InputType = 'text' | 'alpha'

文字種だけ別で定義しているのは、呼び出し側で指定させたくないからです。
半角英字の入力欄のはずなのに、使われている場所によって日本語も入力できたらおかしいですよね。

useValidate を使う

作成した useValidateuseTextInputuseAlphaInput に組み込んでみましょう。
呼び出し側も載せておきます。

const defaultRule: ValidateRule = {
    required: true,
    maxLength: 30,
}

const kanaRule: ValidateRule = {
    required: false,
    maxLength: 50,
}

======== 中略 ========

const useTextInput = (rule: ValidateRule = defaultRule): [{ value: string, errors: string[]}, Dispatch<SetStateAction<string>>] => {
    const [value, setValue] = useState<string>('')
    const errors = useValidate(value, 'text', rule)

    return [{ value, errors }, setValue]
}

const useAlphaInput = (rule: ValidateRule = defaultRule): [{ value: string, errors: string[]}, Dispatch<SetStateAction<string>>] => {
    const [value, setValue] = useState<string>('')
    const errors = useValidate(value, 'alpha', rule)

    return [{ value, errors }, setValue]
}

export default function ValidateCustomHook2() {
    const [fullName, setFullName] = useTextInput()
    const [fullNameKana, setFullNameKana] = useTextInput(kanaRule)
    const [fullNameEnglish, setFullNameEnglish] = useAlphaInput({ required: true, maxLength: 40 })

useTextInputuseAlphaInput の重複した部分が解消していること、呼び出し側でバリデーションルールを指定できていることを確認してください。

おわりに

いかがだったでしょうか?
今回は、カスタムフックの冗長だった箇所をリファクタリングしてみました。

これで必須入力や最大桁数の他にも、最小桁数や電話番号・メールアドレスなどの形式チェックなんかも気軽に追加していけますね。

カスタムフックを作ってロジックがスッキリしたら、次はJSXの冗長なところが気になってきましたね。。。笑
次回はJSXの冗長な箇所をコンポーネント化してみようと思います。

ハコラトリは、Reactを習得したい駆け出しエンジニアを応援しています。

Reactレシピでは、これからもReactで色々なUIを作って紹介していきますので、「こんなUIを作ってみてほしい」「こんな場合はどうすれば?」といったアイディアを募集中です!
コメントお待ちしております。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

28歳のときに音楽家からエンジニアに転向。
WEB系のシステム開発(Java, C#, Vue, React など)に携わっています。

「できる」を増やしてワクワクな人生を送るための情報をシェアしています。

コメント

コメントする

目次