📚

use-immer でネストした State を更新する

2024/08/14に公開

こんにちは。記事の連投記録更新中ののツーさんです 😁。State の管理といえば React の "useState" を使うのが一般的ですよね。もちろん私もよく使っています。

しかし、下記みたいなネストされた State を更新をする時ってちょっとめんどくさいと思ったことありませんか?

const [form, setForm] = useState<FormType>({
  name: "",
  address: {
    prefecture: "",
    city: "",
  },
  contact: {
    tel: "",
    email: "",
  },
});

そもそもネストすんなよ!とゆう話はおいといて、今回はこのようなネストされた State の更新を少し楽にしてくれる React 用のライブラリ "use-immer" をご紹介します。

記事内に掲載しているソースコードは Github でも確認できます。

https://github.com/twosun-8-git/immer.git

use-immer とは 🤔

use-immer は JavaScript の汎用ライブラリ「Immer」を React で使用するためのラッパーライブラリです。React の文脈で Immer の機能を簡単に使用できるようになります。主な特徴を簡潔にまとめてみました。

  • 複雑なデータ構造を扱う際に、直感的で読みやすいコードで記述することができる
  • 内部で効率的な更新を行い、不必要なオブジェクトの複製を避けることでパフォーマンスを最適化してくれる
  • 型安全な状態更新を行うことができる。

Immer → https://immerjs.github.io/immer/
use-immer → https://www.npmjs.com/package/use-immer

開発環境を作る 🛠️

では、開発環境を準備していきたいと思います。今回は Next を利用します。Node や Next などの詳しいバージョンは下記のようになっています。

Node や Next のバージョン

  • Node.js(18.20.4)
  • React(^18)
  • Next(14.2.5)
  • use-immer(^0.10.0)

では、まず create-next-app で Next アプリの雛形を作ります。今回はアプリ名を my-immer としました。

npx create-next-app

/ /下記のように設定
What is your project named?  my-immer
Would you like to use TypeScript?  Yes
Would you like to use ESLint?  Yes
Would you like to use Tailwind CSS?  No
Would you like to use `src/` directory?  Yes
Would you like to use App Router? (recommended)  Yes
Would you like to customize the default import alias (@/*)?  No

immeruse-imemr をインストールします。

npm install immer use-immer

これで準備ができました。
今回は下記のような簡単なフォームを作成していきます。
今回作るフォーム

なお、今回は CSS については react-hook-form と関係ないので解説はしません。CSS が必要な方は下記からコピペして利用してください。

CSS

use-immer を使わず useState でやってみる 🔍

まずは use-immer を使わず useState でやってみます。
フォームの送信処理は実際には行わずコンソールに表示するのみとします。

"use client";
import { useState } from "react";

type FormType = {
  name: string;
  address: {
    prefecture: string;
    city: string;
  };
  contact: {
    tel: string;
    email: string;
  };
};

export default function Page() {
  // 初期化
  const [form, setForm] = useState<FormType>({
    name: "",
    address: {
      prefecture: "",
      city: "",
    },
    contact: {
      tel: "",
      email: "",
    },
  });

  /** form.name 更新 */
  const handleChangeName = (e: React.ChangeEvent<HTMLInputElement>) => {
    setForm({
      ...form,
      [e.target.name]: e.target.value,
    });
  };

  /** form.address 更新 */
  const handleChangeAddress = (e: React.ChangeEvent<HTMLInputElement>) => {
    setForm({
      ...form,
      address: {
        ...form.address,
        [e.target.name]: e.target.value,
      },
    });
  };

  /** form.contact 更新 */
  const handleChangeContact = (e: React.ChangeEvent<HTMLInputElement>) => {
    setForm({
      ...form,
      contact: {
        ...form.contact,
        [e.target.name]: e.target.value,
      },
    });
  };

  /** form 送信 */
  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    console.table(form);
  };

  return (
    <main>
      <form className="form" onSubmit={handleSubmit}>
        <div className="form-group">
          <label>名前</label>
          <div>
            <input name="name" type="text" onChange={handleChangeName} />
          </div>
        </div>
        <fieldset className="fieldset">
          <legend>Address</legend>
          <div className="form-group">
            <label>都道府県</label>
            <div>
              <input
                name="prefecture"
                type="text"
                onChange={handleChangeAddress}
              />
            </div>
          </div>
          <div className="form-group">
            <label>市区町村</label>
            <div>
              <input name="city" type="text" onChange={handleChangeAddress} />
            </div>
          </div>
        </fieldset>
        <fieldset className="fieldset">
          <legend>Contact</legend>
          <div className="form-group">
            <label>電話番号</label>
            <div>
              <input name="tel" type="text" onChange={handleChangeContact} />
            </div>
          </div>
          <div className="form-group">
            <label>メールアドレス</label>
            <div>
              <input name="email" type="text" onChange={handleChangeContact} />
            </div>
          </div>
        </fieldset>
        <div className="button-group">
          <button type="submit">送信</button>
        </div>
      </form>
    </main>
  );
}

State の初期化

まずはフォームのデータを管理するため State を作成します。同時に初期値も設定しておきましょう。

// 初期化
const [form, setForm] = useState<FormType>({
  name: "",
  address: {
    prefecture: "",
    city: "",
  },
  contact: {
    tel: "",
    email: "",
  },
});

onChange と onSubmit 関数を追加

次にフォームが変更された時と送信された時の処理( handleChange, handleSubmit )を設定します。今回は、name, address, contact を更新するように onChange 関数を 3 つに分けて作成しました。

/** form.name 更新 */
const handleChangeName = (e: React.ChangeEvent<HTMLInputElement>) => {
  setForm({
    ...form,
    [e.target.name]: e.target.value,
  });
};

/** form.address 更新 */
const handleChangeAddress = (e: React.ChangeEvent<HTMLInputElement>) => {
  setForm({
    ...form,
    address: {
      ...form.address,
      [e.target.name]: e.target.value,
    },
  });
};

/** form.contact 更新 */
const handleChangeContact = (e: React.ChangeEvent<HTMLInputElement>) => {
  setForm({
    ...form,
    contact: {
      ...form.contact,
      [e.target.name]: e.target.value,
    },
  });
};

/** form 送信 */
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
  e.preventDefault();
  console.table(form);
};

今回の話の肝となるのが下記の部分です。

setForm({
  ...form,
  address: {
    ...form.address,
    [e.target.name]: e.target.value,
  },
});

...form を展開してさらに ...form.address...form.contact を展開して上書きの処理がちょっと冗長的に感じます。これを後に "use-immer" を利用してコードが短くなるようにしていきます。

ここまでできたら実際にフォームを操作して動作チェックしてみましょう。
npm run dev で開発用サーバーを起動しフォームに値を入力して送信までやってみましょう。

npm run dev


コンソールにデータを表示

問題なく動作していますね。
※HTML に関しては特筆すべき点はないので解説は省きます

use-immer で State を更新してみる 👍

ここから先ほどのフォームをもとに use-immer 用に修正していきます。
まずは useImmer をインポートし、次に type FormType の型を修正します。

"use client";
+ import { useImmer } from "use-immer"; // useImmerをインポート

+ /** ★印 は e.target.name が不明確の場合のため追加・修正 */
  type FormType = {
    name: string;
-   address: {
-     prefecture: string;
-     city: string;
-   };
-   contact: {
-     tel: string;
-     email: string;
-   };
+   address: {
+     [key: string]: string; // ★
+   };
+   contact: {
+     [key: string]: string; // ★
+   };
+   [key: string]: string | { [key: string]: string }; // ★
};

続いて、"useState" の部分を "useImmer" に変更して、name, address, contact を更新する onChange 関数を修正します。

export default function Page() {
-  const [form, setForm] = useState<FormType>({
+  const [form, setForm] = useImmer<FormType>({
    name: "",
    address: {
〜省略〜

  /** form.name 更新 */
  const handleChangeName = (e: React.ChangeEvent<HTMLInputElement>) => {
-    setForm({
-      ...form,
-      [e.target.name]: e.target.value,
-    });
+    /** immerを利用した場合 */
+   setForm((draft) => {
+     draft[e.target.name] = e.target.value;
+   });
  };

  /** form.address 更新(...form での展開不要) */
  const handleChangeAddress = (e: React.ChangeEvent<HTMLInputElement>) => {
-    setForm({
-      ...form,
-      address: {
-        ...form.address,
-        [e.target.name]: e.target.value,
-      },
-    });
+    /** immerを利用した場合 */
+    setForm((draft) => {
+      draft.address[e.target.name] = e.target.value;
+    });
  };

  /** form.contact 更新(...form での展開不要) */
  const handleChangeContact = (e: React.ChangeEvent<HTMLInputElement>) => {
-    setForm({
-      ...form,
-      contact: {
-        ...form.contact,
-        [e.target.name]: e.target.value,
-      },
-    });
+    /** immerを利用した場合 */
+    setForm((draft) => {
+      draft.contact[e.target.name] = e.target.value;
+    });
  };

修正点は以上になります。...form...form.address がなくなったのでかなり短くなったと思います。念のため npm run dev で動作チェックをしておきましょう。

npm run dev


use-immer を使った場合

先ほどと同様の動作をしていますね。

これで "use-immer" でのネストされた State の更新はできてるのですが、 name, address, contact と関数を個別に定義しているのが無駄に思えます。今度はこの処理を 1 つの関数で実行できるようにしたいと思います。

onChange 関数を共通化して 1 つにする 🐦

name, address, contact 用の onChange 関数を 1 つの共通関数で補えるようにしたいと思います。ただし、階層は form.adress, form.contact などの 2 階層までの前提です。

先に <input type="text">name 属性を下記のように変更します。

        <fieldset className="fieldset">
          <legend>Address</legend>
          <div className="form-group">
            <label>都道府県</label>
            <div>
              <input
-               name="prefecture"
+               name="address.prefecture"
                type="text"
                onChange={handleChange}
              />
            </div>
          </div>
          <div className="form-group">
            <label>市区町村</label>
            <div>
              <input
-               name="city"
+               name="address.city"
                type="text"
                onChange={handleChange}
              />
            </div>
          </div>
        </fieldset>
        <fieldset className="fieldset">
          <legend>Contact</legend>
          <div className="form-group">
            <label>電話番号</label>
            <div>
              <input
-               name="tel"
+               name="contact.tel"
                type="text"
                onChange={handleChange}
              />
            </div>
          </div>
          <div className="form-group">
            <label>メールアドレス</label>
            <div>
              <input
-               name="email"
+               name="contact.email"
                type="text"
                onChange={handleChange}
              />
            </div>
          </div>
        </fieldset>

そして onChange 関数は 1 つになるため handleChangeName, handleChangeAddress, handleChangeContact は削除しておきます。
新しく handleChange を作成し、この関数で form.adress, form.contact も更新できるようにします。


- handleChangeName, handleChangeAddress, handleChangeContact は削除

+ /** 共通のonChangeハンドラ */
+ const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+   const { name, value } = e.target;
+   const keys = name.split(".");
+
+  setForm((draft) => {
+    // トップレベルのプロパティの更新
+    if (keys.length === 1) {
+      draft[keys[0]] = value;
+    }
+    // ネストされたオブジェクトのプロパティの更新
+    else {
+      const [parentKey, childKey] = keys;
+
+      if (parentKey in draft && typeof draft[parentKey] === "object") {
+        draft[parentKey][childKey] = value;
+      }
+    }
+  });
+};

  /** form 送信 */
  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    〜 省略 〜

まず、namevalue を取得します。
name.split(".") で配列にして keys に格納しています。

const { name, value } = e.target;
const keys = name.split(".");

その後、keys.length"トップレベルのプロパティ""ネストされたオブジェクト" を判断し処理を分けています。

    // トップレベルのプロパティの更新( keys[0] = name )
    if (keys.length === 1) {
      draft[keys[0]] = value;
    }
    // ネストされたオブジェクトのプロパティの更新
    else {
      // parentKey: address | contact
      // childKey: prefecture | city | tel | email
      const [parentKey, childKey] = keys;

トップレベルのプロパティの箇所の説明は不要だと思うので割愛します。

ネストされたオブジェクトである場合は、配列 ( keys )の 0 番目を"parentKey"、1 番目を "childKey" に代入 しています。先ほど修正した name 属性も含めて表にすると下記のようになります。

name 属性 parentKey childKey
address.prefecture address prefecture
address.city address city
contact.tel contact tel
address.email contact email

そして下記の if文 にて "parentKey が draft に存在し、かつ object 型であること" を条件にし処理を実行しています。

if (parentKey in draft && typeof draft[parentKey] === "object") {
  draft[parentKey][childKey] = value;
}
/**
 * <input type="text" name="address.prefecture" value="東京"> の場合は
 * draft[parentKey][childKey] = value は下記のようになる
 * draft.address.prefecture = "東京"
 */

これで処理を共通化することができました。「2 階層 & name は "." で繋ぐこと」 などの多少の不自由さはありますが、似たような処理を何回も書かないでいいのは利点だと思います。

以上で、"use-immer でネストした State を更新する" の記事は終わりです。最後までお読みいただき、ありがとうございました。この記事が皆様のお役に立てば幸いです。

Discussion