Reactの再描写(re-render)って何??useCallback, useMemo, memoと一緒に理解。

Reactではstate、propsが更新されるとすべてのコンポーネントが再描写されます。パフォーマンスを向上させるため不必要な再描写な行わせたくない場合にuseCallback, useMemo, memoを利用することができます。
再描写とは何か?またどのタイミングで再描写の処理が行われているのかを確認しながら、useCallback, useMemo,React.memoの機能を理解することができます。

memoはmemorizationで略で英語では記憶するという意味があります。React.memoではコンポーネント、useCallbackでは(コールバック)関数、useMemoでは値を記憶(キャッシュ)することでコンポーネントの再描写を抑えることができます。
目次
利用するコード
動作確認を行うためのコードが必要となります。今回は下記のTodoアプリケーションのコードを利用します。App.jsを含め、Todo.js, TodoList.jsの3つのコンポーネントを利用します。
各コンポーネントの先頭にconsole.logを追加し、描写のために実行されるとメッセージがコンソールに表示されます。このメッセージを確認することで再描写されているかどうかを判断しています。
少し長く感じるかもしれませんがシンプルなコードです。3つのオブジェクトが入ったTodoリストのtodosをmap関数で展開しています。またinput要素でtodosにタスクを追加できるようにしています。
import {useState} from 'react'
import Todo from './components/Todo';
function App() {
console.log('App')
const [task, setTask] = useState('');
const [todos, setTodo] = useState([
{
task: 'Learn vue.js',
isCompleted: false
},
{
task: 'Learn React',
isCompleted: false
},
{
task: 'Learn Laravel',
isCompleted: false
},
]);
const inputTask = (e) => {
setTask( e.target.value )
}
const addUser = (e) => {
e.preventDefault()
setTodo(todos => [...todos,{task:task, isCompleted:false}])
setTask('')
}
return (
<div style={{ margin: "2em"}}>
<form onSubmit={ addUser }>
<input type="text" onChange={inputTask} value={task} />
</form>
<Todo todos={todos} />
</div>
);
}
export default App;
import TodoList from './TodoList.js'
function Todo({todos}) {
console.log('Todo')
return (
<ul>
{todos.map((todo,index) =>
<TodoList todo={todo} key={index} />
)}
</ul>
)
}
export default Todo
function TodoList({todo}) {
console.log('TodoList')
return (
<li>
{ todo.task }
</li>
)
}
export default TodoList
コード記述後にブラウザで確認すると以下の画面が表示されます。

再描写(re-render)されるとは?
state, propsが更新されるとすべてのコンポーネントが再描写するということがどういうものなのかイメージが湧いていない人もいるかもしれません。ブラウザを眺めていても再描写しているかどうかわからないのでブラウザのデベロッパーツールを開いてコンソールを確認してみましょう。
ブラウザでアクセスするとApp, Todo, TodoListが3回表示されます。TodoListが3になっているのはtodoリストに3つのタスクオブジェクトが入っているため3回TodoListの処理が行われているためです。タスクの数によりこの数は変わります。

App, Todo, TodoListが実行されなければブラウザ上にタスク一覧が表示されることはないのでここまでについては理解できると思います。ここからReactの再描写を知らない人にとっては驚きがあるかもしれません。
stateを更新してもすべてのコンポーネントが再描写されるためinput要素に文字を入力すると先ほどと同じメッセージがコンソールに追加表示されます。ではinput要素に入力してみましょう。
input要素に”L”と入力した瞬間に下記のメッセージが表示されます。

input要素に文字を追加しても文字を削除してもApp, Todo, TodoList 3回のメッセージが表示されます。実際にコンソールのメッセージを目で見ることでstateを更新するとすべてのコンポーネントが再描写されるということを理解することができます。
input要素と関係ないTodo、TodoListまで再描写する必要なんてないと考えるのではないでしょうか。Reactでは再描写を抑える方法がいくつか準備されています。まずReact.memoを利用してTodo, TodoListの再描写を抑える方法を確認していきます。

React.memoを使って再描写を抑える
コンポーネントをReact.memoで包むことでpropsに変更がない場合に再描写を抑えることができます。Todo.jsのコードにReact.memoをimportし、Todoをmemoで包んでいます。
import {memo} from 'react'; //追加
import TodoList from './TodoList.js'
function Todo({todos}) {
console.log('Todo')
return (
<ul>
{todos.map((todo,index) =>
<TodoList todo={todo} key={index} />
)}
</ul>
)
}
export default memo(Todo) //memoで包む
この状態でinput要素に文字”L”を入力してください。先ほどは文字を入力するとTodo, TodoListがコンソールに表示されていましたがmemoでコンポーネントを包むことにより再描写が抑えられていることが確認できます。

Todoにmemoを行うとその中のコンポーネントであるTodoListの再描写もなくなります。
この状態でinputに入力した情報を”Enter”ボタンを押してtodoリストに追加します。ブラウザ上のTodoリストには新たに”Learn React.memo”のタスクが追加されています。

コンソールのメッセージは下記のように表示されます。

16というのは入力した文字数です。Enterボタンを押すとTodoコンポーネントに渡しているtodosの中身が変わるので(タスクが一つ追加)App, Todo, TodoListが再描写されます。3とは別に追加で表示されているTodoListは追加したタスクのメッセージです。
propsのtodosが更新されたのでTodoが再描写されるのは理解できますが既存のTodoListには追加のタスクの影響がないので再描写させたくない場合はTodoListをmemoで包みます。
import {memo} from 'react';
function TodoList({todo}) {
console.log('TodoList')
return (
<li>
{ todo.task }
</li>
)
}
export default memo(TodoList)
設定後にブラウザをリロードして再度”Learn React.memo”のタスクを追加します。

TodoListをmemoで包む前とは異なり、既存のTodoListの再描写が抑えられることが確認でき追加したタスクのメッセージであるTodoListが1つだけ表示されます。
ここまでの動作確認でmemoによりコンポーネントの再描写を抑えられることを理解することができました。
useCallbackを使って再描写を抑える
先ほどはmemoを利用することでpropsに影響がないコンポーネントの再描写を抑えることができました。次はuseCallbackを利用してコンポーネントの再描写を抑える方法を確認します。memoではコンポーネントで行っていましたが、useCallbackは名前の通り、コールバック関数に対して設定をおこないます。
タスク完了機能の追加
useCallbackを利用する前にタスクを未完了から完了にするための更新機能を追加します。配列のindexを利用し配列のindexと同じ番号を持つタスクのisCompletedプロパティをfalseからtrueに変更します。(trueであればfalseに)
const completeTask = index => {
console.log(index)
let newTodos = todos.map((todo,todoIndex) => {
if(todoIndex === index){
todo.isCompleted = !todo.isCompleted;
}
return todo;
})
setTodo(newTodos);
}
completeTask関数を子コンポーネントであるTodoListで実行させるため、propsを利用して子コンポーネントに渡します。
<div style={{ margin: "2em"}}>
<form onSubmit={ addUser }>
<input type="text" onChange={inputTask} value={task} />
</form>
<Todo todos={todos} completeTask={completeTask}/>
</div>
App.jsとTodoList.jsの間にもTodoコンポーネントがありますが、Todoコンポーネントではpropsで受け取ったcompleteTaskをそのままpropsでTodoListコンポーネントに渡します。indexもpropsで渡します。
import {memo} from 'react';
import TodoList from './TodoList.js'
function Todo({todos,completeTask}) {
console.log('Todo')
return (
<ul>
{todos.map((todo,index) =>
<TodoList
todo={todo}
completeTask={completeTask}
index={index}
key={index}
/>
)}
</ul>
)
}
export default memo(Todo)
TodoListコンポーネントでは、ボタンを追加し、onClickイベントでcompleteTaskを実行します。またstyleを使ってtodoのisCompletedプロパティがtrueの場合は横棒が文字の上に引かれるようにtextDecorationLineを設定しています。
import {memo} from 'react';
function TodoList({todo,completeTask,index}) {
console.log('TodoList')
return (
<li
style={ todo.isCompleted === true ? {textDecorationLine: 'line-through'}:{}}
>
{ todo.task }
<button onClick={() => completeTask(index)}>完了</button>
</li>
)
}
export default memo(TodoList)
ブラウザで確認すると”完了”ボタンをクリックするとボタンを押したタスク名の上に横棒が表示されます。

useCallbackの動作確認
コードはmemoを利用したものに完了機能を追加しただけなのでmemoは設定されたままです。
input要素に文字を入力すると完了機能の追加前まではAppのみが再描写されていましたが、App, Todo, TodoListが再描写されるようになっています。

完了機能の追加の前後で変更が行われたのはprops completeTaskの追加です。completeTaskはAppが再描写される度に新しい関数を作成しているためApp.jsから渡されているpropsが更新されたことになり、 Todo, TodoListの再描写が行われます。
Appが再描写されてもcomplateTaskが新しい関数を作成させないためにuseCallbackを利用することができます。useCallbackでは新しい関数を作成する変わりに関数のインスタンスをキャッシュさせます。
useCallbackでは依存関係のある変数を設定することができるのでここではtodosを設定します。todosに変更が加わるとuseCallbackが実行され新しい関すが作成されます。
useCallbackは下記のように設定をおこないます。useCallbackを利用するためにはimportを行っておく必要があります。配列で依存関係のあるtodosを設定しています。
import {useState, useCallback} from 'react'
//略
const completeTask = useCallback(index => {
console.log(index)
let newTodos = todos.map((todo,todoIndex) => {
if(todoIndex === index){
todo.isCompleted = !todo.isCompleted;
}
return todo;
})
setTodo(newTodos);
},[todos]);
useCallbackを設定するとinput要素に文字を入力してもTodo, TodoListの再描写が抑えられます。

タスクの入力を行うと依存関係に設定しているtodosが更新されているためuseCallback内の処理が行われます。React.memoの場合は既存のTodoListは再描写されていませんでしたが、TodoListはpropsで新しく作成されたcompleteTaskを受け取っているので再描写されます。

“Learn useCallback”のタスクを追加した場合にコンソールには下記が表示されます。

useCallbackの動作確認を通して関数はコンポーネントの再描写の度に新しい関数が作成されることがわかり、新しい関数の再作成を抑えるためにuseCallbackを利用できることがわかりました。
useMemoを使って再描写を抑える
ここまでの動作確認でmemoとuseCallbackを使うことで再描写を抑えることができることを確認しました。最後はuseMemoを利用してコンポーネントの再描写を抑える方法を確認します。memoではコンポーネントに対して設定を行い、useCallbackでは関数に対して設定を行っていましたがuseMemoでは値に対して設定をおこないます。
完了したタスクのみ取得
Todoコンポーネントには完了、未完了に関わらずすべてのタスクをTodoコンポーネントにpropsとして渡していましたが、新たにnotCompleteTodosを追加し、未完了のタスクの一覧のみTodoコンポーネントに渡します。notCompleteTodosが実行されるとコンソールにメッセージが表示されるように設定を行っています。
const notCompleteTodos = todos.filter(todo => {
console.log('notComplete')
return todo.isCompleted === false;
})
useMemoの動作確認
useMemoを設定する前の状態でinput要素に文字を入力するとどのようなメッセージが表示されるか確認します。

ブラウザでアクセスするとnotCompleteTodosの処理が行われ、todosに3つのタスクが入っているのでnotCompleteが3回表示されます。その後input要素に文字”L”を入力するとnotCompleteTodosの処理が行われ、処理結果がtodosのpropsでTodoコンポーネントに渡されるのでTodoが再描写されます。todosの内容が更新された(既存のタスクの情報に変更はない)だけなのでTodoListの再描写は行われません。
input要素への文字の入力ではnotCompletedTodosの値の更新は行われないのため再計算を行う必要はありません。useMemoを利用して再計算を行わないように設定します。
useCallbackと同様に依存関係のある変数を配列で指定します。ここでは配列にtodosを設定し、todosに変更があった場合のみuseMemo内の値の再計算をおこないます。
import {useState, useCallback, useMemo} from 'react'
//略
const notCompleteTodos = useMemo(() => todos.filter(todo => {
console.log('filter')
return todo.isCompleted === false
}),[todos])
設定後はinput要素に文字を入力してもnotCompletedTodosの値の再計算は行われないためTodoの再描写が行われることはありません。”Learn useMemo”と入力しているのでAppは13回再描写されています。

input要素に入力後にタスクを追加するとコンソール上のメッセージは下記の通りとなります。filterはfilter関数内で処理された回数なので一つタスクが増えたことによりfilterメッセージがタスク追加時に4回になっています。

memo, useCallback, useMemoと3つの方法で再描写を抑える方法を確認しました。またReactの再描写とはどのようなことなのかまたどのようなタイミングで再描写が行われているのかがわかりました。使い方が理解できたので次はどのような時にどの機能を利用するかを理解していく必要があります。