👩‍💻

#103 Immerを利用したイミュータブルな実装について

に公開

はじめに

前回記事でJavaScriptのシャローコピーとディープコピーそれぞれの特徴が確認できたので、前回取り上げられなかったImmerについて、本記事で確認していきたいと思います。
なお、Immerの紹介に入る前に、Reactを使用する上で重要となる「ミューテート」と「イミュータブル」の概念についても触れていきます。

ミュータブルとイミュータブル

Reactでは、stateに格納する配列は「書き換える」のではなく「置き換える」べきであるとしています。

JavaScript の配列はミュータブル(mutable, 書き換え可能)なものですが、state に格納する場合はイミュータブル(immutable, 書き換え不能)として扱うべきです。オブジェクトの時と同様に、state に保存された配列を更新する場合は、新しい配列を作成して(または既存の配列をコピーして)、その新しい配列で state をセットする必要があります。

引用:https://ja.react.dev/learn/updating-arrays-in-state


そのため、state内の配列に変更を加えたい場合は

  1. map()やfilter()などの、新しい配列を返すメソッドを使用する
  2. 最初に配列をコピーし、それに対してpush()やsort()など既存の配列を書き換えるメソッドを使用する
  3. Immerなどのライブラリを利用する

などの対応が望ましいとされています。
ではそもそも何故、state内の配列をイミュータブルとして扱う必要があるのでしょうか。

state内の配列をイミュータブルに扱いたい理由

ちなみに、Reactではすべてのstateをイミュータブルに扱うべきとしているため、配列に限った話ではありません。
以下からは配列を含むオブジェクトとしての視点からのお話になりますので、ご留意ください。

レンダー間でのstateの変化が追いやすい

イミュータブルな実装であれば、console.log()を使用したデバッグなどで、古いログの内容がそのあとのstateの変更によって上書きされることを考慮する必要がなくなります。

パフォーマンスの最適化

Reactでは、propsやstateが前後で同じ場合は作業をスキップさせることでパフォーマンスの最適化を図ります。
stateを書き換えなければ、preObject === objectの図式が成立するため、素早くそのstateに変更があったかどうかを判定することができます。

再レンダリングのトリガーとなる

再レンダリングのトリガーとするためには、前後のstateで差分が発生している必要があります。
stateの書き換えは上記でいうところの「前のstate」の値を変更するため、前後で差異が生まれず、再レンダリングが実行されません。

ネストが深いと冗長なコードになりやすい

イミュータブルを意識しながら実装していて気になるのは、ネストの深いオブジェクトを扱う時ではないでしょうか。


以下のようなオブジェクトがあったとします。

const drinkInfo = {
    tea: {
      unfermentedTea: {
        greenTea: [
          {
            id: 1,
            name: "shizuokaCha",
            productionArea: "shizuoka",
            stock: {
              unit: "pack",
              quantity: 4,
            },
          },
          {
            id: 2,
            name: "ujiCha",
            productionArea: "kyoto",
            stock: {
              unit: "box",
              quantity: 1,
            },
          },
          {
            id: 3,
            name: "sayamaCha",
            productionArea: "saitama",
            stock: {
              unit: "box",
              quantity: 2,
            },
          },
        ],
        processedTea: ["genmaicha", "hojicha"],
      },
      semiFermentedTea: ["oolongTea"],
      fermentedTea: ["blackTea"],
    },
    coffee: ["espresso", "american"],
  };

画面に設置したボタンを押下することで、以下のような挙動をさせたいとします。

  • UPDATEボタン
    - 上記オブジェクト(drinkInfo)内にある id: 2, name: "ujiCha"の quantity キーの値を「"5"」に変更する
  • GETボタン
    - コンソールに現在のstateの id: 2, name: "ujiCha"の quantity キーの値を出力する

GETボタン押下で実行される処理は以下の通りです。

  const getDrinkQuantityState = () => {
    console.log(drink.tea.unfermentedTea.greenTea[1].stock.quantity);
    // ※初期表示時にGETボタンを押下した場合、「"1"」をコンソールに出力
  };

イミュータブルな実装ができていない例

useStateとスプレッド構文を使用し、UPDATEボタン押下時の処理を以下のようにしてみました。

  1. stateを元にコピーオブジェクトを新しく作成
  2. 作成したコピーオブジェクトに対して該当のquantityの値を変更

こちらの実装におけるstateのquantityの値はどうなっているのか、確認してみましょう。

  const [drink, setDrink] = useState(drinkInfo);
  
  const updateDrinkQuantityState = () => {
    // 処理前の該当quantityの値を確認
    console.log(drink.tea.unfermentedTea.greenTea[1].stock.quantity);
    // ->"1"

    // ①stateを元にスプレッド構文でコピーオブジェクトを作成
    const drinkCopy = { ...drink };
    
    // ②該当quantityの値を変更
    drinkCopy.tea.unfermentedTea.greenTea[1].stock.quantity = "5";
    
    // コピーオブジェクトの該当quantityの値を確認
    console.log(drinkCopy.tea.unfermentedTea.greenTea[1].stock.quantity);
    // ->"5"

    // コピー元/先がメモリー内の同じオブジェクトを参照しているかを判定
    if (
      Object.is(
        drink.tea.unfermentedTea.greenTea[1].stock.quantity,
        drinkCopy.tea.unfermentedTea.greenTea[1].stock.quantity
      )
    ) {
      console.log("メモリー内の同じオブジェクトを参照");
      console.log(drink.tea.unfermentedTea.greenTea[1].stock.quantity);
      // ->"5"
      // ※コピー元であるdrinkの値も変更されてしまった
    }
        
    setDrink(drinkCopy);
  };

// UPDATEボタン->GETボタンの順に押下したときのgetDrinkQuantityStateの出力結果
// ->"5"

コピー元のオブジェクトであるdrink側でもquantityキーの値が変更されてしまいました。
前回記事でも取り上げたように、オブジェクトのコピーがシャローコピーによって行われているのが原因です。

useStateを使用したイミュータブルな実装の例

上記の例の反省を踏まえ、目的の階層までディープコピーをした実装でstateのquantityの値を確認してみましょう。

  const [drink, setDrink] = useState(drinkInfo);
  
  const updateDrinkQuantityState = () => {
    // 処理前の該当quantityの値を確認
    console.log(drink.tea.unfermentedTea.greenTea[1].stock.quantity);
    // ->"1"

    // stateを元にスプレッド構文でコピーオブジェクトを作成し、該当quantityの値を変更
    const drinkCopy = {
      ...drink,
      tea: {
        ...drink.tea,
        unfermentedTea: {
          ...drink.tea.unfermentedTea,
          greenTea: [
            { ...drink.tea.unfermentedTea.greenTea[0] },
            {
              ...drink.tea.unfermentedTea.greenTea[1],
              stock: {
                ...drink.tea.unfermentedTea.greenTea[1].stock,
                quantity: "5",
              },
            },
            { ...drink.tea.unfermentedTea.greenTea[2] },
          ],
        },
      },
    };
    
    // コピーオブジェクトの該当quantityの値を確認
    console.log(drinkCopy.tea.unfermentedTea.greenTea[1].stock.quantity);
    // ->"5"

    // メモリー内の同じオブジェクトを参照しているかを判定
    // ※条件に該当しないため、if文内の処理は実施されない
    if (
      Object.is(
        drink.tea.unfermentedTea.greenTea[1].stock.quantity,
        drinkCopy.tea.unfermentedTea.greenTea[1].stock.quantity
      )
    ) {
      console.log("メモリー内の同じオブジェクトを参照");
      console.log(drink.tea.unfermentedTea.greenTea[1].stock.quantity);
    }

    setDrink(drinkCopy);
  };

// UPDATEボタン->GETボタンの順に押下したときのgetDrinkQuantityStateの出力結果
// ->"5"

目的の箇所のネストが深ければ深い程、コピーしなければならないオブジェクトが増えているのが確認できました。


上記で行ったディープコピーの他、再帰的にシャローコピーする共通処理を作成したり、JSON.parse と JSON.stringifyを使ってディープコピーしたりする方法も選択肢として考えられますね。
ただし、不要な箇所までディープコピーしてしまったり、そもそもシリアライズができないオブジェクトだったりすることもあるかもしれないので、注意が必要です。
※あまりに冗長なコードとなってしまう場合は、ネストを浅くするためにフラットな構造にすることを検討してみても良いかと思います。

Immerとは

Reactの公式ドキュメントでも紹介されている、ミュータブルな状態での操作を使いやすくしてくれるライブラリです。
Reactで使用する場合は、ImmerをReactの文脈で使用するためのラッパーライブラリ「use-imeer」を使用します。

Immerを使った場合

では実際に、先ほどの実装をuse-immerを使用した内容を確認してみましょう

  const [drink, setDrink] = useImmer(drinkInfo);

  const updateDrinkQuantityState = () => {
    // 処理前の該当quantityの値を確認
    console.log(drink.tea.unfermentedTea.greenTea[1].stock.quantity);
    // ->"1"

    setDrink((draft) => {
      // メモリー内の同じオブジェクトを参照しているかを判定
      if (
        Object.is(
          drink.tea.unfermentedTea.greenTea[1].stock.quantity,
          draft.tea.unfermentedTea.greenTea[1].stock.quantity
        )
      ) {
        console.log("メモリー内の同じオブジェクトを参照");
        console.log(drink.tea.unfermentedTea.greenTea[1].stock.quantity);
        // ->"1"
        // コピー元のオブジェクトは変更されず、イミュータブルな状態が保たれる
      }

      // draft(コピーオブジェクト)の該当quantityの値を変更
      draft.tea.unfermentedTea.greenTea[1].stock.quantity = "5";

      // コピーオブジェクトの該当quantityの値を確認
      console.log(draft.tea.unfermentedTea.greenTea[1].stock.quantity);
      // ->"5"
    });
  };

// UPDATEボタン->GETボタンの順に押下したときのgetDrinkQuantityStateの出力結果
// ->"5"

use-immerは、useStateの代わりにuseImmerを使用するイメージで利用することができます。
ディープコピーの実装例と比べて、処理内容が直感的に理解しやすく、かつ冗長なコードになることが避けられました。

おわりに

今回はImmerについて取り上げると共に、Reactでイミュータブルな実装を意識する意図についても確認してみました。
use-immerを実際に使って検証してみて面白かったのは、メモリーとしてはコピー元と先のオブジェクトが同様である判定になる、という点です。
ただし、コピー先を変更してもコピー元には影響がなく、イミュータブルな状態での実装を可能としていることが理解でき、とても勉強になりました。



参考

Discussion