React勉強しなおしてみた #3
前回記事はこちら
本記事の内容
元JavaエンジニアがReactを再学習する記録。
本記事内では下記セクションを学習する。
- インタラクティビティの追加
インタラクティビティの追加
公式学習ページ
#1で作成したプロジェクトを引き続き使用して学習していく。
イベントへの応答
コンポーネント:UIの構成部品
イベントハンドラとはクリック、ホバー、入力等のユーザインタラクションに応答してトリガされる関数。button
のような組み込みコンポーネントはonClick
のような組み込みのブラウザイベントのみをサポートするが、独自コンポーネントではイベントハンドラpropsに自由に名前を付けることができる。
type CancelButtonProps = {
onCancel: () => void; // ☆自由に名付けられる
};
export function CancelButton({ onCancel }: CancelButtonProps) {
return (
<button
className="cancel-button"
style={{ backgroundColor: "red", color: "white" }}
onClick={onCancel} // onClickは組み込み
>
I'm a cancel button
</button>
);
}
...
export default function Home() {
...
return (
<PageLayout title="React Learn">
...
<CancelButton onCancel={() => alert("cancel")} />
</PageLayout>
);
}
state:コンポーネントのメモリ
フォーム上でタイプすると内容が更新される入力欄、「次」をクリックすると表示される画像が変わる画像カルーセル等、コンポーネントによってはユーザ操作の結果として表示内容を変更する必要がある。その際必要になるのが状態を覚えておく機能。Reactではこのようなコンポーネント固有のメモリのことをstateと呼ぶ。
stateを追加するにはuseState
フックを使用する。
const [index, setIndex] = useState(0)
というように記述し、引数として初期値を受け取り現在のstateとそれを更新するためのセッター関数のペアを返す。
下記の例はクリックするごとにボタンに表示される数字が増えていくカウンターコンポーネント。現在のstate「count
」をボタンに表示し、onClick
がトリガされるごとにセッター関数「setCount
」を呼び出している。
export function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
レンダーとコミット
コンポーネントは画面上に表示される前にReactによってレンダーされる必要がある。画面上にコンポーネントが表示される(コンポーネントが更新される)ステップは以下の通り。
- レンダーのトリガ
- Reactがコンポーネントをレンダー
- ReactがDOMへの変更をコミットする
1. レンダーのトリガ
コンポーネントがレンダーされるタイミングは下記の二つ。
- コンポーネントの初回レンダー
- コンポーネント(あるいはその祖先)のstateの更新
2. Reactがコンポーネントをレンダー
レンダーとはReactがコンポーネントを呼び出すこと。
今回勘違いしていたポイント。レンダーとは画面にDOMを描画することかと...描画(DOMの表示/変更)はその先の話らしい。
- 初回レンダー時: Reactはルートコンポーネントを呼び出す。
- 次回以降のレンダー: stateの更新によってレンダーがトリガされた関数コンポーネントをReactが呼び出す。前回のレンダーからどの部分が変わったか、あるいは変わらなかったかを計算する。
以上のプロセスが再帰的に発生する。更新されたコンポーネントが他のコンポーネントを返す場合、次にそのコンポーネントをレンダーし、さらにそのコンポーネントが他のコンポーネントを返す場合そのコンポーネントを...といった具合。
3. ReactがDOMへの変更をコミットする
コンポーネントをレンダーした後、ReactはDOMを変更する。
- 初回レンダー時:appendChild() DOM API を使用して、作成したすべてのDOMを表示する
- 再レンダー時:最新のレンダー出力に合わせてDOMを変更するための必要最小限の操作(全ステップで計算されたもの)を適用する
Reactはレンダー間で違いがあった場合のみDOMを変更する。レンダー結果が前回と同じである場合、ReactはDOMを触らない。
state はスナップショットである
state変数はスナップショットのように振る舞う。stateをセットしてもすでにあるstate変数は変更されず、代わりに再レンダーがトリガされる。
state のセットでレンダーがトリガされる
Reactの動作は「UIとはクリックなどユーザイベントに直接反応して更新されるもの」という考え方とは異なる。
ボタンをクリックすると行われる処理を先ほど作ったCounterで見ていく。
export function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
-
onClick
イベントハンドラが実行される -
setCount(count + 1)
がcount
をcount + 1
にセットし新しいレンダーを予約する - Reactが新しい
count
の値を使ってコンポーネントを再レンダーする
レンダーは時間を切り取ってスナップショットを取る
「レンダーする」とはReactがコンポーネント(関数)を呼び出すということ。関数から返されるJSXはその時点でのUIのスナップショットのようなものであり、そのJSX内のprops、イベントハンドラ、ローカル変数はすべてレンダー時のstateを使用して計算される。
Reactは画面をこの「UIのスナップショット」に合わせて更新し、イベントハンドラを接続する。その結果としてボタンを押すとJSXに書いたクリックハンドラがトリガされる。
Reactがコンポーネントを再レンダーする際のステップは以下。
- Reactが再度関数(コンポーネント)を呼び出す
- 関数が新しいJSXのスナップショットを返す
- 関数が返したスナップショットに合わせて画面を更新する
コンポーネントのメモリとしてのstateは関数終了後消えてしまう通常の変数とは異なり、React自体の中で存在し続ける。
試しにCounterを以下のように変更してみる。クリックしたら+3されそうに見えるが、実際は1ずつしか増えない。
export function Counter() {
const [count, setCount] = useState(0);
return (
<button
onClick={() => {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
}}
>
{count}
</button>
);
}
このボタンのクリックハンドラは以下のようにReactに指示を出している。
-
setCount(count + 1)
はcount
が0
のためsetCount(0 + 1)
- Reactは次回のレンダーで
count
を1
にする準備をする
- Reactは次回のレンダーで
-
setCount(count + 1)
はcount
が0
のためsetCount(0 + 1)
- Reactは次回のレンダーで
count
を1
にする準備をする
- Reactは次回のレンダーで
-
setCount(count + 1)
はcount
が0
のためsetCount(0 + 1)
- Reactは次回のレンダーで
count
を1
にする準備をする
今回のレンダーのイベントハンドラではcount
は常に0
であるため、stateを3回連続で1
にセットしていることになる。
- Reactは次回のレンダーで
時間経過とstate
公式ページ通りにこんなものを作ってみる。
export function CountAlert() {
const [count, setCount] = useState(0);
return (
<button
onClick={() => {
setCount(count + 5);
alert(count);
}}
>
{count}
</button>
);
}
当然アラートには0
と表示される。では次に以下のように3秒後にアラートが出るよう変更してみる。
export function CountAlert() {
const [count, setCount] = useState(0);
return (
<button
onClick={() => {
setCount(count + 5);
alert(count);
}}
>
{count}
</button>
);
}
結果はボタンに表示されるのは5
、アラートに表示されるのは0
となる。
アラートが実行される3秒後時点ではReact内に格納されているstateは既に更新されているが、アラート自体はユーザーがボタンをクリックした時点でのstateのスナップショットをしようしているため更新前のstate0
が表示される。
Reactはレンダー内のstateの値を固定しイベントハンドラ内で保持する。コードの途中でstateが変更されたかどうかは気にする必要がない。
一連のstateの更新をキューに入れる
state変数をセットすると新しいレンダーがキューに予約される。しかし次のレンダーをキューに入れる前にstateの値に対して複数操作を行いたい場合がある。そのためにはReactがstateの更新を一括処理する方法についての理解が必要。
React は state 更新をまとめて処理する
先ほど変更したCounter
。setCounter
を3回呼び出しているため、3回インクリメントされそうにも見える。
export function Counter() {
const [count, setCount] = useState(0);
return (
<button
onClick={() => {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
}}
>
{count}
</button>
);
}
個々のレンダーのstate値は固定であるためである他に、「イベントハンドラ内のすべてのコードが実行されるまで、Reactはstateの更新処理を待機する」という仕様が関わってくる。このため何度setCount(count + 1)
を呼び出したところで1づつしか増えない。
次のレンダー前に同じstateを複数回更新する
もし次のレンダー前に同じstate変数を複数回更新する場合はsetCount(count + 1)
ではなくsetCount(count => count + 1)
とすることで実現できる。これはstateの値を置き換えるのではなく、Reactに対し「このstateの更新はこのようにせよ」と伝えている状態である。
このcount => count + 1
は更新用関数と呼ばれる。これをstateのセッターに渡すとReactは下記のように処理する。
- この関数をキューに入れて、イベントハンドラ内の他のコードがすべて実行された後処理されるようにする
- 次のレンダー中にキューを処理し、最後に更新されたstateを返す
export function Counter() {
const [count, setCount] = useState(0);
return (
<button
onClick={() => {
setCount((count) => count + 1);
setCount((count) => count + 1);
setCount((count) => count + 1);
}}
>
{count}
</button>
);
}
上記の例だと下記表のように処理され、最終結果として3
を保存しuseState
から返す。
キュー内の更新処理 | count | 返り値 |
---|---|---|
count => count + 1 | 0 | 0 + 1 = 1 |
count => count + 1 | 1 | 1 + 1 = 2 |
count => count + 1 | 2 | 2 + 1 = 3 |
stateを置き換えた後に更新するとどうなるか
では下記のようにstateを置き換えた後更新関数を渡すとどうなるか。
export function Counter() {
const [count, setCount] = useState(0);
return (
<button
onClick={() => {
setCount(count + 1);
setCount((count) => count + 1);
}}
>
{count}
</button>
);
}
下記表のように処理され、最終結果として1
を保存しuseState
から返す。
キュー内の更新処理 | count | 返り値 |
---|---|---|
1に置き換えよ | 0 | 1 |
count => count + 1 | 1 | 1 + 1 = 2 |
state を更新した後に置き換えるとどうなるか
先ほどの例にstateを置き換えるsetCount(10)
という一文を追加してみた。
export function Counter() {
const [count, setCount] = useState(0);
return (
<button
onClick={() => {
setCount(count + 1);
setCount((count) => count + 1);
setCount(10);
}}
>
{count}
</button>
);
}
下記表のように処理され、最終結果として10
を保存しuseState
から返す。
キュー内の更新処理 | count | 返り値 |
---|---|---|
1に置き換えよ | 0 | 1 |
count => count + 1 | 1 | 1 + 1 = 2 |
10に置き換えよ | 2 | 10 |
イベントハンドラが完了した後、Reactは再レンダーをトリガする。再レンダー中にReactはキューを処理するため、更新用関数は結果だけを返す純関数である必要がある。その中でstateをセットしたり他の副作用を実行してはいけない。
命名規則
一般的な更新用関数の引数の命名。
- 対応するstate変数の頭文字を使用
setCount(c => c + 1)
- `setAlertCount(ac => ac + 1)
- state名そのまま
setCount(count => count + 1)
-
prev
などのプレフィックスを付けるsetCount(prevCount => prevCount + 1)
state内のオブジェクトの更新
stateの値としてオブジェクトも保存できる。stateに保持されたオブジェクトは直接書き換えるのではなく、新しいオブジェクト(あるいは既存のオブジェクトのコピー)を作成しそれを使用してstateをセットする必要がある。
ミューテーションとは?
stateにはどのようなJavaScriptの値でも格納できる。これまで扱ってきたものは数値、文字列、真偽値の値自体は変わらない不変のものだった。
const [x, setX] = useState(0)
があったとして、setX(5)
とセッターを呼び出してみる。するとx
というstateの値は5
に置き換わるが、0
という数字そのものは変化するわけではない。
ではオブジェクトの場合。
const [position, setPosition] = useState({ x: 0, y: 0 });
position.x = 5;
のようにオブジェクト自体の内容を書き換えることも技術的には可能。これをミューテーションの呼ぶ。
しかしReactのstate内にあるオブジェクトは数値や文字列と同様に不変なものとして扱う、書き換えるのではなく置き換える必要がある。
stateを読み取り専用として扱う
stateとして格納するすべてのJavaScriptオブジェクトは読み取り専用として扱う必要がある。
公式ページにある通りにこんなものを作った。エリア内でカーソルを動かすと赤い点が動く、予定だがこのままでは動かない。
import { useState } from "react";
export default function MovingDotArea() {
const [position, setPosition] = useState({
x: 0,
y: 0,
});
return (
<div
onPointerMove={(e) => {
position.x = e.clientX;
position.y = e.clientY;
}}
style={{
position: "relative",
width: "100vw",
height: "100vh",
}}
>
<div
style={{
position: "absolute",
backgroundColor: "red",
borderRadius: "50%",
transform: `translate(${position.x}px, ${position.y}px)`,
left: 0,
top: 0,
width: 20,
height: 20,
}}
/>
</div>
);
}
問題はposition.x = e.clientX
のようにstateを直接書き換えている点。stateのセット関数を使用していないためReactはオブジェクトが変更されたことを検知できない。そのためstate値は直接書き換えない、読み取り専用のものとして扱うべきである。
// Bad
onPointerMove={(e) => {
position.x = e.clientX;
position.y = e.clientY;
}}
// Good
onPointerMove={(e) => {
setPosition({
x: e.clientX,
y: e.clientY,
});
}}
setPosition
を使用するよう書き換えれば動くようになる。
スプレッド構文を使ったオブジェクトのコピー
先ほどはオブジェクトすべてを新しく作成してセットしていたが、既存のデータも含めたい場合どうするか。下記のようにスプレッド構文を使用することで解決できる。
onPointerMove={(e) => {
setPosition({
...position,
x: e.clientX,
});
}}
ネストされたオブジェクトの更新
以下のようなネストされたオブジェクトの更新はどうすべきか。
const [person, setPerson] = useState({
name: 'Niki de Saint Phalle',
artwork: {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://i.imgur.com/Sd1AgUOm.jpg',
}
});
person.artwork.city = 'New Delhi'
というような書き換えはできないため、下記のように新しいオブジェクトを生成するか、スプレッド構文を複数回使用して解決する。
// 新しいオブジェクトを生成
const nextArtwork = { ...person.artwork, city: 'New Delhi' };
const nextPerson = { ...person, artwork: nextArtwork };
setPerson(nextPerson);
// スプレッド構文を複数回使用
setPerson({
...person, // Copy other fields
artwork: { // but replace the artwork
...person.artwork, // with the same one
city: 'New Delhi' // but in New Delhi!
}
});
Immerで簡潔な更新ロジックを書く
Immerライブラリを使用することでミュ―テート型の構文で書くこともできる。
updatePerson(draft => {
draft.artwork.city = 'Lagos';
});
state内の配列の更新
オブジェクトと同様に配列も読み取り専用として扱う。更新する際は新しい配列を作成(あるいは既存の配列をコピー)して、その新しい配列でstateをセットする必要がある。
配列を書き換えずに更新する
オブジェクトと同様配列も読み取り専用として扱わなければならないため、arr[0] = 'bird'
やpop()``push()
など配列内の要素への再代入や配列を書き換えるメソッドも使用してはならない。
代わりにfilter()
やmap()
など書き換えを行わないメソッドを使用して、元の配列から新しい配列を作成しstateにセットする。
配列に要素を追加
配列を書き換えるpush
, unshift
は使用せず、concat
やスプレッド構文を使用する。
const [artists, setArtists] = useState([]);
// Bad
artists.push({id: nextId++, name: name});
artists.unshift({id: nextId++, name: name});
// Good
setArtists([...artists, {id: nextId++, name: name}])
setArtists([{id: nextId++, name: name}, ...artists])
配列から要素を削除
pop
, shift
, splice
など直接書き換えるメソッドは使わず、filter
, slice
で新しい配列を作成しstateにセットする。
// Good
setArtists(
artists.filter(a => a.id !== artist.id)
);
配列の変換
配列の一部あるいはすべてを変更したい場合、map
を使用して新しい配列を作成できる。
const nextShapes = shapes.map(shape => {
if (shape.type === 'square') {
// No change
return shape;
} else {
// Return a new circle 50px below
return {
...shape,
y: shape.y + 50,
};
}
});
配列内の要素の置換
配列内の一部のみ置き換えたい場合、splice
, arr[i] = ...
などを使用するのではなくmap
を使用する。
// Bad
counters[index] = counters[index] + 1
// Good
const nextCounters = counters.map((c, i) => {
if (i === index) {
// Increment the clicked counter
return c + 1;
} else {
// The rest haven't changed
return c;
}
});
setCounters(nextCounters);
配列への挿入
先頭、終端以外の場所へ要素を挿入したい場合、slice()
とスプレッド構文で実現できる。
const insertAt = 1; // 挿入したい位置
const nextArtists = [
// 挿入したい位置より前の要素を配列から切り抜き
...artists.slice(0, insertAt),
// 新しい要素
{ id: nextId++, name: name },
// 挿入したい位置より後の要素を配列から切り抜き
...artists.slice(insertAt)
];
setArtists(nextArtists);
配列へのその他の変更
順序を逆にしたり並べ替えたり等、filter
やmap
などの書き換えを行わないメソッドでは対応できない場合。revese
, sort
など元の配列を書き換えてしまうメソッドは直接使用することはできないが、最初に配列をコピーしコピーした配列に変更を加えることは可能である。
const [list, setList] = useState(initialList);
// Bad
list.reverse()
// Good
const nextList = [...list];
nextList.reverse();
setList(nextList);
ただし、コピーした配列内の要素には元の配列と同じ要素が含まれるため、コピーした配列内の要素であっても直接変更することはできない。
const [list, setList] = useState(initialList);
// Bad
const nextList = [...list];
nextList[0].seen = true;
setList(nextList);
上記のnextList[0]
とlist[0]
は同じ要素を指しているため、nextList[0]
を変更するとlist[0]
も変更されてしまう。
配列内のオブジェクトを更新する
配列内にオブジェクトが存在しているように見えるが、実際は配列内のオブジェクトはそれぞれ独立した値であり、配列はその場所を参照しているに過ぎない。
ネストされたstateを更新する際は、更新したい場所からトップレベルまでのコピーを作成する必要がある。
以下は公式ページに載っている例で、同じリストを初期値としたstateが二つある。ミューテーションが起きているためstateが誤って共有され、一方のリストでチェックするともう一方のリストまでチェックされてしまう。
import { useState } from 'react';
let nextId = 3;
const initialList = [
{ id: 0, title: 'Big Bellies', seen: false },
{ id: 1, title: 'Lunar Landscape', seen: false },
{ id: 2, title: 'Terracotta Army', seen: true },
];
export default function BucketList() {
const [myList, setMyList] = useState(initialList);
const [yourList, setYourList] = useState(
initialList
);
function handleToggleMyList(artworkId, nextSeen) {
const myNextList = [...myList];
const artwork = myNextList.find(
a => a.id === artworkId
);
artwork.seen = nextSeen; // ミューテーション発生個所
setMyList(myNextList);
}
function handleToggleYourList(artworkId, nextSeen) {
const yourNextList = [...yourList];
const artwork = yourNextList.find(
a => a.id === artworkId
);
artwork.seen = nextSeen;
setYourList(yourNextList);
}
return (
<>
<h1>Art Bucket List</h1>
<h2>My list of art to see:</h2>
<ItemList
artworks={myList}
onToggle={handleToggleMyList} />
<h2>Your list of art to see:</h2>
<ItemList
artworks={yourList}
onToggle={handleToggleYourList} />
</>
);
}
function ItemList({ artworks, onToggle }) {
return (
<ul>
{artworks.map(artwork => (
<li key={artwork.id}>
<label>
<input
type="checkbox"
checked={artwork.seen}
onChange={e => {
onToggle(
artwork.id,
e.target.checked
);
}}
/>
{artwork.title}
</label>
</li>
))}
</ul>
);
}
myNextList
という新しい配列を作成してはいるものの、個々の要素そのものはmyList
と同じであるため、artwork.seen
を更新すると元の要素迄変更されてしまう。また、その要素はyourList
にも存在するため、バグが発生してしまう。
これを避けるため、map
を使用する。
// Bad
const myNextList = [...myList];
const artwork = myNextList.find(a => a.id === artworkId);
artwork.seen = nextSeen;
setMyList(myNextList);
// Good
setMyList(myList.map(artwork => {
if (artwork.id === artworkId) {
// Create a *new* object with changes
return { ...artwork, seen: nextSeen };
} else {
// No changes
return artwork;
}
}));
Immerを使って簡潔な更新ロジックを書く
オブジェクトと同様にImmerを使用して、書きやすいミューテート型の構文で記述しつつコピーを生成することができる。
updateMyTodos(draft => {
const artwork = draft.find(a => a.id === artworkId);
artwork.seen = nextSeen;
});
書き換えているのはImmerから渡されるdraft
オブジェクトであり、元のstateは書き換えていないためこの記述でも正常に動作する。同様にpush()
やpop()
などもdraft
に対しては使用することができる。
Discussion