State を使って入力に反応する

React は UI を操作するための宣言的な方法を提供します。UI の個々の部分を直接操作するのではなく、コンポーネントが取りうる異なる状態を記述し、ユーザの入力に応じてそれらの状態を切り替えます。これは、デザイナが UI について考える方法に似ています。

このページで学ぶこと

  • 宣言型 UI プログラミングと命令型 UI プログラミングの違い
  • コンポーネントが持つ様々な視覚状態を列挙する方法
  • 異なる視覚状態間の変更をコードからトリガする方法

宣言型 UI と命令型 UI の比較

UI の相互作用を設計する際、おそらくユーザのアクションに応じて UI がどのように変化するかを考えることが多いでしょう。たとえば、ユーザが回答を送信できるフォームを考えてみましょう。

  • フォームに何かを入力すると、「Submit」ボタンが有効になります。
  • 「Submit」ボタンを押すと、フォームとボタンが無効になり、スピナが表示されます。
  • ネットワークリクエストが成功した場合、フォームは非表示になり、「ありがとうございました」というメッセージが表示されます。
  • ネットワークリクエストに失敗した場合、エラーメッセージが表示され、フォームが再び使用可能になります。

命令型プログラミングでは、上記はそのまま UI の相互作用の実装法に対応します。今起こったことに応じて UI を操作するための正確な命令を書かなければならないのです。例えば、車の中で隣に乗っている人に、曲がるたびに行き先を指示することを想像してみてください。

In a car driven by an anxious-looking person representing JavaScript, a passenger orders the driver to execute a sequence of complicated turn by turn navigations.

Illustrated by Rachel Lee Nabors

彼らはあなたがどこに行きたいか知りません、彼らはただあなたの指示に従うだけです。(そして、もし方向が間違っていたら、あなたは間違った場所に着いてしまいます!)これは命令型と呼ばれます。なぜなら、スピナからボタンまでの各要素に対して、コンピュータに UI の更新を「指示」しなければならないからです。

この命令型 UI プログラミングの例では、フォームは React を使わずに作成されています。ブラウザの DOM を利用するだけです。

async function handleFormSubmit(e) {
  e.preventDefault();
  disable(textarea);
  disable(button);
  show(loadingMessage);
  hide(errorMessage);
  try {
    await submitForm(textarea.value);
    show(successMessage);
    hide(form);
  } catch (err) {
    show(errorMessage);
    errorMessage.textContent = err.message;
  } finally {
    hide(loadingMessage);
    enable(textarea);
    enable(button);
  }
}

function handleTextareaChange() {
  if (textarea.value.length === 0) {
    disable(button);
  } else {
    enable(button);
  }
}

function hide(el) {
  el.style.display = 'none';
}

function show(el) {
  el.style.display = '';
}

function enable(el) {
  el.disabled = false;
}

function disable(el) {
  el.disabled = true;
}

function submitForm(answer) {
  // Pretend it's hitting the network.
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (answer.toLowerCase() == 'istanbul') {
        resolve();
      } else {
        reject(new Error('Good guess but a wrong answer. Try again!'));
      }
    }, 1500);
  });
}

let form = document.getElementById('form');
let textarea = document.getElementById('textarea');
let button = document.getElementById('button');
let loadingMessage = document.getElementById('loading');
let errorMessage = document.getElementById('error');
let successMessage = document.getElementById('success');
form.onsubmit = handleFormSubmit;
textarea.oninput = handleTextareaChange;

UI を命令的に操作することは、単一の例ではうまくいくかもしれませんが、より複雑なシステムでは指数関数的に難しくなります。例えばこのような様々なフォームでいっぱいのページを更新することを想像してみてください。新しい UI 要素や新しい相互作用を追加する場合、既存のすべてのコードを注意深くチェックして、バグ(例えば、何かを表示または非表示にすることを忘れていないか)を確認する必要があります。

React はこの問題を解決するために作られました。

React では、UI を直接操作することはありません。つまり、コンポーネントの有効化、無効化、表示、非表示を直接行うことはありません。代わりに、表示したいものを宣言することで、React が UI を更新する方法を考えてくれるのです。タクシに乗ったとき、どこで曲がるかを正確に伝えるのではなく、どこに行きたいかを運転手に伝えることを思い浮かべてください。運転手はあなたをそこに連れて行くのが仕事ですし、あなたが考えもしなかった近道も知っているかもしれません!

In a car driven by React, a passenger asks to be taken to a specific place on the map. React figures out how to do that.

Illustrated by Rachel Lee Nabors

UI を宣言的に考える

上記では、フォームを命令的に実装する方法を見てきました。React で考える方法をより理解するために、以下でこの UI を React で再実装する方法を確認していきます。

  1. コンポーネントの様々な視覚状態を特定する
  2. それらの状態変更を引き起こすトリガを決定する
  3. useStateを使用してメモリ上に state を表現する
  4. 必要不可欠でない state 変数をすべて削除する
  5. イベントハンドラを接続して state を設定する

Step 1: コンポーネントの様々な視覚状態を特定する

コンピュータサイエンスでは、 「ステートマシン」 がいくつかの「状態」のうちの1つであることを耳にすることがあります。デザイナと一緒に仕事をしていると、さまざまな「視覚状態」のモックアップを見たことがあるかもしれません。React はデザインとコンピュータサイエンスの交点に位置しているため、これら両方のアイデアがインスピレーションの源になります。

まず、ユーザが目にする可能性のある UI の様々な「状態」をすべて可視化する必要があります。

  • 未入力:フォームには無効な「送信」ボタンがあります。
  • 入力中:フォームには有効な「送信」ボタンがあります。
  • 送信中:フォームは完全に無効化されます。スピナが表示されます。
  • 成功:フォームの代わりに「ありがとうございました」のメッセージが表示されます。
  • エラー:入力中の状態と同じですが、追加のエラーメッセージがあります。

デザイナのように、ロジックを追加する前に様々な状態の「モックアップ」を作成することをお勧めします。例えば、フォームの表示部分だけのモックを以下に示します。このモックはデフォルト値が 'empty'statusという props によって制御されます。

export default function Form({
  status = 'empty'
}) {
  if (status === 'success') {
    return <h1>That's right!</h1>
  }
  return (
    <>
      <h2>City quiz</h2>
      <p>
        In which city is there a billboard that turns air into drinkable water?
      </p>
      <form>
        <textarea />
        <br />
        <button>
          Submit
        </button>
      </form>
    </>
  )
}

その props の名前は何でもよくて、命名は重要ではありません。status = 'empty'status = 'success' に編集して、成功のメッセージが表示されるのを確認してみてください。モックアップを使えば、ロジックを結びつける前に、UI を素早く反復することができます。同じコンポーネントのより具体的なプロトタイプを以下に示しますが、これも status プロパティによって「制御」されています。

export default function Form({
  // Try 'submitting', 'error', 'success':
  status = 'empty'
}) {
  if (status === 'success') {
    return <h1>That's right!</h1>
  }
  return (
    <>
      <h2>City quiz</h2>
      <p>
        In which city is there a billboard that turns air into drinkable water?
      </p>
      <form>
        <textarea disabled={
          status === 'submitting'
        } />
        <br />
        <button disabled={
          status === 'empty' ||
          status === 'submitting'
        }>
          Submit
        </button>
        {status === 'error' &&
          <p className="Error">
            Good guess but a wrong answer. Try again!
          </p>
        }
      </form>
      </>
  );
}

さらに深く知る

多くの視覚状態を一度に表示する

コンポーネントが多くの視覚状態を持つ場合、それらをすべて 1 つのページに表示することが便利な場合があります。

import Form from './Form.js';

let statuses = [
  'empty',
  'typing',
  'submitting',
  'success',
  'error',
];

export default function App() {
  return (
    <>
      {statuses.map(status => (
        <section key={status}>
          <h4>Form ({status}):</h4>
          <Form status={status} />
        </section>
      ))}
    </>
  );
}

このようなページは「living styleguides」あるいは「storybooks」と呼ばれることが多いです。

Step 2: それらの状態変更を引き起こすトリガを決定する

以下の 2 種類の入力に応答して、状態の更新をトリガすることができます。

  • Human inputs(人間からの入力)、例えばボタンをクリックする、フィールドに入力する、リンクをナビゲートするなど。
  • Computer inputs(コンピュータからの入力)、例えばネットワークからのレスポンスが到着する、タイムアウトが完了する、画像が読み込まれるなど。
A finger.
Human inputs
Ones and zeroes.
Computer inputs

Illustrated by Rachel Lee Nabors

いずれの場合も、UI を更新するためには state 変数を設定する必要があります。 今回開発するフォームでは、いくつかの異なる入力に反応して状態を変更する必要があります。

  • テキスト入力の変更(人間)は、テキストボックスが空かどうかによって、未入力の状態から入力中の状態に切り替えるか、その逆にする必要があります。
  • 送信ボタンのクリック(人間)はそれを送信中の状態に切り替える必要があります。
  • 成功したネットワーク応答(コンピュータ)はそれを成功状態に切り替える必要があります。
  • ネットワーク応答の失敗(コンピュータ)は、対応するエラーメッセージと共にエラー状態に切り替える必要があります。

補足

人間からの入力は、しばしばイベントハンドラを必要とすることに注意してください!

このフローを視覚化するために、各状態をラベル付きの円として紙に描き、2 つの状態間の変化を矢印として描くことを試してみてください。このようにして多くのフローを描き出すことで、実装のはるか前にバグを整理することができます。

Flow chart moving left to right with 5 nodes. The first node labeled 'empty' has one edge labeled 'start typing' connected to a node labeled 'typing'. That node has one edge labeled 'press submit' connected to a node labeled 'submitting', which has two edges. The left edge is labeled 'network error' connecting to a node labeled 'error'. The right edge is labeled 'network success' connecting to a node labeled 'success'.
Flow chart moving left to right with 5 nodes. The first node labeled 'empty' has one edge labeled 'start typing' connected to a node labeled 'typing'. That node has one edge labeled 'press submit' connected to a node labeled 'submitting', which has two edges. The left edge is labeled 'network error' connecting to a node labeled 'error'. The right edge is labeled 'network success' connecting to a node labeled 'success'.

Form states

Step 3: useStateを使用してメモリ上に state を表現する

次に、useState. を使用してコンポーネントの視覚状態をメモリ内で表現する必要があります。シンプルさが鍵です。各 state は「動くパーツ」であり、可能な限り「動くパーツ」を少なくすることが望ましいです。複雑さが増すとバグも増えます!

まず絶対に必要な state から始めます。例えば、入力の答えを保存する必要があり、最後のエラーを保存するために(存在すれば)エラーを保存する必要があります。

const [answer, setAnswer] = useState('');
const [error, setError] = useState(null);

そして、どの視覚を表示させるかを表す state 変数が必要になります。通常、メモリ上でそれを表現する方法は 1 つではないので、実験してみる必要があります。

もし、すぐにベストな方法が思い浮かばない場合は、まず、考えられるすべての視覚状態を確実にカバーできる程度の state を追加することから始めてください。

const [isEmpty, setIsEmpty] = useState(true);
const [isTyping, setIsTyping] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [isError, setIsError] = useState(false);

最初のアイデアがベストでない可能性もありますが、それはそれで OK です。state のリファクタリングはプロセスの一部です!

Step 4: 必要不可欠でない state 変数をすべて削除する

state の内容に重複がないようにし、本当に必要なものだけを追跡するようにしたいです。state の構造をリファクタリングすることに少し時間をかけることで、コンポーネントが理解しやすくなり、重複が減り、意図しない意味を持つことがなくなります。目標は、メモリ上の state がユーザに見せたい有効な UI を表現していないケースを防ぐことです。(例えば、エラーメッセージを表示すると同時に入力を無効化するようなことはありません。そうすると、ユーザはエラーを修正することができなくなります!)

以下に、state 変数に関する質問をご紹介します。

  • この state だと矛盾は生じるのでしょうか? 例えば、 isTypingisSubmitting の両方が trueであることはありません。矛盾がある state とは通常、state の制約が十分でないことを意味します。2 つのプール値の組み合わせは 4 通りありますが、有効な state に対応するのは 3 つだけです。「不可能な」 state を削除するためには、これらを組み合わせて、typingsubmitting、または success の 3 つの値のうちの 1 つでなければならない status にすればよいです。
  • 同じ情報はすでに別の state 変数で利用可能ですか? もうひとつの矛盾: isEmptyisTyping は同時に true であることはありません。これらを別々の state 変数にすることで、同期がとれなくなり、バグが発生する危険性があります。幸い、 isEmpty を削除して、変わりに answer.length === 0 をチェックすることができます。
  • 別の state 変数の逆数から同じ情報を得ることはできますか? isError は不要です、なぜなら代わりに error !== null をチェックできるからです。

この削減後、3 つ(7 つから減りました!)の必須 state 変数が残ります。

const [answer, setAnswer] = useState('');
const [error, setError] = useState(null);
const [status, setStatus] = useState('typing'); // 'typing', 'submitting', or 'success'

機能性を壊さない限り、どれかを外すことはできないので、必要不可欠なものであることがわかります。

さらに深く知る

reducer を用いて「ありえない」 state を解消する

この 3 つの変数は、このフォームの状態を十分に表現しています。しかし、まだ完全に意味をなさない中間状態もあります。例えば、ステータスが success のとき、error が null でない state は意味をなしません。state をより正確にモデル化するには、reducerに抽出することができます。reducer を使えば、複数の state 変数を 1 つのオブジェクトに統一し、関連するロジックをすべて統合することができます!

Step 5: イベントハンドラを接続して state を設定する

最後に、state を更新するイベントハンドラを作成します。以下に、すべてのイベントハンドラが接続された最終的なフォームを示します。

import { useState } from 'react';

export default function Form() {
  const [answer, setAnswer] = useState('');
  const [error, setError] = useState(null);
  const [status, setStatus] = useState('typing');

  if (status === 'success') {
    return <h1>That's right!</h1>
  }

  async function handleSubmit(e) {
    e.preventDefault();
    setStatus('submitting');
    try {
      await submitForm(answer);
      setStatus('success');
    } catch (err) {
      setStatus('typing');
      setError(err);
    }
  }

  function handleTextareaChange(e) {
    setAnswer(e.target.value);
  }

  return (
    <>
      <h2>City quiz</h2>
      <p>
        In which city is there a billboard that turns air into drinkable water?
      </p>
      <form onSubmit={handleSubmit}>
        <textarea
          value={answer}
          onChange={handleTextareaChange}
          disabled={status === 'submitting'}
        />
        <br />
        <button disabled={
          answer.length === 0 ||
          status === 'submitting'
        }>
          Submit
        </button>
        {error !== null &&
          <p className="Error">
            {error.message}
          </p>
        }
      </form>
    </>
  );
}

function submitForm(answer) {
  // Pretend it's hitting the network.
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      let shouldError = answer.toLowerCase() !== 'lima'
      if (shouldError) {
        reject(new Error('Good guess but a wrong answer. Try again!'));
      } else {
        resolve();
      }
    }, 1500);
  });
}

このコードは、元の命令型の例よりも長くなっていますが、はるかに壊れにくくなっています。すべてのインタラクションを state 変化として表現することで、既存の state を壊すことなく、後から新しい視覚状態を導入することができます。また、インタラクション自体のロジックを変更することなく、各 state で表示されるべきものを変更することができます。

まとめ

  • 宣言型プログラミングとは、UI を細かく管理する(命令型)のではなく、視覚状態ごとに UI を記述することを意味します。
  • コンポーネントを開発するとき:
    1. コンポーネントの視覚状態をすべて特定する。
    2. 状態を変更するための人間およびコンピュータのトリガを決定する。
    3. useState で state をモデル化する。
    4. バグや矛盾を避けるため、不必要な state を削除する。
    5. state を設定するためのイベントハンドラを接続する。

チャレンジ 1/3:
CSS クラスの追加・削除

画像をクリックすると、外側の <div> から background--active CSS クラスが削除され、<img>picture--active クラスが追加されるようにしてください。もう一度背景をクリックすると、元の CSS クラスに戻るようにします。

視覚的には、画像の上をクリックすると、紫色の背景が消え、画像の境界線が強調されると考えてください。画像の外側をクリックすると、背景が強調されますが、画像の境界線の強調は削除されます。

export default function Picture() {
  return (
    <div className="background background--active">
      <img
        className="picture"
        alt="Rainbow houses in Kampung Pelangi, Indonesia"
        src="https://i.imgur.com/5qwVYb1.jpeg"
      />
    </div>
  );
}