🐳

Reactの基本を知る

2023/04/08に公開

はじめに

先日新しく発表された React の公式ドキュメントを皆さんはご覧になられたでしょうか?
この react.dev の前に Beta 版があり、筆者は少しずつ読み進めていました。その中で React の基本や思想をほとんど知らないまま使っていたことに気づきました。
今回読み進めることで公式ドキュメントをしっかりと理解することの重要性を知り、アウトプットとしてまとめてみたので、是非参考にしてもらえると嬉しいです!(GPT の手助けもあります)

Props について

https://beta.reactjs.org/learn/passing-props-to-a-component

Props とは親から子コンポーネントに渡す変数(プロパティ)のことです。
関数の引数のように利用でき、かならずオブジェクトとして渡す必要があります。

.jsx
function Avatar({ person, size }) {
  // ...
}

// 渡し方
<Avatar
  size={100}
  person={{
    name: "Katsuko Saruhashi",
    imageId: "YfeOqp2",
  }}
/>;

この{ person, size }部分では分割代入が使われています。

この分割代入は、JavaScriptES6(ECMAScript 2015で導入された構文で、オブジェクトや配列から特定のプロパティや要素を取り出して、新しい変数に代入することができます。
例えば、上記の例では、Avatar コンポーネントにsizepersonのプロパティを渡すことでレンダリング時に以下のオブジェクトが生成されます。

js
{
  size: 100,
  person: {
    name: "Katsuko Saruhashi",
    imageId: "YfeOqp2",
  }
}

この propsオブジェクトがAvatar関数に引数として渡されます。ここで分割代入の仕組みが登場します。分割代入を使用することで、オブジェクトから特定のプロパティを取り出し、それらを個別の変数として扱うことができます。

リストのレンダリング

https://react.dev/learn/rendering-lists

基本的な使い方(map と filter)

基本的に map や filter を使う

import { people } from "./data.js";
import { getImageUrl } from "./utils.js";

export default function List() {
  // filterを使って絞り込み
  const chemists = people.filter((person) => person.profession === "chemist");

  // mapを使って新しい配列を生成
  const listItems = chemists.map((person) => (
    <li>
      <img src={getImageUrl(person)} alt={person.name} />
      <p>
        <b>{person.name}:</b>
        {" " + person.profession + " "}
        known for {person.accomplishment}
      </p>
    </li>
  ));
  return <ul>{listItems}</ul>;
}

リストアイテムには一意の key をつける

map や filter で展開するものには必ずkey属性を追加する必要があります。

const listItems = people.map((person) => (
  // keyを指定する必要がある
  <Fragment key={person.id}>
    <h1>{person.name}</h1>
    <p>{person.bio}</p>
  </Fragment>
));

これは、どの配列要素に対して変更が起きたかを識別するために必要です。

★ なぜ key が必要なのかの背景(DOM の再構築)
React では、仮想 DOM を使用して、コンポーネントの変更を効率的に管理しています。アプリケーションの状態が変化するたびに、React は新しい仮想 DOM ツリーを生成し、それを既存の仮想 DOM ツリーと比較します。この比較プロセスを「再構築(Reconciliation)」と呼び、再構築を通じて React は変更があった部分だけを検出し、実際の DOM に効率的に適用します。

その上で、一意で安定したkey属性を指定することで、React は要素の追加、更新、削除を効率的に検出できます。要素が再利用されたり、順序が変更されたりした場合でも、React は key属性を使って要素を特定し、適切な操作を実行します。

key属性が指定されていない場合はindexを用いることで対応されますが、indexkeykeyとして用いることが出来るのは、リストが静的で並び替えや要素の追加・削除がない場合に限定されます。動的なリストの場合は、一意の ID やデータを元に生成されたハッシュ値などを key として使用することが望ましいとされています。

コンポーネントを純粋に保つ

https://react.dev/learn/keeping-components-pure

「コンポーネントを純粋に保つ(Keeping Components Pure)」とは、あるコンポーネントに同じ props が渡された場合、必ず同じ結果をレンダリングするようにすることです。
レンダリング中に props の変更などで中身が変わってしまってはいけません。

❌ 純粋でないコンポーネント
コンポーネント内で変数を直接変更してしまっています。

let guest = 0;

function Cup() {
  // Bad: changing a preexisting variable!
  guest = guest + 1;
  return <h2>Tea cup for guest #{guest}</h2>;
}

export default function TeaSet() {
  return (
    <>
      <Cup /> // 2
      <Cup /> // 4
      <Cup /> // 6
    </>
  );
}

こうすることで、毎回異なる JSX が生成されてしまい、パフォーマンスが悪くなります。また同時に、他のコンポーネントがguestを呼んだタイミングによって都度中身が変わってしまうため、予測不能なバグを生み出してしまうことにもつながります。

⭕ 純粋なコンポーネント
Cupの関数内で使われているguestは、ただ使われているだけで変更はされていません。

function Cup({ guest }) {
  return <h2>Tea cup for guest #{guest}</h2>;
}

export default function TeaSet() {
  return (
    <>
      <Cup guest={1} /> // 1
      <Cup guest={2} /> // 2
      <Cup guest={3} /> // 3
    </>
  );
}

このように、コンポーネントを純粋に保つことで、外部の状態や副作用に依存しないため、予測可能であり、テストやデバッグが容易になります。また、汎用性が高く、再利用が容易になります。純粋な小さな単位のコンポーネントを作成してそれらを組み合わせて複雑な UI を構築することで、メンテナンス性の向上にも繋がります。

純粋でないコンポーネントを検知する仕組み

React のStrictModeでは開発環境でのみ関数は 2 回実行されるようになっています。
これは実際に、関数の中にconsole.log 書いてみると確認できます。

先ほどの例でも StrictMode により関数が 2 回実行されているので、❌ 純粋でないコンポーネントでは「2,4,6」と出力されますが、⭕ 純粋なコンポーネントでは「1,2,3」と出力されます。

このStrictModeによって関数に同じ引数を渡したとき、必ず同じ結果になって純粋なコンポーネントであるかを確認しています。(レンダリング中に変数の中身が変わったりしてはいけない)

レンダリングの最初に作成された変数を変更しても問題ない

レンダリング中に変数の中身を変えてしまうのは厳禁ですが、レンダリング中に作成したばかりの変数を変更するのは問題ないものとされます。
例えば、以下のような例です。

.jsx
function Cup({ guest }) {
  return <h2>Tea cup for guest #{guest}</h2>;
}

export default function TeaGathering() {
  let cups = [];
  for (let i = 1; i <= 12; i++) {
    cups.push(<Cup key={i} guest={i} />);
  }
  return cups;
}

cups配列がTeaGathering関数内で定義されており、その上でレンダリング中にcups配列に JSX をpushしています。
これは、ローカルミューテーションと呼ばれ、コンポーネント内で完結している変数の変更は問題ないものとされます。

副作用について

useEffectなどの副作用は、レンダリング中に変更を及ぼすものではなく、レンダリング後に発生するものであるため、純粋に保つ必要はありません。
例えば、イベントハンドラーなどが該当します。

イベントハンドラーを設定する

基本的な使い方

ここでは click イベントを例に挙げます。
onClickは React がサポートしているデフォルトの Prop です。

// 普通の関数を使う場合
export default function Button() {
  function handleClick() {
    alert('You clicked me!');
  }

  return (
    // handleClick()ではないことに注意 ただ関数を渡すだけであり実行はしない
    <button onClick={handleClick}>
      Click me
    </button>
  );
}

// アロー関数を使う場合
export default function Button() {
  const handleClick = () => {
    alert('You clicked me!');
  }

  return (
    <button onClick={handleClick}>
      Click me
    </button>
  );
}

イベントハンドラーを設定する仕組みとしては、onClickProp として 関数を渡し、<button>がその onClick を Prop として受け取り、<button>にイベントを設置しています。

また、イベントハンドラーの中身の関数名はhandle~とするのが一般的です。

Have names that start with handle, followed by the name of the event. By convention, it is common to name event handlers as handle followed by the event name. You’ll often see onClick={handleClick}, onMouseEnter={handleMouseEnter}, and so on.

イベントハンドラー Props の名前も好きなように決められます。

↓ のonPlayMovieonUploadImage

<Toolbar
  onPlayMovie={() => alert("Playing!")}
  onUploadImage={() => alert("Uploading!")}
/>

関数に引数を渡す場合

Prop として渡す関数を、アロー関数にする必要があります。

<Button onClick={() => alert("Playing!")}>Play Movie</Button>

例えばonClick={alert(’Playing!’)}にすると即座に関数が実行されてしまうことになります。これは、イベントハンドラーの中身として Prop を渡すのではなく関数の実行をしてしまっているので誤りです。

state について

https://react.dev/learn/state-a-components-memory

state の更新が反映されるタイミング

以下 2 つの state 更新方法の違いを考えてみます。

1️⃣

<button
  onClick={() => {
    setNumber(number + 1);
    setNumber(number + 1);
    setNumber(number + 1);
  }}
>
  +3
</button>

2️⃣

<button
  onClick={() => {
    setNumber((n) => n + 1);
    setNumber((n) => n + 1);
    setNumber((n) => n + 1);
  }}
>
  +3
</button>

setNumberの中身が違いますね。
そして、この 2 つのボタンを押した結果も異なるのです。
1️⃣ では、1 回ボタンを押すと+1された値で、2️⃣ では+3された値が出力されます。
想定では両方+3されるはずでしたが、1️⃣ ではうまく機能していないようです。

1️⃣ の流れとしては以下のようになります。

  1. イベントハンドラが実行された時点の変数の値(number)を保持する
  2. その変数をイベントハンドラ終了時まで使い、処理を行う
  3. イベントハンドラによって更新された値で再レンダリングを行う

React では、ユーザーが操作した(クリックした)時点の状態のスナップショットを使用しているため、操作した時に既にあった状態変数の値を使用します。そして、イベントハンドラの処理が終わった後に、コンポーネントが再レンダリングされるという流れです。

また、再レンダリングが行われるのはイベントハンドラ内の関数が全て実行された後であり、それが終わるまでは待機されるようになっています。
この動作をバッチ処理(batching)と言います。

この流れにより、複数のコンポーネントから複数の状態変数を更新しても際レンダリングの回数を抑えることができ、かつ処理が全て実行されるまで UI が変化しないことを担保することができます。

一方で、同じイベントハンドラ内で複数回状態変数を変更したい場合は 2️⃣ のような関数を書けば問題ないです。

2️⃣ の流れは以下のようになっています。

  1. 最初のsetNumber+1を行います
  2. 次のsetNumberで 1 で行った最終的な状態変数の値を使って状態変数を更新します
  3. 最後までイベントハンドラ内の処理が行われた後、最終的な状態変数を使って再レンダリングが行われます

state の中身は read-only、 immutable なものとして扱う

immutable と mutable を訳すと...

まず、前提として、JavaScript ではデータ型にプリミティブ型とオブジェクト型の 2 つが存在します。
https://qiita.com/ta1fukumoto/items/effaa42cd296a2648d41

プリミティブ型は number や boolean、string などが該当し、例えば以下のように状態変数として設定している値がプリミティブ型であればそれは immutable なものになります。

// xはnumber型(=プリミティブなもの)
const [x, setX] = useState(0);
setX(5);

この set されている 0 や 5 は、0 または 5 以外に変化させることはできなく、唯一のものでありかつread-onlyなものです。
React では、このように状態変数を必ず immutable なものにする必要があります。
元からプリミティブ型であれば問題ないですが、オブジェクト型は mutable なものとして扱われます。

mutable の例

const [position, setPosition] = useState({ x: 0, y: 0 });

// mutable
position.x = 5;

このようにオブジェクト型はposition.x=5などとして既にあるオブジェクトの中身の x 変数を好きなように変えられます。
これは、すでに set したものを直接変更しているためmutableとして扱われます。
React においてこれは厳禁であり、状態変数を変更したにも関わらず再レンダリングも行われません。
なので、以下のようにして read-only でかつ immutable にする必要があります

immutable の具体例

const [position, setPosition] = useState({ x: 0, y: 0 });

// immutable
onPointerMove={e => {
  setPosition({
    x: e.clientX,
    y: e.clientY
  });
}}

set するときに新しいオブジェクトごと渡すようにします。
これで再レンダリングは正しく行われます。

state の更新にスプレッド構文を使う

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Operators/Spread_syntax

上記のように必ず状態変数を immutable にするために新しいオブジェクトを再度設定するとしても、わざわざ全ての変数を書くのは面倒です。そこでスプレッド構文を使います。

setPerson({
  firstName: e.target.value, // New first name from the input
  lastName: person.lastName,
  email: person.email,
});

これをスプレッド構文を使うことで以下のように簡潔に書くことができます。

setPerson({
  ...person, // Copy the old fields
  firstName: e.target.value, // But override this one
});

配列の扱い方

ある配列に対して要素を追加したり、削除したりなどなんかしらの操作を行うと思います。その際に、いわゆる破壊的なメソッドを使うことは避けるべきとされています。破壊的というのは元の配列自体を操作するようなことです。
例えば、push・pop・splice・reverse・sortなどが該当します。

具体的に見ていきたいと思います。

  1. 要素の追加

❌ push を使用
元の状態変数自体を変更しているため mutable な処理を行なっています。
mutable なものなので再レンダリングも正しく行われません。

const [artists, setArtists] = useState([]);

<button
  onClick={() => {
    artists.push({
      id: nextId++,
      name: name,
    });
  }}
>
  Add
</button>;

⭕️ 新しい配列を生成
先ほどのスプレッド構文をうまく使って新しい配列を生成し、状態変数として set しています。
再レンダリングも正しく行われます。

<button
  onClick={() => {
    setArtists([...artists, { id: nextId++, name: name }]);
  }}
>
  Add
</button>
  1. 要素の削除

⭕️ filter を使って新しい配列を生成

<button onClick={() => {
  setArtists(
    artists.filter(a =>
      a.id !== artist.id
    )
  );
}}>
  1. 配列の中身を変える

⭕️ map を使って新しい配列を生成

let initialShapes = [
  { id: 0, type: 'circle', x: 50, y: 100 },
  { id: 1, type: 'square', x: 150, y: 100 },
  { id: 2, type: 'circle', x: 250, y: 100 },
];

...

function handleClick() {
  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,
      };
    }
  });
  // Re-render with the new array
  setShapes(nextShapes);
}
  1. n 番目の要素の中身を変える

⭕️ map を使って新しい配列を生成
クリックした要素のインデックスを取得し、そのインデックスと一致する時だけ異なる処理を行います。

function handleIncrementClick(index) {
  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);
}
  1. 配列の途中に要素を追加する

⭕️ slice を使う

function handleClick() {
  const insertAt = 1; // Could be any index
  const nextArtists = [
    // Items before the insertion point:
    ...artists.slice(0, insertAt),
    // New item:
    { id: nextId++, name: name },
    // Items after the insertion point:
    ...artists.slice(insertAt),
  ];
  setArtists(nextArtists);
  setName("");
}
  1. 逆順やソートを行う

⭕️reverse()sort()する時には新しい配列を作成し、その配列に対して行います。

function handleClick() {
  const nextList = [...list];
  nextList.reverse();
  setList(nextList);
}

複数の state を管理する方法

https://react.dev/learn/choosing-the-state-structure
以下のようなことを意識して state を構造化・管理するといいとのことです。

  1. 関係する複数の state はまとめる
    例えば 1 つの操作に対して複数の state を更新するときは、1 つの state にまとめられます。
const [x, setX] = useState(0);
const [y, setY] = useState(0);

// ↑を↓にまとめられる
const [position, setPosition] = useState({ x: 0, y: 0 });
  1. state 同士の矛盾を作らない
    複数の State が必ず起こり得ない状態を作っている場合は見直すようにします。
// この2つが同時にtrueになることはない
const [isSending, setIsSending] = useState(false);
const [isSent, setIsSent] = useState(false);

// リファクタリング後
const [status, setStatus] = useState("typing");

const isSending = status === "sending";
const isSent = status === "sent";
  1. state を冗長的に増やさない
    例えば、以下のような例を考えます。
function App() {
  const [text, setText] = useState("");

  const handleChange = (event) => {
    setText(event.target.value);
  };

  const textLength = text.length;

  return (
    <div>
      <input type="text" value={text} onChange={handleChange} />
      <p>You have entered {textLength} characters.</p>
    </div>
  );
}

textLengthは、state として管理されているtextから計算されたものであり、State として管理する必要はないものです。
もし、このtextLengthをも state として管理しているならば、それは冗長的と言えます。

  1. 同一の変数を複数の state で管理しない
    複数で管理すると同期させるのが難しくなってしまいます。
// itemsの中にselectedItemが必ず含まれているので重複している
// itemsだけを更新してselectedItemを更新し忘れるとミスが発生してしまう
const [items, setItems] = useState(initialItems);
const [selectedItem, setSelectedItem] = useState(items[0]);

// リファクタリング
// Idをstateとして管理することで重複をなくせる
const [items, setItems] = useState(initialItems);
const [selectedId, setSelectedId] = useState(0);

const selectedItem = items.find((item) => item.id === selectedId);
  1. 深いネスト状態を持つ state を作らない
    可能な限り浅くて平らな state にします。そうすることで state の更新など管理がしやすくなります。
// 3階層の深いネストのオブジェクト
export const initialTravelPlan = {
  id: 0,
  title: '(Root)',
  childPlaces: [{
    id: 1,
    title: 'Earth',
    childPlaces: [{
      id: 2,
      title: 'Africa',
      childPlaces: [{
        id: 3,
        title: 'Botswana',
        childPlaces: []
      }, {
        id: 4,
        title: 'Egypt',
        childPlaces: []
      },
...
}

// リファクタリング
// idで結びつける
export const initialTravelPlan = {
  0: {
    id: 0,
    title: '(Root)',
    childIds: [1, 43, 47],
  },
  1: {
    id: 1,
    title: 'Earth',
    childIds: [2, 10, 19, 27, 35]
  },
  2: {
    id: 2,
    title: 'Africa',
    childIds: [3, 4, 5, 6 , 7, 8, 9]
  },
...

🔆 親から受け継いだ Props をそのまま使うときは子コンポーネントの state に詰め替えなくて良い
例えば以下のような例です。

function Message({ messageColor }) {
  // NG
  const [color, setColor] = useState(messageColor);

 // OK
 const color = messageColor;

ただし、子コンポーネントで親の Props を上書きしたい場合は State を使うのが正しいです。

state と DOM ツリーの関係

https://react.dev/learn/preserving-and-resetting-state

React では、JSX によって作られたコンポーネントから UI ツリー(ReactDOM)が作られ、その UI ツリーを元にブラウザの DOM を書き換えが行われています。

例えば、今 2 つの Counter コンポーネントがあるとき、一方の State を更新すると片方はどうなるでしょうか?
一方だけ score state を変えるとどうなるでしょうか?もう片方も連動されて変わってしまうでしょうか?

import { useState } from "react";

export default function App() {
  const [showB, setShowB] = useState(true);
  return (
    <div>
      // それぞれのCounterコンポーネントはそれぞれのscore Stateを持つ
      <Counter />
      <Counter />}
      <label>
        <input
          type="checkbox"
          checked={showB}
          onChange={(e) => {
            setShowB(e.target.checked);
          }}
        />
        Render the second counter
      </label>
    </div>
  );
}

function Counter() {
  const [score, setScore] = useState(0);
  const [hover, setHover] = useState(false);

  let className = "counter";
  if (hover) {
    className += " hover";
  }

  return (
    <div
      className={className}
      onPointerEnter={() => setHover(true)}
      onPointerLeave={() => setHover(false)}
    >
      <h1>{score}</h1>
      <button onClick={() => setScore(score + 1)}>Add one</button>
    </div>
  );
}

もちろん、同期せずに score を変えた方だけが変わります。
これは React において、同じコンポーネントでも UI ツリーの場所がそれぞれ異なるため同期せずに state を変えることができます。以下の画像のようなイメージです。
state更新の仕組み
参照:https://react.dev/learn/preserving-and-resetting-state#state-is-tied-to-a-position-in-the-tree

この、「JSX 内ではなくて UI ツリーの位置を考えること」が React を理解する上で重要です。

Remember that it’s the position in the UI tree—not in the JSX markup—that matters to React!

そして、同じ場所にある同じコンポーネントであれば当然 state の中身も保持されます。
同じ場所にある同じコンポーネント
参照:https://react.dev/learn/preserving-and-resetting-state#same-component-at-the-same-position-preserves-state

この図のように、App 内の同じ位置にある Counter というコンポーネントが持つ state の中身(今回の場合 isFancy)が変わったところで Counter の UI ツリー上の位置は変化していないため、count state である 3 は保たれたままです。

一方で、同じ場所に居続けても異なるコンポーネントに変化した場合は State を保つことはできません。以下の画像から詳しく見ていきます。
同じ場所にある異なるコンポーネント
参照:https://react.dev/learn/preserving-and-resetting-state#different-components-at-the-same-position-reset-state

例えば、div の中にある Counter コンポーネントが p タグのコンポーネントに置き換わったら、Counter コンポーネントで保持していた count state(=3)はどうなるでしょうか?
もちろん、リセットされてしまいます(=0 になる)。
同じ場所にあっても異なるコンポーネントに変わってしまった場合は、元あったコンポーネントは UI ツリーから削除されてしまうため、同時に state も削除されて保持できなくなります。

最後に、同じ場所にある同じコンポーネントで state をリセットしたい場合はどうすればいいでしょうか?

修正前のコンポーネントとして以下のようなものを考えます。
if 文で出し分けを行っていることにより、同じ場所で同じコンポーネントが作られています。

{
  isPlayerA ? <Counter person="Taylor" /> : <Counter person="Sarah" />;
}

修正方法 1:同じコンポーネントを異なる場所に配置する
if 文で出し分けをするのではなくそれぞれを独立させることで異なる場所にコンポーネントを配置します。

{
  isPlayerA && <Counter person="Taylor" />;
}
{
  !isPlayerA && <Counter person="Sarah" />;
}

これで、今までやってきた原理の通り、同じコンポーネントでもそれぞれが存在する UI ツリーの場所が異なるため state は保持されずリセットできるようになりました。

ただ、この方法は簡単ですが、少ないコンポーネントの場合に限ります。今回では 2 つのコンポーネントのみだったので簡単でしたが、沢山のコンポーネントを出しわけする場合は面倒な方法になってしまうので判別が必要です。

方法 2:Key を使う
リストを map や filter するときに使った Key はここでも有効です。出し分けするコンポーネントが多い場合はこちらの方法の方が簡単かも知れません。

React ではデフォルトで、コンポーネントの識別は親から見て何番目に位置するかで決まっています。しかし、コンポーネントに対して唯一の Key を与えることにより、順番ではなく、それを固有のものとして識別が可能になります。

{
  isPlayerA ? (
    <Counter key="Taylor" person="Taylor" />
  ) : (
    <Counter key="Sarah" person="Sarah" />
  );
}

このとき、state が共有されることはなく、一方の Counter コンポーネントが削除された場合そのコンポーネントで管理していた state も当然リセットされることになります。

useReducer を使う

https://react.dev/learn/extracting-state-logic-into-a-reducer

複数のイベントハンドラーによって同じ 1 つの state を更新する状態を考えます。イベントハンドラが増えるほど管理は複雑になってしまい大変です。
例えば以下のようなものです。

const [tasks, setTasks] = useState(initialTasks);

function handleAddTask(text) {
  setTasks([
    ...tasks,
    {
      id: nextId++,
      text: text,
      done: false,
    },
  ]);
}

function handleChangeTask(task) {
  setTasks(
    tasks.map((t) => {
      if (t.id === task.id) {
        return task;
      } else {
        return t;
      }
    })
  );
}

function handleDeleteTask(taskId) {
  setTasks(tasks.filter((t) => t.id !== taskId));
}

このように、複数のイベントハンドラーで 1 つの state を管理するとなると複雑化し、これ以上にイベントハンドラが増えていくとより管理が大変になってしまいます。

そこで、Reducer の登場です。

まず、setTasks している箇所をdispatchに置き換えます。そして、dispatch の中に”action object”を作成します。この時、typeプロパティによって「何が起きたときに〜する」が分かりやすくなるようにします。

function handleAddTask(text) {
  // dispatchの中に”action object”を作成する
  dispatch({
    type: "added",
    id: nextId++,
    text: text,
  });
}

function handleChangeTask(task) {
  // dispatchの中に”action object”を作成する
  dispatch({
    type: "changed",
    task: task,
  });
}

function handleDeleteTask(taskId) {
  // dispatchの中に”action object”を作成する
  dispatch({
    type: "deleted",
    id: taskId,
  });
}

次に、Reducer 関数を定義します。
それぞれの action type によって何を返り値とするかを出し分けできるようにしています。戻り値は新しい state を返すようにします。

// 第1引数;tasks state
// 第2引数: action オブジェクト(dispatchで定義した中身)
function tasksReducer(tasks, action) {
  switch (action.type) {
    case "added": {
      return [
        ...tasks,
        {
          id: action.id,
          text: action.text,
          done: false,
        },
      ];
    }
    case "changed": {
      return tasks.map((t) => {
        if (t.id === action.task.id) {
          return action.task;
        } else {
          return t;
        }
      });
    }
    case "deleted": {
      return tasks.filter((t) => t.id !== action.id);
    }
    default: {
      throw Error("Unknown action: " + action.type);
    }
  }
}

最後にuseReducerを使って先ほど作った Reducer 関数を埋め込みます。

// importの追加
+ import {useReducer} from 'react';

- const [tasks, setTasks] = useState(initialTasks);
// useReducerに書き換え
+ const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);

これで読みやすくなり、管理が容易になりました!

一般的に useReducer は、useState で管理すると複雑になる場合に有効なので、どちらの方がより有効かを都度判断して使い分ける必要があります。
https://react.dev/learn/extracting-state-logic-into-a-reducer#comparing-usestate-and-usereducer

UseReducer を使う時の注意

  • Reducer 関数は必ず純粋に保つ
    状態変数を更新する関数と同じように、純粋に保つ必要があります。同じ入力に対してからなず同じ結果が出力されるように、immutable にしなくてはいけません。理由は、Reducer 関数はレンダリング中に実行されるからです(状態変数の更新と同じ理由)。
  • Reducer 関数は必ず 1 つのユーザーの操作に対して実行されるように設計する
    例えば、Reset ボタンを押したときに全フォーム要素の中身を空にするようにしたい場合、全フォームそれぞれに対して関数が実行されるようにするのではなく、Reset ボタンを押したという 1 つのユーザー操作に対して関数が実行されるようにするべきです。こちらの方がより簡潔で、分かりやすくデバッグもしやすくなります。

useContext を使う

props を深くネストされたツリー同士でやりとりする場合や、複数のコンポーネント間で props を共有する場合、ただ props を渡すだけでは管理が大変になります。

そのときに有効なのがuseContextです。

例えば、以下のような 1 つの Section コンポーネントに属する Heading コンポーネントにそのネストレベルが props として渡されているコンポーネントを考えます。

<Section>
  <Heading level={3}>About</Heading>
  <Heading level={3}>Photos</Heading>
  <Heading level={3}>Videos</Heading>
</Section>

これをリフトアップすることで、同じ Section の全ての Heading に必ず同じ level を割り当てることができます。

<Section level={3}>
  <Heading>About</Heading>
  <Heading>Photos</Heading>
  <Heading>Videos</Heading>
</Section>

ただし、この状態では Heading コンポーネントは今自分がどのレベルにいるのかを知ることが出来ません。なので、一番近い親の Section が持つ level prop を知ることができるようにする何らかの処理が必要になります。

これは prop だけでは不可能であるため、useContextを使います。

まず、コンテキストを作成します。

LevelContext.js
import { createContext } from 'react';

// createContextの引数にはdefaultの値を入れる
export const LevelContext = createContext(1);

次にコンテキストを使う処理を書きます。

Heading コンポーネントの props から level を取り除いて、関数の中でuseContextを使います。

import { useContext } from "react";
import { LevelContext } from "./LevelContext.js";

// level propを削除し、useContextを使う
export default function Heading({ children }) {
  const level = useContext(LevelContext);
  // ...
}

これで Heading コンポーネントが level を受け取れる準備は整いました。

最後に、Section コンポーネントの props に level prop を追加し、Provider を関数の中身に追加します。

import { LevelContext } from "./LevelContext.js";

export default function Section({ level, children }) {
  return (
    <section className="section">
      <LevelContext.Provider value={level}>{children}</LevelContext.Provider>
    </section>
  );
}

この Provider により Section コンポーネントの中にある全てのコンポーネントは level を受け取れるようになります。
そして、その受け取った level は必ず UI ツリー上で最も近い Section コンポーネントが持つ値になります。

また、このやり方の場合 Section コンポーネントで level prop を受け取っていますが、Section コンポーネントないで useContext を使って level を取得してしまえば prop の受け渡しは必要ありません。
これを踏まえてリファクタリングします。

import { useContext } from "react";
import { LevelContext } from "./LevelContext.js";

export default function Section({ children }) {
  // 現在のlevelを取得し、Providerで新しいSectionコンポーネントを作るときにlevel+1したものを渡す
+  const level = useContext(LevelContext);
  return (
    <section className="section">
+     <LevelContext.Provider value={level + 1}>
-     <LevelContext.Provider value={level}>
        {children}
      </LevelContext.Provider>
    </section>
  );
}

このように、異なる値を持つ Provider で子コンポーネントをラップして囲むことで、UI ツリーからの level 値を上書きすることが出来ます。

そして、異なる Context であればお互いに影響を受けずに利用することが出来ます。つまり、作った Context の分だけそれぞれを別々に使うことが出来ます。

Reducer と Context を併用する

Reducer と Context を使うことでよりスッキリ分かりやすく処理をまとめることができます。

例えば、以下は Task について Reducer と Context の処理をまとめたものです。

import { createContext, useContext, useReducer } from "react";

// Task用のContextを作成
const TasksContext = createContext(null);
const TasksDispatchContext = createContext(null);

export function TasksProvider({ children }) {
  const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);

  return (
    <TasksContext.Provider value={tasks}>
      <TasksDispatchContext.Provider value={dispatch}>
        {children}
      </TasksDispatchContext.Provider>
    </TasksContext.Provider>
  );
}

export function useTasks() {
  return useContext(TasksContext);
}

export function useTasksDispatch() {
  return useContext(TasksDispatchContext);
}

function tasksReducer(tasks, action) {
  switch (action.type) {
    case "added": {
      return [
        ...tasks,
        {
          id: action.id,
          text: action.text,
          done: false,
        },
      ];
    }
    case "changed": {
      return tasks.map((t) => {
        if (t.id === action.task.id) {
          return action.task;
        } else {
          return t;
        }
      });
    }
    case "deleted": {
      return tasks.filter((t) => t.id !== action.id);
    }
    default: {
      throw Error("Unknown action: " + action.type);
    }
  }
}

const initialTasks = [
  { id: 0, text: "Philosopher’s Path", done: true },
  { id: 1, text: "Visit the temple", done: false },
  { id: 2, text: "Drink matcha", done: false },
];

別々のファイルで Reducer と Context を扱うより、1 つのファイルにまとめた方がより分かりやすく扱いやすいです。

終わりに

今回react.devLEARN REACTを参考に、React の思想や基本についてまとめました。
useEffectなどについて書かれているEscape Hatches部分については後でまとめたいなと考えています。

また、この記事で間違っていることや修正すべきところ、意見などがありましたらコメントで教授していただけると幸いです!

GitHubで編集を提案

Discussion