Immerはデータ構造をイミュータブル(不変)に保つためのライプラリです。React公式ドキュメントでもおすすめされています(「Write concise update logic with Immer」参照)。また、2019年にはつぎのような賞を獲得しました。
- Breakthrough of the year ー React open source award
- The Most Impactful Contribution to the community ー JavaScript open source award
"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
:draftState
にrecipe
の変更を加えた新たな状態。
関数produce()
はもとの状態baseState
からつくったdraftState
をrecipe
に引数として渡します。baseState
はイミュータブルのまま、ミュータブルなdraftState
にrecipe
が変更を加え、nextState
として返されるのです。
recipe
の中では、すべての標準JavaScript APIを用いてdraftState
オブジェクトに操作が加えられます。たとえば、フィールドの代入や削除の操作、配列あるいはMap
、Set
の変更などです。push
、pop
、splice
、set
、sort
、remove
といった操作も用いることができます。イミュータブルな状態はそのまま保たれますので、参照を書き替えてしまうメソッドも使って構わないのです。
変更できるのは、データツリーのルートにかぎりません。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
*/
入れ子のデータ構造の変更
データをMap
やSet
で操作するときは、enableMapSet
をimport
して呼び出してください(「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で操作すればよいというのがお手軽です。