Immerはデータ構造をイミュータブル(不変)に保つためのライプラリです。React公式ドキュメントの作例でもたびたび使われています(「オブジェクトをイミュータブルに保つ ー Immerを使う」など)。また、2019年にはつぎのような賞を獲得しました。
- Breakthrough of the year ー React open source award
- The Most Impactful Contribution to the community ー JavaScript open source award
Immerはイミュータブルなデータ構造を用いるさまざまな場面で使えます。たとえば、Reactの状態管理はそのひとつです。オブジェクトへの参照が変わっていなければ、オブジェクトもそのまま変更ありません。さらに、複製のコストは比較的低いです。データツリーの中で変更されていない部分は複製されることなく、以前の状態とメモリ上共有されます。
イミュータブルなデータ構造により得られるのがこうした利点です。けれど、手作業でいちいち複製をつくるのは、扱いづらいコードになりやすく、漏れが生じるかもしれません。この課題を解決するのがImmerです。
なお、ReactにかぎらないImmerの基本的な使い方と構文については、「JavaScript + Immer: データ構造をイミュータブルに保つ ー 使うのはイマでしょ!」をお読みください。
React + TypeScriptによる簡単なTodoリストのコード例
useState
フックが内部的にもっている状態は、イミュータブル(不変)として扱われるのが前提です。まずは、React + TypeScriptで簡単な(手抜きの)Todoリストをコード例として書いてみます(まだImmerは使いません)。Todo項目をただ一覧表示するだけです。モジュールsrc/Todo.tsx
は、チェックボックス(<input type="checkbox">
)をクリックすると、親からプロパティで受け取ったonToggle
にtodo.id
を渡して呼び出します。
コード001■Todo項目の表示モジュール
import { FC, memo } from 'react';
import type { TodoItem } from './App';
type Props = {
onToggle: (id: string) => void;
todo: TodoItem;
};
export const Todo: FC<Props> = memo(({ todo, onToggle }) => (
<li>
<input
type="checkbox"
checked={todo.done}
onChange={() => onToggle(todo.id)}
/>
{todo.title}
</li>
));
親モジュールsrc/App.tsx
は、まだImmerを使っていません。子のTodo
コンポーネントにプロパティ(onToggle
)として与えたhandleToggle()
は、状態変数(todos
)の値をスプレッド構文...
により複製して変更したうえで、設定関数(setTodos()
)に渡しています(「データを変更しないことの効果」参照)。
import React, { useCallback, useState } from 'react';
import { Todo } from './Todo';
export type TodoItem = {
id: string;
title: string;
done: boolean;
};
let nextId = 0;
const getNextId = () => String(nextId++);
const initialTodos: TodoItem[] = [
{ id: getNextId(), title: 'Learn React', done: true },
{ id: getNextId(), title: 'Try immer', done: false }
];
function App() {
const [todos, setTodos] = useState(initialTodos);
const handleToggle = useCallback(
(id: string) => {
const nextTodos = [...todos];
const todo = nextTodos.find((todo) => todo.id === id);
if (!todo) return;
todo.done = !todo.done;
setTodos(nextTodos);
},
[todos]
);
return (
<div className="App">
<ul>
{todos.map((todo) => (
<Todo todo={todo} key={todo.id} onToggle={handleToggle} />
))}
</ul>
</div>
);
}
export default App;
Immerで状態を管理する
状態はイミュータブルなまま、深い階層まで更新できるのがImmerです。つぎのようにインストールしますYarnやCDNによるインストール、あるいはカスタマイズについては公式サイトの「Installation」をご参照ください。
npm install immer
Immerの主軸となる関数がproduce()
です。イミュータブルな(変更しない)状態からつくったミュータブルな(変更できる)状態に変更を加えます。構文はつぎのとおりで、引数(recipe
)は状態をどう変更するか定める関数です。戻り値(mutationFunction
)も関数で、ミュータブルに変換した状態に変更を加えます(「カリー化したproduce()関数」参照)。
produce(recipe: (draftState) => void): mutationFunction
-
recipe
:produce
の引数となる関数。戻り値のmutationFunction
が引数に受け取った状態をどう「変更」するか定める。 -
mutationFunction
: 引数に受け取った状態をrecipe
にしたがって変更する。
mutationFunction(baseState): nextState
-
baseState
: 変更を加えるもととなるイミュータブルな状態。 -
nextState
: もとの状態baseState
のプロキシに、recipe
の変更を安全に加えた状態。
前項のモジュールsrc/App.tsx
の記述をproduce()
関数により書き替えたのがつぎのコードです。前掲のコードは設定関数setTodos()
に値(nextTodos
)を渡しているのに対して、produce()
の戻り値による「関数型の更新」になっていることにご注目ください。つまり、draft
として受け取るもとの参照は、直近の状態変数(todos
)の値です。
import produce from 'immer';
function App() {
const handleToggle = useCallback(
(id: string) => {
/* const nextTodos = [...todos];
const todo = nextTodos.find((todo) => todo.id === id);
if (!todo) return;
todo.done = !todo.done;
setTodos(nextTodos); */
const nextTodos = produce((draft) => {
const todo = draft.find((todo: TodoItem) => todo.id === id);
todo.done = !todo.done;
});
},
// [todos]
[]
);
}
Todo項目追加のコンポーネントを定める
テキスト入力フィールドとボタンが備わった、Todo項目追加のコンポーネントをつくりましょう。つぎのコード002が、アプリケーションに新たに加えるモジュールsrc/TodoInput.tsx
です。ボタンクリック(onClick
)のイベントハンドラhandleClick()
から呼び出す項目追加の関数handleAdd()
は、親コンポーネントからプロパティとして受け取ります。引数はテキストフィールドに入力された文字列(title
)です。
コード002■Todo項目追加のモジュール
import { FC, useCallback, useState } from 'react';
type Props = {
handleAdd: (title: string) => void;
};
export const TodoInput: FC<Props> = ({ handleAdd }) => {
const [title, setTitle] = useState('');
const handleClick = useCallback(() => {
handleAdd(title);
setTitle('');
}, [handleAdd, title]);
return (
<p>
<input
type="text"
value={title}
onChange={(event) => setTitle(event.target.value)}
/>
<button type="button" onClick={handleClick}>
Add Todo
</button>
</p>
);
};
親モジュールsrc/App.tsx
に定める項目追加の関数handleAdd()
は、つぎのコードのとおりです。子コンポーネントTodoInput
にプロパティ(handleAdd
)として加えます。設定関数(setTodos()
)に渡す引数のTodo項目を加えたリストの配列には、一旦スプレッド構文...
を用いました。
import { TodoInput } from "./TodoInput";
function App() {
const handleAdd = useCallback(
(todoTitle: string) => {
const title = todoTitle.trim();
if (!title) return;
const newTodo = {
id: getNextId(),
title,
done: false
};
setTodos([...todos, newTodo]);
},
[todos]
);
return (
<div className="App">
<TodoInput handleAdd={handleAdd} />
</div>
);
}
スプレッド構文...
をproduce()
関数に書き替えたのがつぎのコードです。
function App() {
const [todos, setTodos] = useState(initialTodos);
const handleAdd = useCallback(
(todoTitle: string) => {
// setTodos([...todos, newTodo]);
setTodos(produce((draft) => {
draft.push(newTodo)
}));
},
// [todos]
[]
);
}
書き上がったルートモジュールsrc/App.tsx
の記述をつぎのコード003にまとめました。以下のサンプル001がCodeSandboxに公開した作例です。各モジュールのコードや動きは、このサンプルでお確かめください。
コード003■ルートモジュール
import React, { useCallback, useState } from 'react';
import produce from 'immer';
import { Todo } from './Todo';
import { TodoInput } from './TodoInput';
import './styles.css';
export type TodoItem = {
id: string;
title: string;
done: boolean;
};
let nextId = 0;
const getNextId = () => String(nextId++);
const initialTodos: TodoItem[] = [
{ id: getNextId(), title: 'Learn React', done: true },
{ id: getNextId(), title: 'Try immer', done: false }
];
function App() {
const [todos, setTodos] = useState(initialTodos);
const handleAdd = useCallback((todoTitle: string) => {
const title = todoTitle.trim();
if (!title) return;
const newTodo = {
id: getNextId(),
title,
done: false
};
setTodos(
produce((draft) => {
draft.push(newTodo);
})
);
}, []);
const handleToggle = useCallback((id: string) => {
const nextTodos = produce((draft) => {
const todo = draft.find((todo: TodoItem) => todo.id === id);
todo.done = !todo.done;
});
setTodos(nextTodos);
}, []);
return (
<div className="App">
<TodoInput handleAdd={handleAdd} />
<ul>
{todos.map((todo) => (
<Todo todo={todo} key={todo.id} onToggle={handleToggle} />
))}
</ul>
</div>
);
}
export default App;
サンプル001■React + TypeScript: Management of immutable state with Immer 01
フックuseImmer
を使う
モジュールuse-immer
には、Reactで使いやすいようにフックuseImmer
が備わっています。produce()
関数は直接は用いません。
npm install use-immer
前掲コード003のモジュールsrc/App.tsxの記述はつぎのように改めます。
// import React, { useCallback, useState } from 'react';
import { useCallback } from 'react';
// import produce from 'immer';
import { useImmer } from 'use-immer';
function App() {
// const [todos, setTodos] = useState(initialTodos);
const [todos, setTodos] = useImmer(initialTodos);
const handleAdd = useCallback(
(todoTitle: string) => {
setTodos(
// produce((draft) => {
(draft) => {
draft.push(newTodo);
// })
}
);
// }, []);
},
[setTodos]
);
const handleToggle = useCallback(
(id: string) => {
/* const nextTodos = produce((draft) => {
const todo = draft.find((todo: TodoItem) => todo.id === id);
todo.done = !todo.done;
}); */
// setTodos(nextTodos);
setTodos((draft) => {
const todo = draft.find((todo: TodoItem) => todo.id === id);
if (todo) {
todo.done = !todo.done;
}
});
// }, []);
},
[setTodos]
);
}
ルートモジュールsrc/App.tsx
の記述全体は、つぎのコード004のとおりです。併せて、以下のサンプル002をCodeSandboxに掲げます。
コード004■ルートモジュール
import { useCallback } from 'react';
import { useImmer } from 'use-immer';
import { Todo } from './Todo';
import { TodoInput } from './TodoInput';
import './styles.css';
export type TodoItem = {
id: string;
title: string;
done: boolean;
};
let nextId = 0;
const getNextId = () => String(nextId++);
const initialTodos: TodoItem[] = [
{ id: getNextId(), title: 'Learn React', done: true },
{ id: getNextId(), title: 'Try immer', done: false }
];
function App() {
const [todos, setTodos] = useImmer(initialTodos);
const handleAdd = useCallback(
(todoTitle: string) => {
const title = todoTitle.trim();
if (!title) return;
const newTodo = {
id: getNextId(),
title,
done: false
};
setTodos((draft) => {
draft.push(newTodo);
});
},
[setTodos]
);
const handleToggle = useCallback(
(id: string) => {
setTodos((draft) => {
const todo = draft.find((todo: TodoItem) => todo.id === id);
if (todo) {
todo.done = !todo.done;
}
});
},
[setTodos]
);
return (
<div className="App">
<TodoInput handleAdd={handleAdd} />
<ul>
{todos.map((todo) => (
<Todo todo={todo} key={todo.id} onToggle={handleToggle} />
))}
</ul>
</div>
);
}
export default App;
サンプル002■React + TypeScript: Management of immutable state with Immer 02
フックuseReducer
にproduce()
関数を使う
produce()
関数はフックuseReducer
にも使えます。書き替えるもとにするのは、useImmer
を使う前のコード003のモジュールsrc/App.tsx
にしましょう。useReducer
の第1引数に渡すのが、product()
関数の呼び出しです。状態の初期値(initialTodos
)は第2引数として与えます。
// import React, { useCallback, useState } from 'react';
import React, { useCallback, useReducer } from 'react';
type ActionType = 'add' | 'toggle';
type Action = {
type: ActionType;
title?: string;
id?: string;
};
function App() {
// const [todos, setTodos] = useState(initialTodos);
const [todos, dispatch] = useReducer(
produce((draft, action: Action) => {
switch (action.type) {
case 'add':
const newTodo = {
id: getNextId(),
title: action.title,
done: false
};
draft.push(newTodo);
break;
case 'toggle':
const todo = draft.find((todo: TodoItem) => todo.id === action.id);
todo.done = !todo.done;
break;
default:
break;
}
}),
initialTodos
);
}
App
コンポーネントの関数は、設定関数(setTodos()
)でなく、dispatch()
関数を呼び出さなければなりません。
function App() {
const handleAdd = useCallback((todoTitle: string) => {
/* const newTodo = {
id: getNextId(),
title,
done: false
};
setTodos(
produce((draft) => {
draft.push(newTodo);
})
); */
dispatch({
type: 'add',
title
});
}, []);
const handleToggle = useCallback((id: string) => {
/* const nextTodos = produce((draft) => {
const todo = draft.find((todo: TodoItem) => todo.id === id);
todo.done = !todo.done;
});
setTodos(nextTodos); */
dispatch({ type: 'toggle', id });
}, []);
}
書き改めたルートモジュールsrc/App.tsx
の記述全体は、つぎのコード005のとおりです。CodeSandbox作例は以下のサンプル003に掲げました。
コード005■ルートモジュール
import React, { useCallback, useReducer } from 'react';
import produce from 'immer';
import { Todo } from './Todo';
import { TodoInput } from './TodoInput';
import './styles.css';
export type TodoItem = {
id: string;
title: string;
done: boolean;
};
type ActionType = 'add' | 'toggle';
type Action = {
type: ActionType;
title?: string;
id?: string;
};
let nextId = 0;
const getNextId = () => String(nextId++);
const initialTodos: TodoItem[] = [
{ id: getNextId(), title: 'Learn React', done: true },
{ id: getNextId(), title: 'Try immer', done: false }
];
function App() {
const [todos, dispatch] = useReducer(
produce((draft, action: Action) => {
switch (action.type) {
case 'add':
const newTodo = {
id: getNextId(),
title: action.title,
done: false
};
draft.push(newTodo);
break;
case 'toggle':
const todo = draft.find((todo: TodoItem) => todo.id === action.id);
todo.done = !todo.done;
break;
default:
break;
}
}),
initialTodos
);
const handleAdd = useCallback((todoTitle: string) => {
const title = todoTitle.trim();
if (!title) return;
dispatch({
type: 'add',
title
});
}, []);
const handleToggle = useCallback((id: string) => {
dispatch({ type: 'toggle', id });
}, []);
return (
<div className="App">
<TodoInput handleAdd={handleAdd} />
<ul>
{todos.map((todo: TodoItem) => (
<Todo todo={todo} key={todo.id} onToggle={handleToggle} />
))}
</ul>
</div>
);
}
export default App;
サンプル003■React + TypeScript: Management of immutable state with Immer 03
フックuseImmerReducer
を使う
すでにご紹介したuse-immer
モジュールには、フックuseImmerReducer
が備わっています。このフックを用いれば、前掲コード005はつぎのように少しだけ短く書けるのです。produce()
関数は、直接は使わなくなりました。
// import React, { useCallback, useReducer } from 'react';
import React, { useCallback } from 'react';
// import produce from 'immer'
import { useImmerReducer } from 'use-immer';
function App() {
// const [todos, dispatch] = useReducer(
const [todos, dispatch] = useImmerReducer(
// produce((draft, action: Action) => {
(draft, action: Action) => {
}
// }),
},
);
const handleAdd = useCallback(
(todoTitle: string) => {
// }, []);
},
[dispatch]
);
const handleToggle = useCallback(
(id: string) => {
// }, []);
},
[dispatch]
);
}
修正箇所は少ないものの、ルートモジュールsrc/App.tsx
の記述全体をつぎのコード006にまとめておきましょう。CodeSandbox作例は以下のサンプル004として公開しました。
コード006■ルートモジュール
import React, { useCallback } from 'react';
import { useImmerReducer } from 'use-immer';
import { Todo } from './Todo';
import { TodoInput } from './TodoInput';
import './styles.css';
export type TodoItem = {
id: string;
title: string;
done: boolean;
};
type ActionType = 'add' | 'toggle';
type Action = {
type: ActionType;
title?: string;
id?: string;
};
let nextId = 0;
const getNextId = () => String(nextId++);
const initialTodos: TodoItem[] = [
{ id: getNextId(), title: 'Learn React', done: true },
{ id: getNextId(), title: 'Try immer', done: false }
];
function App() {
const [todos, dispatch] = useImmerReducer((draft, action: Action) => {
switch (action.type) {
case 'add':
const newTodo = {
id: getNextId(),
title: action.title,
done: false
};
draft.push(newTodo);
break;
case 'toggle':
const todo = draft.find((todo: TodoItem) => todo.id === action.id);
todo.done = !todo.done;
break;
default:
break;
}
}, initialTodos);
const handleAdd = useCallback(
(todoTitle: string) => {
const title = todoTitle.trim();
if (!title) return;
dispatch({
type: 'add',
title
});
},
[dispatch]
);
const handleToggle = useCallback(
(id: string) => {
dispatch({ type: 'toggle', id });
},
[dispatch]
);
return (
<div className="App">
<TodoInput handleAdd={handleAdd} />
<ul>
{todos.map((todo: TodoItem) => (
<Todo todo={todo} key={todo.id} onToggle={handleToggle} />
))}
</ul>
</div>
);
}
export default App;
サンプル004■React + TypeScript: Management of immutable state with Immer 04