メインコンテンツまでスキップ

React

宣言的なUI

これまで、JavaScriptによりHTML要素を操作するために、DOMを用いることができることを学んできました。しかしながら、ナイーブな方法によりDOMを使用すると、アプリケーションの規模の限界がすぐにやってきます。

簡単なToDoアプリケーションを例に考えてみましょう。

index.html
<ul id="todos"></ul>
<input id="message" />
<button id="add-todo" type="button">追加</button>
script.js
const todoContainer = document.getElementById("todos");
const messageInput = document.getElementById("message");
const addTodoButton = document.getElementById("add-todo");

addTodoButton.onclick = () => {
const message = messageInput.value;
const li = document.createElement("li");
const span = document.createElement("span");
span.textContent = message;
li.appendChild(span);
const removeTodoButton = document.createElement("button");
removeTodoButton.type = "button";
removeTodoButton.textContent = "削除";
removeTodoButton.onclick = () => {
todoContainer.removeChild(li);
};
li.appendChild(removeTodoButton);
todoContainer.appendChild(li);
};

なんとか作り上げることができましたが、このまま要件を増やして複雑なプログラムを作ろうとすれば、要素の作成忘れ、削除忘れなどにより、すぐに破綻してしまいそうです。

このようになってしまう根本的な原因は、現在の状態がDOMに記憶されてしまっていることにあります。DOMには、テキストや位置、色など、数えきれない状態が格納されています。状態の数がnn種類あれば、その遷移パターンはn2n^2個になるわけですので、状態の数が増えることが非常に危険であることは明らかです。

ところが、アプリケーションの本質的な状態というのは、一般的にそこまで多いものではありません。例えば、ToDoリストアプリケーションであれば、各ToDoを表すstringの配列string[]がひとつだけあれば、アプリケーションの状態は全て表現できていることになるはずです。

宣言的UIは、こういった性質に着目します。より具体的に説明するのであれば、アプリケーションの状態SSに対し、関数f(S)f(S)によりUIの状態を表現できるのであれば、開発者の関心をSSの変化とffの定義のみに絞ることができるというわけです。

具体的なコードで確認してみましょう。先ほどのToDoアプリケーションを、宣言的UIのアプローチを用いて書き換えてみましょう。状態を追いやすいよう、TypeScriptを用いて記述します。

まずはアプリケーションの状態と、その状態を格納する変数を宣言します。

type State = { todos: string[] };
const state: State = { todos: [] };

続いて、state変数をもとにUIを構築する関数renderを定義します。

function render() {
// いったん既存の要素を全て削除
todoContainer.innerHTML = "";

for (const todo of state.todos) {
const li = document.createElement("li");
// ここにliの中身を作る処理が入る
todoContainer.appendChild(li);
}
}

最後に、状態を変化させる関数を定義します。状態を変化させたら、render関数を呼び出して、変更をUIに反映させます。

function addTodo(todo: string) {
state.todos.push(todo);
render();
}
function removeTodo(index: number) {
state.todos.splice(index, 1);
render();
}

これにより、アプリケーション全体の状態が変数stateに集約され、開発者が意識すべき状態のパターンを大幅に減らすことに成功しました。

React

先ほどのプログラムはうまく動作しますが、一つ問題があります。それは、render関数が呼ばれるたびに全ての要素が削除され、再構築される点です。一般的に、DOMに対する操作は非常にコストが高く、可能な限り減らすことがパフォーマンス改善のために有効です。

Reactは、この問題を仮想DOMを用いて解決します。Reactは、DOMに似たデータ構造を内部的にJavaScriptオブジェクトの形式で保持し、実際に変更された部分のみを実際のDOMに反映させることで、高いパフォーマンスを実現しています。

それでは、Reactを用いたプロジェクトを作成してみましょう。Viteでプロジェクトを作成しますが、frameworkReactvariantTypeScriptを選択してください。

Reactの使用に最低限必要なパッケージ

Reactを新規プロジェクトではなく、既存のウェブプロジェクトで用いる場合には、reactパッケージと、react-domパッケージが必要です。

また、React本体はTypeScriptに対応していないので、TypeScriptプロジェクトでReactを用いるためには@typesパッケージを加えてインストールする必要があります。

package.json (抜粋)
{
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@types/react": "^18.0.28",
"@types/react-dom": "^18.0.11"
}
}

JSX

Reactを使用するプロジェクトでは、通常JSXと呼ばれる、JavaScriptの拡張構文も用いられます。拡張子は.jsxで、TypeScriptとともに用いるためには.tsxとなります。Viteのテンプレートからプロジェクトを作成した場合には、main.tsxApp.tsxが作成されるはずです。

main.tsxはHTMLから直接実行されるファイルで、id属性にrootを持つ要素の中をReactにより管理する旨を示しています。また、このファイルからApp.tsxで定義された関数Appが読み込まれています。詳細は重要ではないのでここでは扱いません。

main.tsx
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
Non-null assertion operator

document.getElementById("root")の直後に続く!記号は、TypeScriptのnon-null assertion operatorです。document.getElementById関数は、要素が見つからなかった場合にnullを返すため、戻り値はHTMLElement | null型と定義されています。nullである可能性がないことをプログラマが保証することをTypeScriptに伝える記号が!です。なお、tsconfig.jsonの設定によってはこのエラーは表示されません。

document.getElementById("root").textContent; // Object is possibly 'null'.
document.getElementById("root")!.textContent; // OK

それでは、App.tsxを書き換えながら、Reactの動作を確認していきましょう。まずは、App.tsxを次のように修正します。

App.tsx
export default function App() {
return <div>Hello React</div>;
}

このプログラムを実行すると、div要素が生成され、その中にHello Reactが表示されます。3行目の<div>Hello React</div>が見慣れない文法ですね。

JSXでは、<div>のように、HTMLの開始タグに似た記号が現れると、対応する終了タグまで囲まれた部分を、「JSX要素」を生成する式と解釈するようになります。この部分のことを以後便宜的にJSX式と呼ぶことにします。

JSX式は、JSX要素 (JSX.Element型の値) を生成します。この値はごく一般的なオブジェクトで、変数に代入するなど、他の値と同じように扱うことが可能です。

App.tsx
const message: JSX.Element = <div>Hello React</div>;

export default function App() {
return message;
}
JSX式のトランスパイル結果

JSX式は、Viteなどのトランスパイラによりトランスパイルされると、関数呼び出しになります。例えば、

const message: JSX.Element = <div>Hello React</div>;

は、次のようにトランスパイルされます。

const message = React.createElement("div", null, "Hello React");

Reactは、App関数の戻り値としてJSX.Elementが返されると、それをもとに実際のDOMを構築します。この例では、div要素を作成し、その中にHello Reactというテキストを挿入します。つまり、このJSX.Elementが、先ほどの仮想DOMなるものの実体です。

JSX式の中に括弧{}が現れると、その内部は通常のJavaScript式として評価されるようになります。これを利用して、HTML構造の中にJavaScriptによる計算結果を埋め込むことができます。

App.tsx
export default function App() {
return <div>1 + 1 = {1 + 1}</div>;
}

属性の値部分にも{}が使用できます。

App.tsx
export default function App() {
return <input placeholder={new Date().toString()} />;
}

JSX式とJavaScriptの間を行き来することもできます。

App.tsx
const age = 22;

export default function App() {
return (
<p>
{age >= 20 ? (
<span>いらっしゃいませ!</span>
) : (
<strong>お酒は20歳になってから!</strong>
)}
</p>
);
}
条件演算子 (三項演算子)

?:の組で表される演算子は、**条件演算子 (三項演算子)**です。条件式の評価結果が真なら2つめの式を、偽なら3つめの式を評価します。

const a = 5;
const b = 6;
const max = a > b ? a : b; // 6

JSXとJavaScriptの入れ子構造

課題

自分のテストの点数を表す変数scoreを用意し、Reactで次を満たすプログラムを作成してください。

  • scoreが80以上なら大変よくできました。と表示する。
  • scoreが50以上80未満ならよくできました。と表示する。
  • scoreが50未満ならもう少し頑張りましょう。と表示する。
解答例

解答例1

条件演算子をネストして、条件分岐を表現します。

App.tsx
const score: number = 80;

export default function App() {
return (
<>
{score >= 80 ? (
<div>大変よくできました。</div>
) : score >= 50 ? (
<div>よくできました。</div>
) : (
<div>もう少し頑張りましょう。</div>
)}
</>
);
}

解答例2

次のように変数にJSX要素を代入しておき、最後にその変数を返すという方法もあります。

App.tsx
const score: number = 80;

export default function App() {
let message: JSX.Element;
if (score >= 80) {
message = <div>大変よくできました。</div>;
} else if (score >= 50) {
message = <div>よくできました。</div>;
} else {
message = <div>もう少し頑張りましょう。</div>;
}
return message;
}

JSXにおける条件分岐

JSX要素は式の形で表現されるため、内部でif文やfor文といった制御構造は用いることができません。

前項で扱ったように、if〜else構造を式として表現するためには、条件演算子が使用できます。一方、else ifを含まない単純なifに相当する構造をJSX式として表現するためには、通常&&演算子が用いられます。例を見てみましょう。

App.tsx
const age = 20;

export default function App() {
return (
<form>
<input placeholder="お名前" />
<button>送信</button>
{age < 18 && <p>18歳未満の場合は保護者の同意が必要です。</p>}
</form>
);
}
JSXと閉じタグ

JSXでは、HTMLにおいて閉じタグが必須でない要素 (この例ではinput要素) でも閉じタグが必須となります。

このプログラムは、age変数が18以上である場合のみメッセージを表示します。これは、&&演算子の挙動を利用した手法です。これまで、&&演算子は両辺がtrueであればtrueを返す演算子であるとしてきました。しかしながら、&&演算子のより一般的な定義は、左辺がtruthyであれば右辺の値を、そうでなければ左辺の値を返す演算子です。

const a = 3 && 4; // 3はtruthyなのでaは4
const b = null && "Hello"; // nullはfalsyなのでbはnull

つまり、age < 18 && <p>18歳未満の...</p>という式は、ageが18未満のとき<p>18歳未満の...</p> (JSX.Element) に、そうでないときにfalseになります。

さらに、Reactは、JSX中に現れたfalsenullundefinedといった値は無視します。これにより、ifに似た構造が表現できるわけです。

truthyとfalsy

JavaScriptでは、if文やwhile文などの制御構造も、条件式の結果がtruthyであるかを確認しています。

if ("") {
// 空文字列はfalsyなのでこの部分は実行されない
}

Boolean関数は、truthyな値をtrueに、falsyな値をfalseに変換します。

Boolean(null); // false
Boolean("Hello"); // true

JSXにおける繰り返し

Reactでは、JSXの子要素として配列を指定することができます。ただし、配列の要素がJSX.Element型である場合、各要素のkey属性に重複しない値を指定する必要があります

App.tsx
const listItems = [
<li key="1">要素1</li>,
<li key="2">要素2</li>,
<li key="3">要素3</li>,
];

export default function App() {
return <ul>{listItems}</ul>;
}

この性質から、ReactにおいてArray#mapメソッドは、繰り返し構文の代わりとして非常によく用いられます。次の例は、Student[]型の変数studentsが、Array#mapによりJSX.Element[]の値に変換され、ul要素の子要素に指定されています。

App.tsx
type Student = { id: string; name: string; age: number };

const students: Student[] = [
{ id: "J4-220000", name: "田中", age: 19 },
{ id: "J5-220001", name: "鈴木", age: 18 },
{ id: "J6-230001", name: "佐藤", age: 20 },
];

export default function App() {
return (
<ul>
{students.map((student) => (
<li key={student.id}>
{student.name} ({student.age})
</li>
))}
</ul>
);
}

課題

先程のstudentsのデータを用いて、次のような表を作ってみましょう。

学籍番号名前年齢
J4-220000田中19
J5-220001鈴木18
J6-230001佐藤20
解答例
App.tsx
type Student = { id: string; name: string; age: number };

const students: Student[] = [
{ id: "J4-220000", name: "田中", age: 19 },
{ id: "J5-220001", name: "鈴木", age: 18 },
{ id: "J6-230001", name: "佐藤", age: 20 },
];

export default function App() {
return (
<table>
<thead>
<tr>
<th>学生証番号</th>
<th>名前</th>
<th>年齢</th>
</tr>
</thead>
<tbody>
{students.map((student) => (
<tr key={student.id}>
<td>{student.id}</td>
<td>{student.name}</td>
<td>{student.age}</td>
</tr>
))}
</tbody>
</table>
);
}

コンポーネント

Reactでは、大文字の名前から始まる関数を、コンポーネントとして使用できます。コンポーネントとなる関数は、JSX.Elementを返さなければなりません。次の例では、自作のコンポーネントGreetingを定義しています。なお、main.tsxから呼び出されるAppもまたコンポーネントです。

App.tsx
function Greeting() {
return <p>Hello World!</p>;
}

export default function App() {
return (
<div>
<Greeting />
</div>
);
}

属性を指定した場合、属性名と属性の値の組み合わせからなるオブジェクトがコンポーネントの第1引数に渡されます。この引数は通常propsと命名されます。属性名は通常キャメルケースで表記されます。

App.tsx
type GreetingProps = { myName: string };

function Greeting(props: GreetingProps) {
return <p>Hello {props.myName}!</p>;
}

export default function App() {
return (
<div>
<Greeting myName="田中" />
</div>
);
}

属性名には文字列しか指定できませんが、属性の値にはJavaScriptの任意の値が使用できます。次の例では、Clockコンポーネントのnow属性にDateオブジェクトを指定しています。

App.tsx
type ClockProps = { now: Date };

function Clock(props: ClockProps) {
return <p>現在は {props.now.toString()}!</p>;
}

export default function App() {
return (
<div>
<Clock now={new Date()} />
</div>
);
}

useStateフックと状態

Reactでは、フックと呼ばれる、コンポーネント内のみから呼び出すことのできる特別な関数を使用できます。フックは通常useから始まる名前の関数となっています。useStateフックは、最も基本的なフックで、コンポーネントに状態を持たせるためのフックです。次の例は、状態countが、ボタンがクリックされるたびに1ずつ増加していくアプリケーションです。

App.tsx
import { useState } from "react";

export default function App() {
const [count, setCount] = useState<number>(0);

const increment = () => {
setCount(count + 1);
};

return (
<div>
<p>{count}</p>
<button type="button" onClick={increment}>
増やす
</button>
</div>
);
}

useState関数は、コンポーネントに持たせる状態の初期値を引数にとり、コンポーネントの状態を作成する関数です。型パラメータを用いて、状態の型を指定できます。この例では、初期値が0であるようなnumber型の状態を作成しています。

useState関数の戻り値は、要素数2の配列で、0番目の要素が現在の状態を、1番目の要素が状態を更新するための関数になります。もう少し厳密な表現を用いるのであれば、useState<T>関数の戻り値は[T, (value: T) => void]型とみなせます。

配列の分割代入

先ほどのプログラムにおいて

const [count, setCount] = useState(0);

は、分割代入という記法であり、次のように動作します。

const useStateResult = useState(0);
const count = useStateResult[0];
const setCount = useStateResult[1];
void

void型は、通常関数の戻り値にのみ使用される型で、関数が値を返さないことを示します。

App関数内で定義されているincrement関数では、setCount関数に対し、現在の状態であるcount変数に1を加えた値を引数として渡しています。これにより、increment関数が呼ばれると、状態countが増加するようになります。

フック呼び出しの制約

Reactのフックは、コンポーネントの中で毎度同じ回数、同じ順序で呼ばれる必要があります。ですので、ifなどの制御構造の中でフックを呼び出すことは通常ありません。この理由は次の項で判明します。

function App() {
if (condition) {
// フックが呼び出される順番や回数が変わってはならない
// const [state, setState] = useState(0);
}
return <div />;
}

コンポーネント関数が実行されるタイミング

Reactにおけるコンポーネントとは、JSX.Elementを返す関数を指すのでした。では、この関数は、どういったタイミングで実行されるのでしょうか。

この疑問に対する回答を探るため、先ほど作成したApp関数の先頭に、console.logを追加してみましょう。これにより、App関数が実行されるタイミングで、コンソールにメッセージが表示されるようになります。

App.tsx
import { useState } from "react";

export default function App() {
const [count, setCount] = useState<number>(0);
console.log(`count = ${count}`);

const increment = () => {
setCount(count + 1);
};

return (
<div>
<p>{count}</p>
<button type="button" onClick={increment}>
増やす
</button>
</div>
);
}

このプログラムを実行することで、App関数は、初回読み込み時と、ボタンがクリックされたタイミングで実行されていることが分かります。

つまり、Reactは、状態が変化するたびにコンポーネント関数を実行し、その結果得られたJSX.Elementの変化を検知してDOMに反映させているのです。

ユーザー入力を扱う

Reactでは、入力可能な要素のvalue属性を固定すると、その要素には入力できなくなります。

App.tsx
export default function App() {
return <input value="Fixed" />; // 入力できない
}

onChangeイベントを受け取って入力した値をコンポーネントの状態に反映させることで、ユーザー入力とコンポーネントの状態を同期させることができるようになります。

App.tsx
import { useState } from "react";

export default function App() {
const [text, setText] = useState("");

return (
<>
<input
value={text}
onChange={(e) => {
setText(e.target.value);
}}
/>
<p>入力されたテキスト: {text}</p>
</>
);
}

onChange属性には、要素のテキストが変更された際に発生するイベントのイベントハンドラを指定します。ReactのonChange属性は、DOMのchangeイベントハンドラと同様に記述することができ、第1引数にはEventオブジェクトに似た値が与えられます。

Event#targetプロパティには、イベントが発生した要素 (上の例ではHTMLInputElement) が格納されます。このオブジェクトのvalueプロパティを通して入力されようとしている値が取得できるので、この値をsetText関数を用いて状態に反映させています。

入力されたデータの流れ

複数のコンポーネントで状態を共有する

親コンポーネントAppと子コンポーネントTextFieldの関係があったとします。TextFieldコンポーネントで編集可能な状態を、親コンポーネントAppでも使用したいとします。

複数のコンポーネントで共通の状態が必要となる場合、それら全てが持つ共通の親コンポーネントで状態を定義する必要があります。この場合では、親コンポーネントであるAppに状態を定義するのが正解です。

子コンポーネントには、現在の状態の値そのものと、状態を更新するための関数を属性を経由して渡せば、通常の状態と同じように使用できるようになります。

App.tsx
import { useState } from "react";

type TextFieldProps = {
value: string;
onChange: (value: string) => void;
};

function TextField(props: TextFieldProps) {
return (
<input
value={props.value}
onChange={(e) => {
props.onChange(e.target.value);
}}
/>
);
}

export default function App() {
const [text, setText] = useState("");

return (
<>
<TextField value={text} onChange={setText} />
<p>入力されたテキスト: {text}</p>
</>
);
}

複雑な状態を扱う

useStateが作成可能な状態は、何もプリミティブな値のみに限りません。オブジェクトの形の状態を作成することで、より複雑な状態を表現することができます。以前扱ったToDoアプリを、Reactを用いて書き直してみましょう。

App.tsx
import { useState } from "react";

type Todo = { id: number; title: string };

export default function App() {
const [todos, setTodos] = useState<Todo[]>([]);
const [nextId, setNextId] = useState(1);
const [newTodo, setNewTodo] = useState("");

const addTodo = () => {
setTodos([...todos, { id: nextId, title: newTodo }]);
setNextId(nextId + 1);
setNewTodo("");
};

const removeTodo = (id: number) => {
setTodos(todos.filter((todo) => todo.id !== id));
};

return (
<>
<ul>
{todos.map((todo) => (
<li key={todo.id}>
<span>{todo.title}</span>
<button
type="button"
onClick={() => {
removeTodo(todo.id);
}}
>
削除
</button>
</li>
))}
</ul>
<div>
<input
value={newTodo}
onChange={(e) => {
setNewTodo(e.target.value);
}}
/>
<button type="button" onClick={addTodo}>
追加
</button>
</div>
</>
);
}

この例では、ToDo一覧を保持する状態todosと、次のIDを保持する状態nextId、そして新規作成用のテキストボックスの内容を保持する状態newTodoに分けて状態を管理しています。

Reactとイミュータビリティ

オブジェクトの参照節で扱ったように、JavaScriptオブジェクトは参照として扱われます。Reactでは、状態として保存されたオブジェクトの参照先への変更は許可されていません。例えば、先ほどのプログラムのaddTodo関数とremoveTodo関数は、次のように書き換えることはできません。これは、この方法ではReactが状態が変化したことを検知できないからです。

const addTodo = () => {
todos.push({ id: nextId, title: newTodo });
};
const removeTodo = (id: number) => {
todos.splice(
todos.findIndex((todo) => todo.id === id),
1,
);
};

オブジェクトの中身が変化しないとき、そのオブジェクトはイミュータブルであるといいます。一方、Array#pushメソッドやArray#spliceメソッドは、配列の中身を変化させます。このように、ミュータブルな操作を伴う関数を、破壊的であるという場合があります。破壊的メソッドは React の状態に対して使用できません。

スプレッド構文

スプレッド構文は、配列やオブジェクトを、別の配列やオブジェクトに展開するための記法です。重複するプロパティがある場合は、後に記載されたものが優先されます。

const array1 = [1, 2, 3];
const array2 = [...array1, 4, 5]; // [1, 2, 3, 4, 5]

const object1 = { name: "田中", age: 18 };
const object2 = { ...object1, age: 19, address: "東京" }; // { name: "田中", age: 19, address: "東京" }

課題