React公式サイトのドキュメントが2023年3月16日に改訂されました(「Introducing react.dev」参照)。本稿は、基本解説の「Reacting to Input with State」をかいつまんでまとめた記事です。ただし、コードにはTypeScriptを加えました。反面、初心者向けのJavaScriptの基礎的な説明は省いています。
なお、本シリーズ解説の他の記事については「React + TypeScript: React公式ドキュメントの基本解説『Learn React』を学ぶ」をご参照ください。
Reactが用いるUIの操作方法は宣言的です。それぞれのUI部分は直接いじりません。コンポーネントが取り得るさまざまな状態を記述し、ユーザー入力に応じて切り替えるのです。デザイナーがUIを考えるのと似ているかもしれません。
UIは宣言型と命令型でどう異なるか
UIのインタラクションを設計するとき、UIがユーザー操作に応じてどう変わるか考えるでしょう。たとえば、ユーザーが回答を送信するフォームです。
- フォームへの入力: [送信]ボタンが有効になります。
- [送信]ボタンのクリック: フォームとボタンは無効になり、表示されるのはスピナーです。
- ネットワークリクエスト
- 成功: フォームは非表示になり、「ありがとうございました」というメッセージが表示されます。
- 失敗: 表示されるのはエラーメッセージです。フォームは再び有効になりまます。
上記のインタラクションをそのまま実装するのが命令型プログラミングです。何が起こったら、UIをどう操作するか、指示は正確に記述しなければなりません。たとえば、車の助手席で行き先までの道順を指示する場合で考えてみましょう。
運転手はこれから行こうとしている先は知りません。ただ、指示にしたがうだけです(指示を間違えたら目的地につきません)。命令型と呼ばれるのは、各要素に「命令」を下すことにもとづきます。スピナーからボタンまで、コンピュータにUIをどう更新するのか伝えなければなりません。
UIのプログラミングを標準JavaScriptだけで書こうとすると、通常は宣言型になります。DOMでひとつひとつの要素を参照して操作することになるからです。以下のサンプル001のようなシンプルなアプリケーションであれば問題ありません(なお、非同期通信は実際にはしておらず、クイズの答えはコードに書き込まれています)。
const form = document.getElementById('form') as HTMLFormElement;
const textarea = document.getElementById('textarea') as HTMLTextAreaElement;
const button = document.getElementById('button') as HTMLButtonElement;
const loadingMessage = document.getElementById('loading');
const errorMessage = document.getElementById('error');
const successMessage = document.getElementById('success');
サンプル001■React + TypeScript: Reacting to Input with State 01
けれど、システムが複雑になるにつれ、指数関数的に難しくなるのがコードの管理です。このようなフォームがほかにも一杯に詰まったページを更新するとしましょう。新しいUI要素やインタラクションを加える必要が生じました。このとき、既存のコードすべてを注意深く確かめて、バグが起こらないようにしなければなりません(何かの表示・非表示を忘れるなど)。
この問題を解決するために開発されたのがReactです。
Reactでは、UIは直接操作しません。コンポーネントを有効化・無効化、表示・非表示するといった操作は直に加えないのです。ただ表示したいものを宣言します。すると、ReactがUIをどう更新すればよいか考えてくれるのです。車の例でいうなら、タクシーでは行き先を正しく伝えれば済みます。細かく道順を指示する必要はありません。運転はタクシードライバーの仕事だからです。場合によっては、あなたの知らない近道を知っているかもしれません。
UIを宣言型で考える
冒頭に示したフォームのインタラクション設計において、Reactで実装するUIの考え方はつぎのとおりです。
- コンポーネントが示し得る視覚的な状態を特定します。
- 状態は何が起こったら変わるのかを指定します。
-
useState
により状態をメモリ上に表現します。 - 重要でない状態変数は削除します。
- イベントハンドラを状態に接続します。
ステップ1: コンポーネントが示し得る視覚的な状態を特定
コンピュータサイエンスで用いられる「ステートマシン」という考え方を聞いたことがあるかもしれません。変化を制御する条件に応じて、予め決められた状態のひとつに遷移するシステムの表現です(「ステートマシン」参照)。デザイナーと仕事をしていると、さまざまな「視覚的状態」のモックアップを見ることもあるでしょう。Reactはデザインとコンピュータサイエンスの狭間にあるため、双方の発想から示唆を受けます。
まず、ユーザーが目にし得るUIのさまざまな「状態」を、すべて視覚化しなければなりまません。
- 空欄: フォームの[送信]ボタンは無効です。
- 入力中: フォームの[送信]ボタンが有効になります。
- 送信中: フォームは完全に無効です。スピナーが表示されます。
- 成功: フォームに代わって表示されるのは「ありがとうございます」のメッセージです。
- 失敗: 入力中の状態と変わりません。ただし、エラーメッセージが追加で表示されます。
デザイナーと同じように、ロジックがまだ加えられる前に、それぞれの状態の「モック」をつくることになるでしょう。以下のコードは、フォームの視覚的な部分だけのモックです。Form
コンポーネントが引数に受け取るプロパティ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
の値は、コンポーネントForm
が返すJSXの条件分岐に用いられています。デフォルト値を'success'
に書き替えれば、表示されるのは成功のメッセージです。
status
には、状態を示すつぎのような値が想定されています。
-
empty
: 空欄 -
submitting
: 送信中 -
success
: 成功 -
error
: 失敗
これらの値に対応するように、モックにもう少し手を加えましょう。戻り値のJSXにstatus
に応じたロジックを組み込んだのがつぎのコードです。以下のサンプル002でstatus
のデフォルト値を書き替えて、表示がどう変わるかお確かめください。
export default function Form({ status = 'empty' }) {
return (
<>
<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>
</>
);
}
サンプル002■React + TypeScript: Reacting to Input with State 02
ステップ2: 状態は何が起こったら変わるのかを指定
状態の更新が起こるのは、つぎのふたつの入力への反応です。
- ユーザーのインタラクション: ボタンのクリックやフィールドへの入力、リンクの遷移など。
- コンピュータの処理: ネットワークのレスポンス受信や処理のタイムアウトによる終了、画像のロードなど。
いずれの場合も、状態変数を設定してUIは更新しなければなりません。お題のフォームでは、対応した状態の変更が求められるのは、つぎの入力です。
- テキスト入力の変更(ユーザー): テキストフィールドの中身により、「空欄」か「入力中」かが切り替わります。
- [送信]ボタンのクリック(ユーザー): 「送信中」の状態に切り替えなければなりません。
- ネットワークレスポンスの成功(コンピュータ): 切り替えるべき状態は「成功」です。
- ネットワークレスポンスの失敗(コンピュータ): 「エラー」状態に切り替え、エラーメッセージが表示されます。
ステートマシンの状態図のように、「状態」と「遷移」を図で表すとわかりやすいでしょう。
ステップ3: useState
により状態をメモリ上に表現
コンポーネントの視覚的な状態はフックuseState
でメモリに表現します。状態はシンプルに保つことが大切です。状態は変化します。変化するものはできるかぎり少なく抑えるべきでしょう。複雑さが増すほどバグが生じやすいからです。
状態は、必須の値から定めます。フォームの場合は、回答を収めるanswer
や直近のエラー(もし存在すれば)の値error
です。
const [answer, setAnswer] = useState("");
const [error, setError] = useState(null);
そして、視覚的状態を表す変数が加わります。状態変数の決め方はひとつではありません。試してみることも必要です。あり得る視覚的状態を書き出すことから始めてもよいでしょう。無駄な変数があっても構いません。このあと最適化します。
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);
ステップ4: 重要でない状態変数は削除
中身が重複する状態変数は除いてゆきましょう。重要な変数に絞って確かめます。状態構造の最適化に少し時間を費やすだけで、コンポーネントはわかりやすく、重複も減り、コードに意図しない意味が加わることは避けられるのです。
前述のフォームに定めた状態変数については、つぎのような改善点が考えられます。
-
状態に矛盾は生じませんか。
isTyping
とisSubmitting
は同時にtrue
にはなりません。この矛盾は、状態の設定が十分に絞られていないことによるものです。ふたつのブール値の組み合わせは4とおりあります。けれど、有効な状態はそのうち3つだけです。「あり得ない」状態を除くには、これらはひとつに組み合わせてしまえばよいでしょう。そして、値を'typing'
、'submitting'
または'success'
の3つのいずれかにするのです。 -
同じ情報が他の状態変数からわかりませんか。やはり矛盾点として、
isEmpty
とisTyping
は同時に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'または'success'
イベントハンドラを状態に接続
最後に状態を更新するイベントハンドラに接続します。状態とロジックの記述はつぎの抜書きのとおりです。コード全体と動きについては、以下のサンプル003をご参照ください。
const submitForm = (answer: string) => {
// ネットワーク接続のシミュレーション
};
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>;
}
const handleSubmit: FormEventHandler = async (event) => {
event.preventDefault();
setStatus("submitting");
try {
await submitForm(answer);
setStatus("success");
} catch (error) {
setStatus("typing");
setError(error as any);
}
};
const handleTextareaChange: ChangeEventHandler<HTMLTextAreaElement> = ({
target: { value }
}) => {
setAnswer(value);
};
return (
<>
<form onSubmit={handleSubmit}>
<textarea
disabled={status === "submitting"}
onChange={handleTextareaChange}
value={answer}
/>
<br />
<button disabled={status === "empty" || status === "submitting"}>
Submit
</button>
{error !== null && (
<p className="Error">Good guess but a wrong answer. Try again!</p>
)}
</form>
</>
);
}
サンプル003■React + TypeScript: Reacting to Input with State 03
はじめにご紹介した標準JavaScriptのサンプル001より、むしろコードの行数は増えてしまいました。けれど、堅牢さは増しています。すべてのインタラクションを状態の変化で表現したので、今ある状態は壊すことなく、新しい視覚的な状態が加えられるからです。また、各状態の表示を変更しても、インタラクションのロジックは変更する必要がありません。
まとめ
この記事では、つぎのような項目についてご説明しました。
- 宣言型プログラミングとは、視覚的な状態ごとにUIを記述することです。UIを細かく管理する命令型とは異なります。
- コンポーネントを開発する手順はつぎのとおりです。
- 視覚的な状態を特定します。
- ユーザーとコンピュータによる状態変更を指定しなければなりません。
-
useState
による状態のモデル化です。 - 重要でない状態は削除して、バグや矛盾を避けましょう。
- イベントハンドラに状態を接続して設定します。