状態を使った入力への反応

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

学ぶ内容

  • 宣言型UIプログラミングが命令型UIプログラミングとどのように異なるか
  • コンポーネントが存在できるさまざまな視覚的な状態を列挙する方法
  • コードからさまざまな視覚的な状態間の変更をトリガーする方法

宣言型UIが命令型とどのように比較されるか

UIのインタラクションを設計する場合、ユーザーの操作に応じてUIがどのように変化するかを考えることが多いでしょう。ユーザーが回答を送信できるフォームを考えてみましょう。

  • フォームに何かを入力すると、「送信」ボタンが有効になります。
  • 「送信」を押すと、フォームとボタンの両方が無効になり、スピナーが表示されます。
  • ネットワークリクエストが成功すると、フォームが非表示になり、「ありがとうございます」メッセージが表示されます。
  • ネットワークリクエストが失敗すると、エラーメッセージが表示され、フォームが再び有効になります。

命令型プログラミングでは、上記はインタラクションの実装方法に直接対応します。何が起こったかに応じてUIを操作するための正確な指示を記述する必要があります。これを別の方法で考えてみましょう。車で誰かの隣に乗って、どこに行くかを順番に指示しているのを想像してみてください。

彼らはあなたがどこに行きたいかを知りません。彼らは単にあなたの命令に従います。(そして、あなたが指示を間違えると、間違った場所にたどり着きます!) これは、スピナーからボタンまで、コンピュータに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の更新方法を判断します。タクシーに乗り込み、どこに行くかを正確に指示するのではなく、運転手に目的地を伝えることを考えてみてください。そこへ連れて行くのは運転手の仕事であり、彼らはあなたが考えていない近道を知っているかもしれません!

宣言的にUIについて考える

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

  1. 特定する コンポーネントのさまざまな視覚的な状態
  2. 決定する これらの状態の変化を引き起こすもの
  3. 表現する useStateを使用してメモリ内の状態
  4. 削除する 不要な状態変数
  5. 接続する 状態を設定するイベントハンドラ

ステップ1:コンポーネントの様々なビジュアル状態を特定する

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

まず、ユーザーが目にできるUIの異なるすべての「状態」を視覚化する必要があります。

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

デザイナーと同様に、ロジックを追加する前に、異なる状態の「モックアップ」または「モック」を作成することをお勧めします。たとえば、フォームのビジュアル部分だけのモックを以下に示します。このモックは、statusというプロパティで制御され、デフォルト値は'empty'です。

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>
    </>
  )
}

そのプロパティは何でも好きな名前を付けることができます。名前付けは重要ではありません。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>
      ))}
    </>
  );
}

このようなページは、しばしば「リビングスタイルガイド」または「ストーリーブック」と呼ばれます。

ステップ2:これらの状態変化のトリガーを特定する

2種類の入力に応じて状態の更新をトリガーできます。

  • 人間の入力(ボタンのクリック、フィールドへの入力、リンクへの移動など)。
  • コンピューターの入力(ネットワーク応答の到着、タイムアウトの完了、画像の読み込みなど)。
A finger.
人間の入力
Ones and zeroes.
コンピューターの入力

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

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

この流れを視覚化するために、各状態をラベル付きの円として紙に描き、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'.

フォームの状態

ステップ3:useStateを使用してメモリに状態を表す

次に、useStateを使用して、コンポーネントのビジュアル状態をメモリに表す必要があります。シンプルさが重要です。各状態は「可動部分」であり、できるだけ少ない「可動部分」にする必要があります。 複雑さが増すと、バグも増えます!

絶対に必要となる状態から始めましょう。たとえば、入力のanswerと、(存在する場合)最後のエラーを格納するためのerrorを格納する必要があります。

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

次に、表示するビジュアル状態のいずれかを示す状態変数が必要です。メモリにそれを表す方法は通常1つ以上あるため、それについて実験する必要があります。

最適な方法がすぐに思いつかない場合は、可能なすべてのビジュアル状態が確実に網羅されるのに十分な状態を追加することから始めましょう。

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);

最初のアイデアが最善とは限りませんが、それは問題ありません。状態のrefactoringはプロセスの1部分です!

ステップ4:不要な状態変数を削除する

状態の内容の重複を避け、本質的なものだけを追跡する必要があります。状態構造のrefactoringに少し時間をかけることで、コンポーネントの理解が容易になり、重複が減り、意図しない意味合いが避けられます。目標は、メモリ内の状態が、ユーザーに見せたい有効なUIを表していないケースを防ぐことです。(たとえば、エラーメッセージを表示し、同時に入力を無効にすることは決してありません。そうしないと、ユーザーはエラーを修正できません!)

状態変数について尋ねることができる質問をいくつか示します。

  • この状態はパラドックスを引き起こしますか? 例えば、isTypingisSubmitting は両方とも true になることはできません。パラドックスは通常、状態が十分に制約されていないことを意味します。2つのブール値には4つの可能な組み合わせがありますが、有効な状態に対応するのは3つだけです。「不可能な」状態を削除するには、これらを status に組み合わせ、3つの値のいずれかになります。'typing''submitting'、または 'success'
  • 同じ情報が別の状態変数ですでに利用可能ですか? 別のパラドックス:isEmptyisTyping は同時に true になることはできません。これらを別々の状態変数にすることで、同期がずれてバグが発生するリスクがあります。幸いなことに、isEmpty を削除し、代わりに answer.length === 0 を確認できます。
  • 別の状態変数の逆から同じ情報を得ることができますか? isError は必要ありません。代わりに error !== null を確認できます。

この整理の後、7つから3つに減った必須の状態変数が残ります。

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

それらが必須であることは、機能を壊すことなくどれか1つでも削除できないことから分かります。

詳細

リデューサを使用した「不可能な」状態の排除

これらの3つの変数は、このフォームの状態を十分に表現しています。しかし、完全に意味をなさない中間状態がいくつか残っています。例えば、status'success' の場合、NULL 以外の error は意味がありません。状態をより正確にモデル化するには、リデューサに抽出することができます。リデューサを使用すると、複数の状態変数を単一のオブジェクトに統合し、関連するすべてのロジックを統合できます!

ステップ5:イベントハンドラを接続して状態を設定する

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

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);
  });
}

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

要約

  • 宣言型プログラミングとは、UIを細部まで管理する(命令型)のではなく、各視覚状態のUIを記述することを意味します。
  • コンポーネントを開発する際
    1. すべての視覚状態を特定します。
    2. 状態変化の人間とコンピュータのトリガーを決定します。
    3. useState を使用して状態をモデル化します。
    4. バグやパラドックスを避けるために、非必須の状態を削除します。
    5. イベントハンドラを接続して状態を設定します。

課題 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>
  );
}