4
2

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.

React + TypeScript: Immerで状態をイミュータブルに保つ

Last updated at Posted at 2022-08-16

Immerはデータ構造をイミュータブル(不変)に保つためのライプラリです。React公式ドキュメントの作例でもたびたび使われています(「オブジェクトをイミュータブルに保つ ー Immerを使う」など)。また、2019年にはつぎのような賞を獲得しました。

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

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

なお、ReactにかぎらないImmerの基本的な使い方と構文については、「JavaScript + Immer: データ構造をイミュータブルに保つ ー 使うのはイマでしょ!」をお読みください。

React + TypeScriptによる簡単なTodoリストのコード例

useStateフックが内部的にもっている状態は、イミュータブル(不変)として扱われるのが前提です。まずは、React + TypeScriptで簡単な(手抜きの)Todoリストをコード例として書いてみます(まだImmerは使いません)。Todo項目をただ一覧表示するだけです。モジュールsrc/Todo.tsxは、チェックボックス(<input type="checkbox">)をクリックすると、親からプロパティで受け取ったonToggletodo.idを渡して呼び出します。

コード001■Todo項目の表示モジュール

src/Todo.tsx
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())に渡しています(「データを変更しないことの効果」参照)。

src/App.tsx
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)の値です。

src/App.tsx
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項目追加のモジュール

src/TodoInput.tsx
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項目を加えたリストの配列には、一旦スプレッド構文...を用いました。

src/App.tsx
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()関数に書き替えたのがつぎのコードです。

src/App.tsx
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■ルートモジュール

src/App.tsx
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の記述はつぎのように改めます。

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■ルートモジュール

src/App.tsx
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

フックuseReducerproduce()関数を使う

produce()関数はフックuseReducerにも使えます。書き替えるもとにするのは、useImmerを使う前のコード003のモジュールsrc/App.tsxにしましょう。useReducerの第1引数に渡すのが、product()関数の呼び出しです。状態の初期値(initialTodos)は第2引数として与えます。

src/App.tsx
// 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()関数を呼び出さなければなりません。

src/App.tsx
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■ルートモジュール

src/App.tsx
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()関数は、直接は使わなくなりました。

src/App.tsx
// 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■ルートモジュール

src/App.tsx
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

4
2
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
4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?