📘

React勉強しなおしてみた #3

2024/04/24に公開

前回記事はこちら

本記事の内容

元JavaエンジニアがReactを再学習する記録。
本記事内では下記セクションを学習する。

  • インタラクティビティの追加

インタラクティビティの追加

公式学習ページ
#1で作成したプロジェクトを引き続き使用して学習していく。

イベントへの応答

コンポーネント:UIの構成部品

イベントハンドラとはクリック、ホバー、入力等のユーザインタラクションに応答してトリガされる関数。buttonのような組み込みコンポーネントはonClickのような組み込みのブラウザイベントのみをサポートするが、独自コンポーネントではイベントハンドラpropsに自由に名前を付けることができる。

src\components\Button\MyButton.tsx
type CancelButtonProps = {
  onCancel: () => void; // ☆自由に名付けられる
};

export function CancelButton({ onCancel }: CancelButtonProps) {
  return (
    <button
      className="cancel-button"
      style={{ backgroundColor: "red", color: "white" }}
      onClick={onCancel} // onClickは組み込み
    >
      I&apos;m a cancel button
    </button>
  );
}
src\pages\index.tsx
...
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」を呼び出している。

src\components\Button\MyButton.tsx
export function Counter() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(count + 1)}>{count}</button>;
}

レンダーとコミット

コンポーネントは画面上に表示される前にReactによってレンダーされる必要がある。画面上にコンポーネントが表示される(コンポーネントが更新される)ステップは以下の通り。

  1. レンダーのトリガ
  2. Reactがコンポーネントをレンダー
  3. ReactがDOMへの変更をコミットする

1. レンダーのトリガ

コンポーネントがレンダーされるタイミングは下記の二つ。

  1. コンポーネントの初回レンダー
  2. コンポーネント(あるいはその祖先)の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で見ていく。

src\components\Button\MyButton.tsx
export function Counter() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
  1. onClickイベントハンドラが実行される
  2. setCount(count + 1)countcount + 1にセットし新しいレンダーを予約する
  3. Reactが新しいcountの値を使ってコンポーネントを再レンダーする

レンダーは時間を切り取ってスナップショットを取る

「レンダーする」とはReactがコンポーネント(関数)を呼び出すということ。関数から返されるJSXはその時点でのUIのスナップショットのようなものであり、そのJSX内のprops、イベントハンドラ、ローカル変数はすべてレンダー時のstateを使用して計算される。
Reactは画面をこの「UIのスナップショット」に合わせて更新し、イベントハンドラを接続する。その結果としてボタンを押すとJSXに書いたクリックハンドラがトリガされる。
Reactがコンポーネントを再レンダーする際のステップは以下。

  1. Reactが再度関数(コンポーネント)を呼び出す
  2. 関数が新しいJSXのスナップショットを返す
  3. 関数が返したスナップショットに合わせて画面を更新する
    コンポーネントのメモリとしてのstateは関数終了後消えてしまう通常の変数とは異なり、React自体の中で存在し続ける。
    試しにCounterを以下のように変更してみる。クリックしたら+3されそうに見えるが、実際は1ずつしか増えない。
src\components\Button\MyButton.tsx
export function Counter() {
  const [count, setCount] = useState(0);
  return (
    <button
      onClick={() => {
        setCount(count + 1);
        setCount(count + 1);
        setCount(count + 1);
      }}
    >
      {count}
    </button>
  );
}

このボタンのクリックハンドラは以下のようにReactに指示を出している。

  1. setCount(count + 1)count0のためsetCount(0 + 1)
    • Reactは次回のレンダーでcount1にする準備をする
  2. setCount(count + 1)count0のためsetCount(0 + 1)
    • Reactは次回のレンダーでcount1にする準備をする
  3. setCount(count + 1)count0のためsetCount(0 + 1)
    • Reactは次回のレンダーでcount1にする準備をする
      今回のレンダーのイベントハンドラではcountは常に0であるため、stateを3回連続で1にセットしていることになる。

時間経過とstate

公式ページ通りにこんなものを作ってみる。

src\components\Button\MyButton.tsx
export function CountAlert() {
  const [count, setCount] = useState(0);
  return (
    <button
      onClick={() => {
        setCount(count + 5);
        alert(count);
      }}
    >
      {count}
    </button>
  );
}

当然アラートには0と表示される。では次に以下のように3秒後にアラートが出るよう変更してみる。

src\components\Button\MyButton.tsx
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 更新をまとめて処理する

先ほど変更したCountersetCounterを3回呼び出しているため、3回インクリメントされそうにも見える。

src\components\Button\MyButton.tsx
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は下記のように処理する。

  1. この関数をキューに入れて、イベントハンドラ内の他のコードがすべて実行された後処理されるようにする
  2. 次のレンダー中にキューを処理し、最後に更新されたstateを返す
src\components\Button\MyButton.tsx
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を置き換えた後更新関数を渡すとどうなるか。

src\components\Button\MyButton.tsx
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)という一文を追加してみた。

src\components\Button\MyButton.tsx
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オブジェクトは読み取り専用として扱う必要がある。
公式ページにある通りにこんなものを作った。エリア内でカーソルを動かすと赤い点が動く、予定だがこのままでは動かない。

src\components\Area\MovingDotArea.tsx
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);

配列へのその他の変更

順序を逆にしたり並べ替えたり等、filtermapなどの書き換えを行わないメソッドでは対応できない場合。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