【Reactレシピ】Contextでバリデーションの状態を取得する

こんにちは。

豆腐グラタンを食べたらお腹を壊したはこだてたろうです。

妻も同じものを食べたのですがケロッとしていました。
ちょっと気になったのでAIに「同じものを食べたのにお腹を壊す人と壊さない人がいるのはなぜか」と質問したところ「抵抗力の差」と言われました。

目次

はじめに

さて、以下の記事ではバリデーションつきの入力欄やセレクトボックスのコンポーネントを作成しました。

バリデーションをする画面でよくある仕様として「入力エラーがある場合はボタンを非活性にして押せないようにする」というものがあります。

今回は、その機能を実装してみたいと思います。

実物はこちら

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

リポジトリはこちら

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

この記事を読むことで作れるもの

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

少しわかりにくいですが、郵便番号の入力欄で入力エラーが起きているので送信ボタンが非活性になっています(通常時は青色のボタン)。

実装

先にコード全容です。

今回新しく追加した ValidationContext のコードをご紹介します。

import React, {createContext, ReactNode, useContext, useState} from "react";

type ValidationError = { key: string; message: string };
type ValidationErrors = Array<ValidationError>;

type ValidationContextType = {
  errors: ValidationErrors;
  setError: (field: string, message: string) => void;
  clearError: (field: string) => void;
  getError: (field: string) => ValidationError | undefined;
};

const ValidationContext = createContext<ValidationContextType>({
  errors: [],
  setError: () => {},
  clearError: () => {},
  getError: () => undefined
});

export const ValidationContextProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
  const [errors, setErrors] = useState<ValidationErrors>([]);

  const setError = (field: string, message: string) => {
    setErrors(prev => {
      const filteredErrors = prev.filter(err => err.key !== field);
      const newError: ValidationError = { key: field, message: message };
      const updatedErrors: ValidationErrors = [...filteredErrors, newError];
      return updatedErrors;
    });
  };

  const clearError = (field: string) => {
    setErrors(prev => prev.filter(err => err.key !== field));
  };

  const getError = (field: string): ValidationError | undefined => {
    return errors.find(err => err.key === field);
  };

  return (
    <ValidationContext.Provider value={{ errors, setError, clearError, getError }}>
      {children}
    </ValidationContext.Provider>
  );
};

export const useValidationContext = () => {
  return useContext(ValidationContext);
};

各部分の解説

ValidationContext

入力エラー情報を ValidationContext に格納します。

この Context には、エラー情報として { key: string; message: string } のオブジェクト配列を格納します。
入力エラーが起きたときに、key にはその入力欄を特定するための値を、message には画面に表示するエラーメッセージを入れます。

多くの場合、入力欄は複数あると思うのでエラー情報も配列で持ちます。

type ValidationError = { key: string; message: string };
type ValidationErrors = Array<ValidationError>;

type ValidationContextType = {
  errors: ValidationErrors;
  setError: (field: string, message: string) => void;
  clearError: (field: string) => void;
  getError: (field: string) => ValidationError | undefined;
};

const ValidationContext = createContext<ValidationContextType>({
  errors: [],
  setError: () => {},
  clearError: () => {},
  getError: () => undefined
});

ValidationContextProvider

ValidationContext に格納した情報にアクセスできるようにするためのプロバイダーを作成します。

export const ValidationContextProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
  const [errors, setErrors] = useState<ValidationErrors>([]);

  const setError = (field: string, message: string) => {
    setErrors(prev => {
      const filteredErrors = prev.filter(err => err.key !== field);
      const newError: ValidationError = { key: field, message: message };
      const updatedErrors: ValidationErrors = [...filteredErrors, newError];
      return updatedErrors;
    });
  };

  const clearError = (field: string) => {
    setErrors(prev => prev.filter(err => err.key !== field));
  };

  const getError = (field: string): ValidationError | undefined => {
    return errors.find(err => err.key === field);
  };

  return (
    <ValidationContext.Provider value={{ errors, setError, clearError, getError }}>
      {children}
    </ValidationContext.Provider>
  );
};

setError は入力エラーを ValidationContext に格納します。
すでに別の入力欄で入力エラーが発生している可能性もあるので、既存の入力エラーを保持するようにしていることに注目してください。

また、この更新処理は説明変数を使用しているので4行あります。
やろうと思えば1行で書けるのですが、読みやすさを優先するならこのように書くのも手です。

clearError は指定された入力欄の入力エラー情報を削除します。

getError は指定された入力欄の入力エラー情報を返します。

useValidationContext

ValidationContext を取得する処理は、カスタムフックに抽出します。

export const useValidationContext = () => {
  return useContext(ValidationContext);
};

ValidationContext に入力エラー情報を格納する

入力欄からValidationContext に入力エラー情報を格納するよう変更します。

label をキーにして、入力エラーを ValidationContext に格納していることを確認してください。

なお、ラベルはキーとしてふさわしいか問題があります。
この程度の画面の規模であれば重複することはないですが、規模が大きくなれば重複する可能性が出てきそうです。
ちゃんとするなら props.name を新しく追加するのがいいかもしれませんね。

function LabeledTextInputWithValidation(
  // props 略
) {
  const { setError, clearError, getError } = useValidationContext();

  const validate = (value: string): string => {
    if (required && value.trim() === '') {
      return `必須項目です。`;
    }
    if (max !== undefined && value.length > max) {
      return `${max}文字以内で入力してください。`;
    }
    if (min !== undefined && value.length < min) {
      return `${min}文字以上で入力してください。`;
    }
    if (pattern) {
      for (const { regex, message } of pattern) {
        if (!regex.test(value)) {
          return message;
        }
      }
    }
    return '';
  };

  const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
    const newValue = event.target.value;
    onChange(event);
    const errorMessage = validate(newValue);
    if (errorMessage) {
      setError(label, errorMessage);
    } else {
      clearError(label);
    }
  };

  return (
    <>
      <LabeledTextInput
        id={id}
        label={label}
        value={value}
        onChange={handleChange}
        placeholder={placeholder}
      />
      <div style={{color: 'red', fontSize: '1rem', minHeight: '1.5rem', marginTop: '0.5rem'}}>
        <ErrorMessage text={getError(label)?.message ?? ''} />
      </div>
    </>
  )
}

export default LabeledTextInputWithValidation;

セレクトボックスの方も同様に変更します。

function LabeledSelectBoxWithValidation(
  // props 略
) {
  const { setError, clearError, getError } = useValidationContext();

  const validate = (value: string): string => {
    if (required && value.trim() === '') {
      return `必須項目です。`;
    }
    return '';
  };

  const handleChange = (event: ChangeEvent<HTMLSelectElement>) => {
    const newValue = event.target.value;
    onChange(event);
    const errorMessage = validate(newValue);
    if (errorMessage) {
      setError(label, errorMessage);
    } else {
      clearError(label);
    }
  };

  return (
    <>
      <LabeledSelectBox
        id={id}
        label={label}
        value={value}
        options={options}
        onChange={handleChange}
      />
      <div style={{color: 'red', fontSize: '1rem', minHeight: '1.5rem', marginTop: '0.5rem'}}>
        <ErrorMessage text={getError(label)?.message ?? ''} />
      </div>
    </>
  )
}

ValidationFormButton

ValidationContext の値を取得するボタンコンポーネントを作成します。

入力エラーが起きている場合は非活性表示になるようにしています。

import React from 'react';
import Button, {ButtonProps} from "@/app/input-field/form-state/components/Button";
import {useValidationContext} from "@/app/input-field/form-state/contexts/ValidationContext";

type ValidationFormButtonProps = Omit<ButtonProps, 'disabled'>

function ValidationFormButton({ text, type, onClick }: ValidationFormButtonProps) {
  const {errors} = useValidationContext()
  return <Button text={text} type={type} onClick={onClick} disabled={errors.length > 0}/>
}

export default ValidationFormButton;

ValidationContextProvider でラップする

最後に、ValidationContext にアクセスできるようにするために、ValidationContextProvider でフォームを囲みましょう。

// import文 略

function FormState() {
  // stateなど 略

  return (
    <main>
      <div style={{marginTop: '2rem', marginLeft: '2rem'}}>
        <ValidationContextProvider>
          <form onSubmit={handleSubmit}>
            <div>
              <LabeledTextInputWithValidation
                label={'名前'}
                value={name}
                onChange={(event: ChangeEvent<HTMLInputElement>) => setName(event.target.value)}
                placeholder={'名前を入力してください'}
                required
                max={50}
                min={2}
              />
            </div>
            <div>
              <LabeledTextInputWithValidation
                label={'名前(カナ)'}
                value={nameKana}
                onChange={(event: ChangeEvent<HTMLInputElement>) => setNameKana(event.target.value)}
                placeholder={'名前(カナ)を入力してください'}
                required
                max={50}
                min={2}
                pattern={[
                  {regex: /^[ァ-ヴ]+$/, message: 'カタカナのみ使用できます'}
                ]}
              />
            </div>
            <div style={{marginTop: '1rem'}}>
              <LabeledTextInputWithValidation
                label={'郵便番号'}
                value={postalCode}
                onChange={(event: ChangeEvent<HTMLInputElement>) => setPostalCode(event.target.value)}
                placeholder={'郵便番号を入力してください'}
                required
                max={7}
                min={7}
                pattern={[
                  {regex: /^[0-9]+$/, message: '数字のみ使用できます'}
                ]}
              />
            </div>
            <div>
              <LabeledSelectBoxWithValidation
                value={prefecture}
                options={prefectures}
                onChange={e => setPrefecture(e.target.value)}
                label={'都道府県'}
                required
              />
            </div>
            <div>
              <LabeledTextInputWithValidation
                label={'市区町村以降の住所'}
                value={address}
                onChange={(event: ChangeEvent<HTMLInputElement>) => setAddress(event.target.value)}
                placeholder={'市区町村以降の住所を入力してください'}
                required
                max={50}
              />
            </div>
            <div>
              <LabeledTextInputWithValidation
                label={'電話番号'}
                value={phoneNumber}
                onChange={(event: ChangeEvent<HTMLInputElement>) => setPhoneNumber(event.target.value)}
                placeholder={'電話番号を入力してください'}
                required
                max={11}
                min={10}
                pattern={[
                  {regex: /^[0-9]+$/, message: '数字のみ使用できます'}
                ]}
              />
            </div>
            <div style={{marginTop: '0.5rem'}}>
              <ValidationFormButton type="submit" text="送信"/>
            </div>
          </form>
        </ValidationContextProvider>
      </div>
    </main>
  );
}

export default FormState;

おわりに

いかがだったでしょうか?

今回は、Context を使って入力エラーが起きている場合はボタンを押せなくするようにしてみました。

このやり方は、ValidationContextProvider がトップレベルで使われているという前提でLabeledTextInputWithValidationValidationFormButton を使います。
もし ValidationContextProvider でラップするのを忘れたら、LabeledTextInputWithValidationValidationFormButton はちゃんと動かないので注意が必要です。

このようなコンポーネント間の依存がある状態は、好き嫌いが分かれるところですね。
もっといい方法を思いついた方は、ぜひ作ってみてください。

ハコラトリでは、「できる」を増やしてワクワクする人生を送るための情報をシェアしています。
ぜひ一緒にワクワクな人生を送れるように「できる」を増やしていきましょう。

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

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

この記事を書いた人

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

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

コメント

コメントする

目次