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

いきなりですがReactではstate、propsが更新されるとすべてのコンポーネントが再描写(Re-render)されます。再描写を行うということはなにかしらの処理を行うのでアプリケーションの動作の遅延につながる可能性があることはイメージしやすいかと思います。そのため再描写が必要ないのであれば再描写を止めたいものです。Reactでは不必要な再描写を行わせたくない場合にuseCallback, useMemo, React.memoを利用することができます。
再描写とは何か?まずは再描写は何を意味しているのか、またどのタイミングで再描写の処理が行われているのかを確認しながら、useCallback, useMemo, React.memoの機能を説明していきます。

memoはmemorizationで略で英語では記憶するという意味があります。ここで説明する機能は記憶というよりもキャッシュという言葉のほうが理解しやすいかもしれません。
React.memoではコンポーネント、useCallbackでは(コールバック)関数、useMemoでは関数による計算結果戻される値をキャッシュすることでコンポーネントの再描写を抑えることができます。ポイントはpropsで渡す値をuseCallback, useMemoを利用してキャッシュし、キャッシュした関数/値を利用することでpropsの更新を無くすことです。
目次
利用するコード
動作確認を行うためのコードが必要となるため、今回はシンプルなTodoアプリケーションのコードを利用します。App.jsを含めて、Todo.js, TodoList.jsの3つのコンポーネントを利用します。Todo.jsとTodoList.jsファイルはcomponentsフォルダに保存します。
各コンポーネントの処理の先頭にメッセージを表示させるためconsole.logを追加します。描写が行われるとconsole.logで設定したメッセージがコンソール上に表示されます。このメッセージを確認することで再描写されているかどうかを判断しています。
利用するコードは下記の通りです。少し長く感じるかもしれませんがシンプルなコードです。3つのオブジェクトが入ったTodoリストのtodosをmap関数で展開しています。またinput要素でtodosに新規のTodoを追加できるようにしています。
import {useState} from 'react'
import Todo from './components/Todo';
function App() {
console.log('App')
const [todo, setTodo] = useState('');
const [todos, setTodos] = useState([
{
todo: 'Learn vue.js',
isCompleted: false
},
{
todo: 'Learn React',
isCompleted: false
},
{
todo: 'Learn Laravel',
isCompleted: false
},
]);
const inputTodo = (e) => {
setTodo( e.target.value )
}
const addTodo = (e) => {
e.preventDefault()
setTodos(todos => [...todos,{todo:todo, isCompleted:false}])
setTodo('')
}
return (
<div style={{ margin: "2em"}}>
<form onSubmit={ addTodo }>
<input type="text" onChange={inputTodo} value={todo} />
</form>
<Todo todos={todos} />
</div>
);
}
export default App;
import TodoList from './TodoList'
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.todo }
</li>
)
}
export default TodoList
コード記述後にブラウザで確認すると以下の画面が表示されます。

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

App, Todo, TodoListがそれぞれ実行されなければブラウザ上にTodo一覧が表示されることはないので表示されるメッセージの数については疑問はないかと思います。
ここからはReactの再描写を知らない人にとっては驚きがあるかもしれません。
input要素に”L”と入力した瞬間にコンソールには下記のメッセージが表示されます。上から3行はページを開いた時のメッセージで先ほど確認したものです。4行目からのメッセージが”L”を入力した瞬間に追加表示されたメッセージです。

input要素に文字を追加しても文字を削除してもApp, Todo, TodoList 3回のメッセージ(3行)(計5行)が表示されます。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で包む
下記のように記述することも可能です。こちらの方がコンポーネントを包んでいるというのがわかりやすいかもしれません。
import { memo } from 'react';
import TodoList from './TodoList';
const Todo = memo(({ todos }) => {
console.log('Todo');
return (
<ul>
{todos.map((todo, index) => (
<TodoList todo={todo} key={index} />
))}
</ul>
);
});
export default Todo;
この状態でinput要素に文字”L”を入力してください。先ほどは文字を入力するとTodo, TodoListがコンソールに表示されていましたがmemoでコンポーネントを包むとApp以外のメッセージは表示されなくなります。ここでも最初の3行は表示直後のもので4行目AppのみがLを入力した時のメッセージです。このことからmemoの機能によりTodo, TodoListの再描写が抑えられていることを理解することができます。

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

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

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

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

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

完了機能の追加の前後で変更が行われたのはpropsであるcompleteTodoの追加です。completeTodoはAppが再描写される度に新しい関数を作成しているためApp.jsから渡されているpropsが更新されたことになり, Todo, TodoListの再描写が行われます。stateの更新だけではなくpropsが再描写に影響を与えることがわかります。
Appが再描写されてもcomplateTodoの新しい関数を作成させないためにuseCallbackを利用することができます。useCallbackでは新しい関数を作成する変わりに関数のインスタンスをキャッシュさせます。
useCallbackでは依存関係のある変数を設定することができるのでここではtodosを設定します。todosに変更が加わるとuseCallbackが実行され新しい関数が作成されます。
useCallbackは下記のように設定をおこないます。useCallbackを利用するためにはuseStataと同様にimportを行っておく必要があります。useCallbackはpropsを渡す親コンポーネントで設定を行います。React.memoはpropsを受け取る子コンポーネントで設定で行いました。
import {useState, useCallback} from 'react'
//略
const completeTodo = useCallback(index => {
console.log(index)
let newTodos = todos.map((todo,todoIndex) => {
if(todoIndex === index){
todo.isCompleted = !todo.isCompleted;
}
return todo;
})
setTodos(newTodos);
},[todos]);
ブラウザのコンソールで動作を確認してみましょう。useCallbackを設定することでinput要素に文字を入力してもTodo, TodoListの再描写が抑えらることがわかります。

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

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

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

ブラウザでアクセスするとnotCompleteTodosの処理が行われ、todosに3つのTodoが入っているのでfilter関数によりnotCompleteが3回表示されます。その後input要素に文字”L”を入力すると再描写によりnotCompleteTodosの処理が行われ、処理結果がtodosのpropsでTodoコンポーネントに渡されるのでTodoが再描写されます。既存のtodoの内容が更新されたわけではないのでTodoListの再描写は行われません。TodoListからmemoを外すとTodoListの再描写は行われます。
input要素への文字の入力ではnotCompletedTodosの値の更新は行われないのためnotCompoletedTodosの再計算を行う必要はありません。再計算を行わないように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要素に入力後にTodoを追加するとコンソール上のメッセージは下記の通りとなります。filterはfilter関数内で処理された回数なので一つTodoが増えたことによりfilterメッセージがTodo追加時に4回になっています。


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