React
宣言的なUI
これまで、JavaScriptによりHTML要素を操作するために、DOMを用いることができることを学んできました。しかしながら、ナイーブな方法によりDOMを使用すると、アプリケーションの規模の限界がすぐにやってきます。
簡単なToDoアプリケーションを例に考えてみましょう。
<ul id="todos"></ul>
<input id="message" />
<button id="add-todo" type="button">追加</button>
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には、テキストや位置、色など、数えきれない状態が格納されています。状態の数が種類あれば、その遷移パターンは個になるわけですので、状態の数が増えることが非常に危険であることは明らかです。
ところが、アプリケーションの本質的な状態というのは、一般的にそこまで多いものではありません。例えば、ToDoリストアプリケーションであれば、各ToDoを表すstring
の配列string[]
がひとつだけあれば、アプリケーションの状態は全て表現できていることになるはずです。
宣言的UIは、こういった性質に着目します。より具体的に説明するのであれば、アプリケーションの状態に対し、関数によりUIの状態を表現できるのであれば、開発者の関心をの変化との定義のみに絞ることができるというわけです。
具体的なコードで確認してみましょう。先ほどの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でプロジェクトを作成しますが、framework
はReact
、variant
はTypeScript
を選択してください。
Reactを新規プロジェクトではなく、既存のウェブプロジェクトで用いる場合には、react
パッケージと、react-dom
パッケージが必要です。
また、React本体はTypeScriptに対応していないので、TypeScriptプロジェクトでReactを用いるためには@types
パッケージを加えてインストールする必要があります。
{
"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.tsx
とApp.tsx
が作成されるはずです。
main.tsx
はHTMLから直接実行されるファイルで、id
属性にroot
を持つ要素の中をReactにより管理する旨を示しています。また、このファイルからApp.tsx
で定義された関数App
が読み込まれています。詳細は重要ではないのでここでは扱いません。
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
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
を次のように修正します。
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
型の値) を生成します。この値はごく一般的なオブジェクトで、変数に代入するなど、他の値と同じように扱うことが可能です。
const message: JSX.Element = <div>Hello React</div>;
export default function App() {
return message;
}
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による計算結果を埋め込むことができます。
export default function App() {
return <div>1 + 1 = {1 + 1}</div>;
}
属性の値部分にも{}
が使用できます。
export default function App() {
return <input placeholder={new Date().toString()} />;
}
JSX式とJavaScriptの間を行き来することもできます。
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
課題
自分のテストの点数を表す変数score
を用意し、Reactで次を満たすプログラムを作成してください。
score
が80以上なら大変よくできました。
と表示する。score
が50以上80未満ならよくできました。
と表示する。score
が50未満ならもう少し頑張りましょう。
と表示する。
解答例
解答例1
条件演算子をネストして、条件分岐を表現します。
const score: number = 80;
export default function App() {
return (
<>
{score >= 80 ? (
<div>大変よくできました。</div>
) : score >= 50 ? (
<div>よくできました。</div>
) : (
<div>もう少し頑張りましょう。</div>
)}
</>
);
}
解答例2
次のように変数にJSX要素を代入しておき、最後にその変数を返すという方法もあります。
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式として表現するためには、通常&&
演算子が用いられます。例を見てみましょう。
const age = 20;
export default function App() {
return (
<form>
<input placeholder="お名前" />
<button>送信</button>
{age < 18 && <p>18歳未満の場合は保護者の同意が必要です。</p>}
</form>
);
}
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中に現れたfalse
やnull
、undefined
といった値は無視します。これにより、if
に似た構造が表現できるわけです。
JavaScriptでは、if文やwhile文などの制御構造も、条件式の結果がtruthyであるかを確認しています。
if ("") {
// 空文字列はfalsyなのでこの部分は実行されない
}
Boolean関数は、truthyな値をtrue
に、falsyな値をfalse
に変換します。
Boolean(null); // false
Boolean("Hello"); // true
JSXにおける繰り返し
Reactでは、JSXの子要素として配列を指定することができます。ただし、配列の要素がJSX.Element
型である場合、各要素のkey
属性に重複しない値を指定する必要があります。
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
要素の子要素に指定されています。
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 |
解答例
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
もまたコンポーネントです。
function Greeting() {
return <p>Hello World!</p>;
}
export default function App() {
return (
<div>
<Greeting />
</div>
);
}
属性を指定した場合、属性名と属性の値の組み合わせからなるオブジェクトがコンポーネントの第1引数に渡されます。この引数は通常props
と命名されます。属性名は通常キャメルケースで表記されます。
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
オブジェクトを指定しています。
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ずつ増加していくアプリケーションです。
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
関数が実行されるタイミングで、コンソールにメッセージが表示されるようになります。
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
属性を固定すると、その要素には入力できなくなります。
export default function App() {
return <input value="Fixed" />; // 入力できない
}
onChange
イベントを受け取って入力した値をコンポーネントの状態に反映させることで、ユーザー入力とコンポーネントの状態を同期させることができるようになります。
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
に状態を定義するのが正解です。
子コンポーネントには、現在の状態の値そのものと、状態を更新するための関数を属性を経由して渡せば、通常の状態と同じように使用できるようになります。
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を用いて書き直してみましょう。
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
に分けて状態を管理しています。
オブジェクトの参照節で扱ったように、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: "東京" }
演習問題1
ToDoリストの要素を上下に移動させる機能を追加しましょう
演習問題2
ToDoリストの要素へ編集する機能を追加しましょう
演習問題3(発展)
データベースに永続化することができるToDoリストアプリケーションを作成しましょう
ヒント: ページ読み込み時にFetch APIを用いてデータを保存済みのToDo一覧を取得します。リストが編集されたら再びFetch APIを用いてデータを保存しましょう。
解答例
別解