本文へスキップ
入力チェック

Part 5 · フォーム

33 / 64

入力チェック

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

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="メールアドレス"
        aria-label="メールアドレス"
        value={email}
        onChange={(event) => setEmail(event.target.value)}
        // 入力欄から離れた時点で「触った」とみなす
        onBlur={() => setTouched(true)}
        aria-invalid={showError}
        aria-describedby={showError ? "email-error" : undefined}
      />

      {showError && (
        <p id="email-error" role="alert" className="text-sm text-destructive">
          {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 の「state は最小限にする」 でやった「計算できるものは state にしない」がそのまま当てはまります。

state にしてしまうと、入力のたびにsetError を呼ぶ必要が出てきます。 呼び忘れると、こうなります。

エラーを state で持った版うまくいかない例

空のまま「確認する」→ 正しく直す → エラーはどうなる?

表示されているエラー
(なし)

手順は 3 つです。 何も入れずに「確認する」を押し、エラーを出す。 次に taro@example.com のように正しく入力する。 それだけです。

エラーが出たままです。入力はもう正しいのに、表示だけが古い。error という state が、入力とは別に、勝手に古い値を持ち続けているからです。

このあとのデモ(計算で出している版)では、 同じ操作をしてもエラーはすぐ消えます。表示するものが、常に今の入力から計算されているためです。

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="メールアドレス"
        aria-label="メールアドレス"
        value={email}
        onChange={(event) => setEmail(event.target.value)}
        // 入力欄から離れた時点で「触った」とみなす
        onBlur={() => setTouched(true)}
        aria-invalid={showError}
        aria-describedby={showError ? "email-error" : undefined}
      />

      {showError && (
        <p id="email-error" role="alert" className="text-sm text-destructive">
          {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="メールアドレス"
        aria-label="メールアドレス"
        value={email}
        onChange={(event) => setEmail(event.target.value)}
        // 入力欄から離れた時点で「触った」とみなす
        onBlur={() => setTouched(true)}
        aria-invalid={showError}
        aria-describedby={showError ? "email-error" : undefined}
      />

      {showError && (
        <p id="email-error" role="alert" className="text-sm text-destructive">
          {error}
        </p>
      )}

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

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

項目が 3 つ 4 つと増えてくると、 この書き方はつらくなります。条件と表示が離れていくからです。

そのための道具がReact Hook Formzod です。Part 10 で実際に使います。ここでは名前だけ覚えておいてください。

先に自分で書いておくと、道具が何を肩代わりしてくれているのかが分かります。 この章はそのための下地です。

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="メールアドレス"
        aria-label="メールアドレス"
        value={email}
        onChange={(event) => setEmail(event.target.value)}
        // 入力欄から離れた時点で「触った」とみなす
        onBlur={() => setTouched(true)}
        aria-invalid={showError}
        aria-describedby={showError ? "email-error" : undefined}
      />

      {showError && (
        <p id="email-error" role="alert" className="text-sm text-destructive">
          {error}
        </p>
      )}

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

本物のフォームは form で囲む

この Part のデモは、話を絞るために<input><button> だけで作っています。 実際のフォームでは <form> で囲みます。

<form onSubmit={handleSubmit}>
  <input ... />
  <button type="submit">送信</button>
</form>
  • Enter キーでも送信できるようになります(囲まないと効きません)
  • 送信の処理は onSubmit に書きます。 ボタンの onClick ではありません

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="メールアドレス"
        aria-label="メールアドレス"
        value={email}
        onChange={(event) => setEmail(event.target.value)}
        // 入力欄から離れた時点で「触った」とみなす
        onBlur={() => setTouched(true)}
        aria-invalid={showError}
        aria-describedby={showError ? "email-error" : undefined}
      />

      {showError && (
        <p id="email-error" role="alert" className="text-sm text-destructive">
          {error}
        </p>
      )}

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

理解できたか確かめる

確認クイズ

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

確認クイズ

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

確認クイズ

入力欄で Enter を押しても送信されるようにするには?

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="メールアドレス"
        aria-label="メールアドレス"
        value={email}
        onChange={(event) => setEmail(event.target.value)}
        // 入力欄から離れた時点で「触った」とみなす
        onBlur={() => setTouched(true)}
        aria-invalid={showError}
        aria-describedby={showError ? "email-error" : undefined}
      />

      {showError && (
        <p id="email-error" role="alert" className="text-sm text-destructive">
          {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="メールアドレス"
        aria-label="メールアドレス"
        value={email}
        onChange={(event) => setEmail(event.target.value)}
        // 入力欄から離れた時点で「触った」とみなす
        onBlur={() => setTouched(true)}
        aria-invalid={showError}
        aria-describedby={showError ? "email-error" : undefined}
      />

      {showError && (
        <p id="email-error" role="alert" className="text-sm text-destructive">
          {error}
        </p>
      )}

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