入力チェック

Part 5 · フォーム

入力チェック

型と実行時チェックを両立させる

Part 0 で TypeScript の型をやりました。ですが、型はユーザーの入力を守ってくれません

型のチェックは書いているときとビルドのときだけで、動いている最中には存在しません。 「メールアドレスの形か」を判断できるのは、実行時のチェックだけです。

この章では、その書き方といつエラーを出すかを扱います。

型は実行時には効かない

type Form = { email: string };

// 型としては通ってしまう。string ではあるので
const form: Form = { email: "これはメールアドレスではない" };

string であることは型が保証しますが、その中身が正しいかどうかは別の話です。 ユーザーは何でも打てます。

だから、値を受け取って問題があれば教えてくれる関数を自分で用意します。

const validateEmail = (value: string) => {
  if (value.trim() === "") return "メールアドレスを入力してください";
  if (!value.includes("@")) return "@ が含まれていません";
  return null; // 問題なし
};

この関数には React の要素がひとつも入っていません。ただの関数です。 だから単体で読めますし、単体で試せます。 チェックの処理はコンポーネントの外に出しておくと、あとで楽になります。

validation.tsx

"use client";

import { useTrackDemoRender } from "@/components/lesson/demo-card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useState } from "react";

// 値を受け取って、問題があればその内容を返すだけの関数。
// React とは無関係なので、単体で読めるし試せる
const validateEmail = (value: string) => {
  if (value.trim() === "") return "メールアドレスを入力してください";
  if (!value.includes("@")) return "@ が含まれていません";
  return null;
};

export function Validation() {
  // このカードが描き直された回数を数えるための 1 行(教材の仕掛け)
  useTrackDemoRender();

  const [email, setEmail] = useState("");
  // 一度でも触ったかどうか。触る前からエラーを出さないために持つ
  const [touched, setTouched] = useState(false);

  // エラーは state にしない。値から計算できる
  const error = validateEmail(email);
  const showError = touched && error !== null;

  return (
    <div className="flex flex-col gap-3">
      <Input
        placeholder="メールアドレス"
        value={email}
        onChange={(event) => setEmail(event.target.value)}
        // 入力欄から離れた時点で「触った」とみなす
        onBlur={() => setTouched(true)}
        aria-invalid={showError}
      />

      {showError && <p className="text-sm text-red-600">{error}</p>}

      <Button size="sm" disabled={error !== null}>
        送信
      </Button>
    </div>
  );
}

エラーは state にしない

エラーメッセージを useState で持ちたくなりますが、その必要はありません。

// ✕ 二重に持つことになる
const [error, setError] = useState<string | null>(null);

// ○ 値から計算する
const error = validateEmail(email);

エラーは入力値から必ず決まります。 Part 4-3 でやった「計算できるものは state にしない」がそのまま当てはまります。

state にしてしまうと、入力のたびにsetError を呼ぶ必要が出てきて、 呼び忘れれば直したのにエラーが消えないという状態になります。

validation.tsx

"use client";

import { useTrackDemoRender } from "@/components/lesson/demo-card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useState } from "react";

// 値を受け取って、問題があればその内容を返すだけの関数。
// React とは無関係なので、単体で読めるし試せる
const validateEmail = (value: string) => {
  if (value.trim() === "") return "メールアドレスを入力してください";
  if (!value.includes("@")) return "@ が含まれていません";
  return null;
};

export function Validation() {
  // このカードが描き直された回数を数えるための 1 行(教材の仕掛け)
  useTrackDemoRender();

  const [email, setEmail] = useState("");
  // 一度でも触ったかどうか。触る前からエラーを出さないために持つ
  const [touched, setTouched] = useState(false);

  // エラーは state にしない。値から計算できる
  const error = validateEmail(email);
  const showError = touched && error !== null;

  return (
    <div className="flex flex-col gap-3">
      <Input
        placeholder="メールアドレス"
        value={email}
        onChange={(event) => setEmail(event.target.value)}
        // 入力欄から離れた時点で「触った」とみなす
        onBlur={() => setTouched(true)}
        aria-invalid={showError}
      />

      {showError && <p className="text-sm text-red-600">{error}</p>}

      <Button size="sm" disabled={error !== null}>
        送信
      </Button>
    </div>
  );
}

いつエラーを出すか

チェックそのものより、いつ見せるかのほうが体験を左右します。

入力欄に触った瞬間から「入力してください」と赤字が出たら、 何も悪いことをしていないのに怒られている気分になります。

const [touched, setTouched] = useState(false);

const error = validateEmail(email);
const showError = touched && error !== null;
触ってから出るエラー

入力欄から離れると、はじめてエラーが出る

touched は state です。 「ユーザーが触ったかどうか」は、他の何からも計算できません。これは持つべき stateです。

一方で、送信ボタンを押せるかどうかはerror !== null で決まります。こちらは計算です。何を持ち、何を計算するかの判断が、ここでも効いています。

validation.tsx

"use client";

import { useTrackDemoRender } from "@/components/lesson/demo-card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useState } from "react";

// 値を受け取って、問題があればその内容を返すだけの関数。
// React とは無関係なので、単体で読めるし試せる
const validateEmail = (value: string) => {
  if (value.trim() === "") return "メールアドレスを入力してください";
  if (!value.includes("@")) return "@ が含まれていません";
  return null;
};

export function Validation() {
  // このカードが描き直された回数を数えるための 1 行(教材の仕掛け)
  useTrackDemoRender();

  const [email, setEmail] = useState("");
  // 一度でも触ったかどうか。触る前からエラーを出さないために持つ
  const [touched, setTouched] = useState(false);

  // エラーは state にしない。値から計算できる
  const error = validateEmail(email);
  const showError = touched && error !== null;

  return (
    <div className="flex flex-col gap-3">
      <Input
        placeholder="メールアドレス"
        value={email}
        onChange={(event) => setEmail(event.target.value)}
        // 入力欄から離れた時点で「触った」とみなす
        onBlur={() => setTouched(true)}
        aria-invalid={showError}
      />

      {showError && <p className="text-sm text-red-600">{error}</p>}

      <Button size="sm" disabled={error !== null}>
        送信
      </Button>
    </div>
  );
}

項目が増えたら道具を使う

項目が 2 つ 3 つのうちは、いまの書き方で十分です。 ですが 10 個になると、useStatetouched が項目の数だけ並ぶことになります。

そうなったら、専用の道具を使います。

  • react-hook-form … 入力欄の値と触ったかどうかをまとめて管理してくれる
  • zod … チェックのルールを宣言的に書ける。しかもそこから型を作れるので、型とチェックがズレない
// zod で書くと、ルールと型が 1 か所にまとまる
const schema = z.object({
  email: z.string().email("メールアドレスの形式ではありません"),
  age: z.number().min(0),
});

type Form = z.infer<typeof schema>; // 型はここから作られる

validation.tsx

"use client";

import { useTrackDemoRender } from "@/components/lesson/demo-card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useState } from "react";

// 値を受け取って、問題があればその内容を返すだけの関数。
// React とは無関係なので、単体で読めるし試せる
const validateEmail = (value: string) => {
  if (value.trim() === "") return "メールアドレスを入力してください";
  if (!value.includes("@")) return "@ が含まれていません";
  return null;
};

export function Validation() {
  // このカードが描き直された回数を数えるための 1 行(教材の仕掛け)
  useTrackDemoRender();

  const [email, setEmail] = useState("");
  // 一度でも触ったかどうか。触る前からエラーを出さないために持つ
  const [touched, setTouched] = useState(false);

  // エラーは state にしない。値から計算できる
  const error = validateEmail(email);
  const showError = touched && error !== null;

  return (
    <div className="flex flex-col gap-3">
      <Input
        placeholder="メールアドレス"
        value={email}
        onChange={(event) => setEmail(event.target.value)}
        // 入力欄から離れた時点で「触った」とみなす
        onBlur={() => setTouched(true)}
        aria-invalid={showError}
      />

      {showError && <p className="text-sm text-red-600">{error}</p>}

      <Button size="sm" disabled={error !== null}>
        送信
      </Button>
    </div>
  );
}

理解できたか確かめる

確認クイズ

TypeScript の型があれば、入力チェックは不要?

確認クイズ

エラーメッセージを useState で持つべき?

validation.tsx

"use client";

import { useTrackDemoRender } from "@/components/lesson/demo-card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useState } from "react";

// 値を受け取って、問題があればその内容を返すだけの関数。
// React とは無関係なので、単体で読めるし試せる
const validateEmail = (value: string) => {
  if (value.trim() === "") return "メールアドレスを入力してください";
  if (!value.includes("@")) return "@ が含まれていません";
  return null;
};

export function Validation() {
  // このカードが描き直された回数を数えるための 1 行(教材の仕掛け)
  useTrackDemoRender();

  const [email, setEmail] = useState("");
  // 一度でも触ったかどうか。触る前からエラーを出さないために持つ
  const [touched, setTouched] = useState(false);

  // エラーは state にしない。値から計算できる
  const error = validateEmail(email);
  const showError = touched && error !== null;

  return (
    <div className="flex flex-col gap-3">
      <Input
        placeholder="メールアドレス"
        value={email}
        onChange={(event) => setEmail(event.target.value)}
        // 入力欄から離れた時点で「触った」とみなす
        onBlur={() => setTouched(true)}
        aria-invalid={showError}
      />

      {showError && <p className="text-sm text-red-600">{error}</p>}

      <Button size="sm" disabled={error !== null}>
        送信
      </Button>
    </div>
  );
}

この章のまとめ

  • 型は実行時に存在しない。入力の中身は自分で確かめる
  • チェックはコンポーネントの外に出す。ただの関数なら単体で試せる
  • エラーは値から計算する。state にしない
  • 「触ったかどうか」は計算できないので state。触ってから見せる
  • 項目が増えたら react-hook-form や zod。ただし考え方は同じ

validation.tsx

"use client";

import { useTrackDemoRender } from "@/components/lesson/demo-card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useState } from "react";

// 値を受け取って、問題があればその内容を返すだけの関数。
// React とは無関係なので、単体で読めるし試せる
const validateEmail = (value: string) => {
  if (value.trim() === "") return "メールアドレスを入力してください";
  if (!value.includes("@")) return "@ が含まれていません";
  return null;
};

export function Validation() {
  // このカードが描き直された回数を数えるための 1 行(教材の仕掛け)
  useTrackDemoRender();

  const [email, setEmail] = useState("");
  // 一度でも触ったかどうか。触る前からエラーを出さないために持つ
  const [touched, setTouched] = useState(false);

  // エラーは state にしない。値から計算できる
  const error = validateEmail(email);
  const showError = touched && error !== null;

  return (
    <div className="flex flex-col gap-3">
      <Input
        placeholder="メールアドレス"
        value={email}
        onChange={(event) => setEmail(event.target.value)}
        // 入力欄から離れた時点で「触った」とみなす
        onBlur={() => setTouched(true)}
        aria-invalid={showError}
      />

      {showError && <p className="text-sm text-red-600">{error}</p>}

      <Button size="sm" disabled={error !== null}>
        送信
      </Button>
    </div>
  );
}