5
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

JavaScript + Immer: データ構造をイミュータブルに保つ ー 使うのはイマでしょ!

Posted at

Immerはデータ構造をイミュータブル(不変)に保つためのライプラリです。React公式ドキュメントでもおすすめされています(「Write concise update logic with Immer」参照)。また、2019年にはつぎのような賞を獲得しました。

"immer"はドイツ語で「いつも」(always)という意味です。発音は日本語の「今」に近い。ということで、見出しに親父ギャグをぶっこみました。すみません。

Immerはイミュータブルなデータ構造を用いるさまざまな場面で使えます。たとえば、Reactの状態管理はそのひとつです。オブジェクトへの参照が変わっていなければ、オブジェクトもそのまま変更ありません。さらに、複製のコストは比較的低いです。データツリーの中で変更されていない部分は複製されることなく、以前の状態とメモリ上共有されます。

イミュータブルなデータ構造により得られるのがこうした利点です。けれど、手作業でいちいち複製をつくるのは、扱いづらいコードになりやすく、漏れが生じるかもしれません。この課題を解決するのがImmerです。

インストール

ImmerはES5環境で動きます。インストールはつぎのとおりです。

npm install immer

YarnやCDNによるインストール、あるいはカスタマイズについては公式サイトの「Installation」をご参照ください。

produce()関数でつくったミュータブルな状態に変更を加える

Immerの主軸となる関数がproduce()です。イミュータブルな(変更しない)状態からつくったミュータブルな(変更できる)状態に変更を加えます。構文はつぎのとおりです。

produce(baseState, recipe: (draftState) => void): nextState
  • baseState: produceの第1引数で、もととなるイミュータブルな状態。
  • recipe: produceの第2引数となる関数。第1引数から得た状態をどう「変更」するか定める。
  • draftState: 第2引数の関数recipeが受け取る引数。もとの状態baseStateのプロキシで、安全に変更できる。
  • nextState: draftStaterecipeの変更を加えた新たな状態。

関数produce()はもとの状態baseStateからつくったdraftStaterecipeに引数として渡します。baseStateはイミュータブルのまま、ミュータブルなdraftStaterecipeが変更を加え、nextStateとして返されるのです。

recipeの中では、すべての標準JavaScript APIを用いてdraftStateオブジェクトに操作が加えられます。たとえば、フィールドの代入や削除の操作、配列あるいはMapSetの変更などです。pushpopsplicesetsortremoveといった操作も用いることができます。イミュータブルな状態はそのまま保たれますので、参照を書き替えてしまうメソッドも使って構わないのです。

変更できるのは、データツリーのルートにかぎりません。draftStateの中のどのような深い階層であっても、自由に手が加えられます。

draft.todos[0].tags['urgent'].author.age = 56

なお、recipe関数からの戻り値は、通常はありません。けれど、draftStateを別のオブジェクトに差し替えたい場合には、返すことができます。詳しくは、「Returning new data from producers」をご参照ください。

コード例

import produce from 'immer';

const initialTodos = [
	{ id: 0, title: 'Learn React', done: true },
	{ id: 1, title: 'Try immer', done: false }
];
const nextTodos = produce(initialTodos, (draft) => {
	draft.push({
		id: 2,
		title: 'Learn TypeScript',
		done: false
	});
	draft[1].done = true;
});

console.log()で確かめると、もとの配列(initialTodos)は変わらないまま、複製されたオブジェクトnextTodosが変更されています。しかも、手を加えなかった要素(インデックス0)は、オブジェクト間で共有されていることにご注目ください。

console.log("length:", initialTodos.length, nextTodos.length);  // length: 2 3
console.log("done:", initialTodos[1].done, nextTodos[1].done);  // done: false true
console.log("shared:", initialTodos[0] === nextTodos[0]);  // shared: true

カリー化したproduce()関数

簡単な関数のコード例で、produce()関数を使ってみましょう。

スプレッド構文...をImmerのproduce()関数に置き替える

標準JavaScriptで配列やオブジェクトを複製するときは、スプレッド構文がよく用いられます。

const todos = [
	{ id: 0, title: 'Learn React', done: true },
	{ id: 1, title: 'Try immer', done: false }
];
const toggleTodo = (todos, id) => {
	const newTodos = [...todos];  // 配列を複製する
	const todo = newTodos.find((todo) => todo.id === id);
	todo.done = !todo.done;
	return newTodos;
};
const nextTodos = toggleTodo(todos, 1);
console.log('nextTodos:', nextTodos[1]);  // nextTodos: {id: 1, title: "Try immer", done: true}

produce()関数で書き替えたのがつぎのコードです。

const toggleTodo = (todos, id) => {
	// const newTodos = [...todos];
	const newTodos = produce(todos, (draft) => {
		const todo = todos.find((todo) => todo.id === id);
		if (!todo) return;
		todo.done = !todo.done;
	});

};

produce()関数の第1引数に状態変数の関数を渡す

produce()関数の第1引数に関数を渡すと、状態変更の関数として定められて戻り値となります。関数を返すだけですので、実行はされません。戻り値の関数に対して、イミュータブルな状態を第1引数にして呼び出すと、関数による変更が加わったミュータブルな状態として返されるのです。このような構文は「カリー化」と呼ばれます。

前掲の例にこの構文を用いたのが、つぎのコードです。produce()には変更関数(recipe)のみ渡して、戻り値の関数を変数(toggleTodo)に収めます。そのうえで、第1引数にイミュータブルなオブジェクト(todos)を渡して呼び出せば、変更の加わった新たな状態(nextTodos)が得られるのです。コードもすっきりと短くなりました。

// const toggleTodo = (todos, id) => {
const toggleTodo = produce((draft, id) => {
	// const newTodos = produce(todos, (draft) => {
	const todo = draft.find((todo) => todo.id === id);
	if (!todo) return;
	todo.done = !todo.done;
});
const nextTodos = toggleTodo(todos, 1);
console.log('nextTodos:', nextTodos[1]);  // nextTodos: {id: 1, title: "Try immer", done: true}

さまざまな更新方法

標準JavaScriptだけでイミュータブルなデータ構造を扱おうとすると、データに応じて処理に工夫が必要でした。Immerを使えば、標準JavaScript APIの普通の操作でそれができてしまうのです。

オブジェクトの変更

import produce from 'immer';

const todosObj = {
	id01: { title: 'Learn React', done: true },
	id02: { title: 'Try immer', done: false }
};
// 追加
const addedTodosObj = produce(todosObj, (draft) => {
	draft['id03'] = {
		title: 'Learn TypeScript',
		done: false
	};
});
console.log('added:', addedTodosObj.id03); // added: {title: 'Learn TypeScript', done: false}
// 削除
const deletedTodosObj = produce(todosObj, (draft) => {
	delete draft['id01'];
});
console.log('deleted:', deletedTodosObj.id01); // deleted: undefined
// 更新
const updatedTodosObj = produce(todosObj, (draft) => {
	draft['id01'].done = false;
});
console.log('updated:', updatedTodosObj.id01); // updated: {title: 'Learn React', done: false}

配列の変更

import produce from 'immer';

const todosArray = [
	{ id: 0, title: 'Learn React', done: true },
	{ id: 1, title: 'Try immer', done: false }
];
// 追加
const addedTodosArray = produce(todosArray, (draft) => {
	draft.push({ id: 2, title: 'Learn TypeScript', done: false });
});
console.log('added:', addedTodosArray[2]); // added: {id: 2, title: 'Learn TypeScript', done: false}
// インデックスによる削除
const deletedTodosArray = produce(todosArray, (draft) => {
	draft.splice(1 /* インデックス */, 1);
});
console.log('deleted:', deletedTodosArray[1]); // deleted: undefined
// インデックスによる更新
const updatedTodosArray = produce(todosArray, (draft) => {
	draft[1].done = true;
});
console.log('updated:', updatedTodosArray[1]); // updated: {id: 1, title: 'Try immer', done: true}
// インデックスに挿入
const insertedTodosArray = produce(todosArray, (draft) => {
	draft.splice(2, 0, { id: 2, title: 'Learn TypeScript', done: false });
});
console.log('inserted:', insertedTodosArray[2]); // inserted: {id: 2, title: 'Learn TypeScript', done: false}
// 最後の要素を削除
const deletededLastTodosArray = produce(todosArray, (draft) => {
	draft.pop();
});
console.log(
	'deleted last:',
	deletededLastTodosArray[deletededLastTodosArray.length - 1]
); // deleted last: {id: 0, title: 'Learn React', done: true}
// 最初の要素を削除
const deletedFirstTodosArray = produce(todosArray, (draft) => {
	draft.shift();
});
console.log('deleted first:', deletedFirstTodosArray[0]); // deleted first: {id: 1, title: 'Try immer', done: false}
// 最初の要素として追加
const addedFirstTodosArray = produce(todosArray, (draft) => {
	draft.unshift({ id: 2, title: 'Learn TypeScript', done: false });
});
console.log('added first:', addedFirstTodosArray[0]); // added first: {id: 2, title: 'Learn TypeScript', done: false}
// インデックスによる削除
const deletedByIndexTodosArray = produce(todosArray, (draft) => {
	const index = draft.findIndex((todo) => todo.id === 0);
	if (index !== -1) draft.splice(index, 1);
});
console.log('deleted by index:', deletedByIndexTodosArray[0]); // deleted by index: {id: 1, title: 'Try immer', done: false}
// インデックスを確認して更新
const updatedByIndexTodosArray = produce(todosArray, (draft) => {
	const index = draft.findIndex((todo) => todo.id === 0);
	if (index !== -1) draft[index].done = false;
});
console.log('updated by index:', updatedByIndexTodosArray[0]); // updated by index: {id: 0, title: 'Learn React', done: false}
// 要素のフィルタリング
const filteredTodosArray = produce(todosArray, (draft) => {
	// Array.prototype.filter()メソッドは新たな配列を返す
	// そのため、この場合produce()を用いる必要はない
	// ただし、使うことが有効な場合もある
	return draft.filter((todo) => todo.done);
});
console.log('filtered:', filteredTodosArray);
/*
filtered: (1) [Object]
0: Object
	id: 0
	title: 'Learn React'
	done: true
*/

入れ子のデータ構造の変更

データをMapSetで操作するときは、enableMapSetimportして呼び出してください(「Pick your Immer version」参照)。

import produce, { enableMapSet } from 'immer';

enableMapSet();
const todoCollection = {
	index: new Map([
		[
			'01',
			{
				language: 'JavaScript',
				todos: [{ id: 0, title: 'Learn React', done: true }]
			}
		]
	])
};
// 更新
const updatedTodos = produce(todoCollection, (draft) => {
	draft.index.get('01').todos[0].done = false;
});
console.log('updated:', updatedTodos.index.get('01').todos);
/*
updated: (1) [Object]
0: Object
	id: 0
	title: 'Learn React'
	done: false
*/
// フィルタリング
const filteredTodos = produce(todoCollection, (draft) => {
	const selectedTodo = draft.index.get('01');
	const todos = selectedTodo.todos.filter((todo) => todo.done);
});
console.log('filtered:', filteredTodos.index.get('01').todos);
/*
filtered: (1) [Object]
0: Object
	id: 0
	title: 'Learn React'
	done: true
*/

配列に複数の要素を加えたいときは、メソッドによっては複数の引数で渡したり、スプレッド構文...が使えます。

todos.unshift(...items)

idで識別されるオブジェクトの配列を操作する場合には、上記コード例のようにMapやインデックスにもとづいて操作することがお勧めです。いちいち要素を検索するより、パフォーマンスは高まります。

公式ドキュメントの「Basics」をもとに、Immerの基本についてご説明しました。produce()関数の使い方を覚えれば、あとは標準のJavaScript APIで操作すればよいというのがお手軽です。

5
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?