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: コンポーネントの様々な視覚状態を特定する

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

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

  • Empty:フォームには無効な “Submit” ボタンがある。
  • Typing:フォームには有効な “Submit” ボタンがある。
  • Submitting:フォームは完全に無効化される。スピナが表示される。
  • Success:フォームの代わりにお礼のメッセージが表示される。
  • Error:Typing 状態と同様だがエラーメッセージも表示される。

デザイナのように、ロジックを追加する前に様々な状態の「モックアップ」を作成することをお勧めします。例えば、フォームの表示部分だけのモックを以下に示します。このモックはデフォルト値が '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 styleguide” あるいは “storybook” と呼ばれます。

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

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

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

Illustrated by Rachel Lee Nabors

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

  • テキスト入力フィールドの編集(人間)により、テキストボックスが空かどうかによって、Empty 状態と Typing 状態を切り替える。
  • 送信ボタンのクリック(人間)により、Submitting 状態に切り替える。
  • ネットワーク応答の成功(コンピュータ)により、Success 状態に切り替える。
  • ネットワーク応答の失敗(コンピュータ)により、対応するエラーメッセージと共に Error 状態に切り替える。

補足

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

このフローを視覚化するために、各状態を丸で囲んで紙に描き、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 から始めます。例えば、入力中の回答である answer を保存する必要があり、最後に起きたエラー(あれば)を保存するために error が必要です。

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 通りありますが、有効な状態に対応するのは 3 つだけです。このような「ありえない」state を削除するためには、これらをまとめて、typingsubmitting、または success の 3 つの値のうちどれかでなければならない status という 1 つの state にすればよいでしょう。
  • 同じ情報が別の 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'

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

さらに深く知る

リデューサを用いて「ありえない」state を解消する

この 3 つの変数は、このフォームの状態を十分に表現しています。しかし、あまり意味をなさない中途半端な状態がまだ存在します。例えば、statussuccess の場合、error が null 以外になることは意味をなしません。state をより正確にモデル化するには、リデューサに抽出することができます。リデューサを使えば、複数の 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>
  );
}