こんにちは、mk_1222です。
本日は前回記事の「【Recoil入門】ToDoアプリ作成を通したRecoil基礎学習」で作成したToDoアプリのRecoil部分をリファクタするとともにパフォーマンスを上げるためにどうするかについてご紹介させていただきます。
Recoilを用いたToDoアプリの問題点
前回作成したToDoアプリはRecoilの基礎を抑えるために重要な部分を切り落としたものになっていました。
仕様通りではありましたが、前回のToDoアプリには大きく2つの問題点があると思いました。
** ・ 複数人でRecoilを使うときに意図しない状態変更が行われる可能性**
** ・ 無駄な部分まで走る再レンダリング**
それぞれについて解説します。
複数人でRecoilを使うときに意図しない状態変更が行われる可能性
Recoilの状態管理の仕組みは以下のようになっています。
RecoilはReduxのように状態の加工から管理までをするのではなく、状態の管理のみを担当し、Hooks APIを使ってコンポーネント側から値を更新できるのが特徴だと思います。
とてもシンプルな仕組みで使いやすく強力な状態管理ライブラリですが、このシンプルさが規模が大きくなってきたときに弱みになる可能性があります。
今回のようなToDoアプリの場合は規模感としてはかなり小さいですし、使っているのも自分一人だけでした。これが大きなアプリになって、一人だけでなく、複数人で開発していく場合、Recoilは下記のような状態になると考えられます。
おそらく、実際に複数人で使うとなると図よりももっと複雑になると思います。
色々なコンポーネントから利用され、状態を更新されるため、Aさんが担当したコンポーネントで更新した状態はCさんが担当したコンポーネントでは意図した状態でない可能性があります。
これは後々、大きな問題点になると思われます。
無駄な部分まで走る再レンダリング
前回、作成したToDoアプリは一つのContainer、Presenterで構成されています。
なので、フォームに文字を入力したり、追加、削除、完了・未完了の切り替えボタンを押すたびに全てに再レンダリングがかかってしまいます。
色のついた枠は再レンダリングされているコンポーネントです。何か操作をするたびに全てがレンダリングされていることがわかると思います。
今の時点では表示するToDoリストの数が少ないためそれほど重たい処理に感じることはありませんが、リストが数百件単位になると、かなり重くなるだろうと思われます。
変化していない部分を再レンダリングするのは無駄ですし、パフォーマンス的に問題があると言えます。
解決方法
「複数人でRecoilを使うときに意図しない状態変更が行われる可能性」の解決方法
この問題は他のコンポーネントがAtom(状態)を好きに変更できることにより発生するものです。なので解決策としては単純ですが、Atomに対して行える操作を指定すれば良いです。
具体的にはカスタムフックを使用して、Atomを直接変更できないようにします。
変更も取得もカスタムフック越しに行うようにします。これで意図しない変更が行われる可能性は大きく下がると思います。
また、この問題点とはあまり関係ないのですが、一つのAtomに持たせる状態の大きさにも気をつけます。深いネストを持った状態を一つのAtomに持たせるのは変更がとても複雑になってしまいます。できる限り正規化を意識した方が良いです。
現状のToDoアプリは一つのAtomにToDoのオブジェクトを配列で持たせているので、これをAtom一つに対してToDo一つのように変更します。そうすれば、操作用カスタムフック内のコードがシンプルになります。
Reduxのスタイルガイドですが状態の正規化に関してはReduxスタイルガイドに書かれています。
「無駄な部分まで走る再レンダリング」の解決方法
現在は入力部、出力部を一つのコンポーネントで記述しているため、どこかで更新が起きると全てが再レンダリングされてしまいます。
まず、入力部と出力部を分ければ、お互いの影響で再レンダリングされることは無くなります。
ですが、最もパフォーマンス面に影響するのは出力部にあるToDoリストのレンダリングだと思います。
例えば、新しいToDoを追加したときに既存のToDoリスト内のToDoは何も変わらないのに全てを再レンダリングするのはあまりにも無駄です。削除や、完了・未完了の切り替えに関しても同じことが言えます。
また、ToDoを表示するコンポーネントを出力部の子コンポーネントとして新規作成し、分割をしても再レンダリングを防ぐことはできません。なぜなら、コンポーネントは以下の条件に当てはまったときに再レンダリングされるためです。
- Stateが更新されたか
- Propsが更新されたか
- 親コンポーネントが再レンダリングされたか
出力部のPropsが更新されて、再レンダリングされてしまうと子コンポーネントへのPropsに変更がなくても強制的に子コンポーネントは再レンダリングされます。
ですが、React.memoを使うことで、Propsが変更された場合にだけ再レンダリングするようにできます。
React.memoとは、下図のように送られてきたPropsが前回のものと比べて同じかチェックして再レンダリングするべきか判断してくれるようにするものです。
メモ化することで、追加などの操作をしても関係のないToDoが再レンダリングされることがなくなり、かなりパフォーマンスが上がることが期待できます。
では、これから実際に前回のToDoアプリのコードを編集し、問題点を解決していきます。
コード修正
TodoStateの修正
まずは、状態管理部分の修正をします。コンポーネント側では直接、Atom を変更できないようにし、カスタムフック越しに変更や取得ができるようにします。また、Atom一つにToDoリストを管理させるのではなく、Atom一つに対し、一つのToDoを管理させるようにします。
手順は以下のようになります。
1.Atomの正規化
2.変更系カスタムフックの作成
3.取得系カスタムフックの作成
Atomの正規化
現時点のToDoリストのAtomは以下のようになっています。
export const todosState = atom<Todo[]>({
key: AtomKeys.TODOS_STATE,
default: [
{
id: 1,
title: "テスト1",
content: "テスト1の内容",
isCompleted: false
},
{
id: 2,
title: "テスト2",
content: "テスト2の内容",
isCompleted: false
}
],
});
これを正規化していきます。ですが、ToDo一つに対し、Atom一つで管理するためにAtomをその数だけ作るのは非効率です。「atomFamily」を使います。
atomFamilyは同じ型の情報をもつatomの集合体のようなものです。atomFamilyにはパラメータを設定でき、パラメータにToDoのIDを指定することで特定の情報を抜き出すことが可能になります。
実際にtodosStateを次のように変更しましょう。第一引数にはatomの型を、第二引数には特定のatomを指定するための値が入ります。IDで指定するのでnumber型にします。
また、コンポーネント側から直接操作できないように「export」は消しておきます。
// todoState.ts
const todoState = atomFamily<Todo|null, number>({
key: AtomKeys.TODOS_STATE,
default: null
})
ToDoを複数管理することができましたが、IDの管理をしていないため、ある時点で最大のIDはいくつなのか、特定のIDは存在しているのかがわからなくなってしまいます。よって、次のAtomを加え、ToDoのIDを管理します。
//todoState.ts
const todoIdState = atom<number[]>({
key: AtomKeys.TODOID_STATE,
default: []
})
keyは「recoilKeys.ts」に新たに作成します。
//recoilKeys.ts
export const AtomKeys = {
"TODOS_STATE" : "todosState",
"TODOID_STATE" : "todoIdState",
}
正規化が完了しました。これで、IDを指定し、atomFamilyから特定のToDoを取り出せるようになりました。
変更系カスタムフックの作成
Atomを変更するためのカスタムフックを作成していきます。
変更系のカスタムフックを作成する目的はAtomに対して行える操作を特定の範囲内に抑えるためです。ToDoアプリならば、追加と削除、完了・未完了の切り替えのみの操作に抑えるべきです。
「TodoContainer.tsx」内に記述した変更処理とRecoilに関わるものを削除し、「todoState.ts」で下記のようにカスタムフックにします。
// todoState.ts
let id = 1;
const getId = () => {
return id++;
}
export const useTodoAction = () => {
/** 追加処理 */
const addTodo = useRecoilCallback(({ set }) => (title: string, content: string) => {
const newTodo: Todo = {
id : getId(),
title : title,
content : content,
isCompleted: false
}
set(todoIdState, prev => [...prev, newTodo.id]);
set(todosState(newTodo.id), newTodo);
}, [])
/** 削除処理 */
const removeTodo = useRecoilCallback(({ set, reset }) => (targetId : number) => {
set(todoIdState, prev => prev.filter(id => id !== targetId));
reset(todosState(targetId))
}, [])
/** 完了の切り替え */
const toggleComplete = useRecoilCallback(({set}) => (targetId: number) => {
set(todosState(targetId), todo => {
if (!todo) return null
return {...todo, isCompleted: !todo.isCompleted}
})
}, [])
return {
addTodo,
removeTodo,
toggleComplete,
}
}
「useTodoAction」がカスタムフックです。ポイントなのは処理をする関数を囲む形で使う「useRecoilCallback」です。
useRecoilCallbackを使わないでこのカスタムフックの関数をPropsに渡すとコンポーネントで更新が起きるたびに新しい関数を生み出してしまい、余計な再レンダリングを起こしてしまいます。
この再レンダリングを抑えるのがuseRecoilCallbackです。関数をメモ化してくれるものと思えば簡単です。
引数には、「snapShot」「gotoSnapshot」「set」「reset」「refresh」「transact_UNSTABLE」が入れられます。
大体の場合はsetやresetで十分かと思われます。
「set」は特定のAtomやSelectorに値を設定するのに使います。
「reset」は特定のAtomやSelectorの値をdefault値にします。削除処理で用いていますが、この場合は対象のAtomの値がnullになるということです。
atomFamilyを用いたことにより対象以外のToDoを読み込むことなく、対象のToDoのみを扱うことが可能になりました。その分、処理も簡単に書けることがわかります。
取得系カスタムフックの作成
次は特定のToDoとIDの全件を取得するカスタムフックの作成をします。
「todoState.ts」に次のコードを加えます。
const getTodo = selectorFamily<Todo|null, number>({
key: SelectorKeys.GET_TODO,
get: todoId => ({ get }) => get(todosState(todoId))
})
export const useGetTodoAction = () => {
const todoIds = useRecoilValue(todoIdState);
const useGetTodo = (todoId:number) => useRecoilValue(getTodo(todoId));
return { todoIds, useGetTodo }
}
「useGetTodoAction」がカスタムフックです。中身は単純で、useRecoilValueでtodoIdStateとgetTodoの値を取得しています。
getTodoは、引数にIDを入れることでそのIDと合致するToDoを返すSelectorFamilyです。
これで、「todoState.ts」の修正が完了しました。コンポーネント側の修正をしていきましょう。
ContainerとPresenterの修正
コンポーネントのパフォーマンス部分の修正をしますが、その前にもう一度、コンポーネント側の問題点と解決方法を振り返ります。
現時点では入力部、出力部を一つのコンポーネントで記述しており、更新が起きると全てが再レンダリングされてしまいます。なので、入力部と出力部を分割します。
次にレンダリングコストの高いToDoリスト部分が問題になります。変更が起きていないToDoまで再レンダリングされるのは無駄なので、出力部にToDoを出力する子コンポーネントを作成し、そのコンポーネントをメモ化をすることでPropsに変更がない限りは再レンダリングされないようにしましょう。
手順は以下のようになります。
- 入力部と出力部の分割
- ToDoを表示するコンポーネントの作成
- Recoilと接続
入力部と出力部の分割
まずは入力部と出力部の分割です。
入力部のContainer・Presenterと出力部のContainer・Presenterをそれぞれ作成します。下記のような構成にしましょう。
変更点は以下のようになってます。
-
TodoPresenter.tsxの削除
-
TodoContainer.tsxの削除
-
containerディレクトリの新規作成
- TodosContainer.tsxを新規作成
- TodosInputContainer.tsxの新規作成
- TodosOutputContainer.tsxの新規作成
-
presenterディレクトリの新規作成
- TodosInputPresenter.tsxの新規作成
- TodosOutputPresenter.tsxの新規作成
※「TodoPresenter.tsx」を削除する際に、中身をどこかに保管しておくといいかもしれないです。
「TodosContainer.tsx」は「TodosInputContainer.tsx」「TodosOutputContainer.tsx」を返すようにします。
//TodosContainer.tsx
export const TodosContainer = () => {
return(
<>
<TodosInputContainer />
<TodosOutputContainer />
</>
)
}
「TodosInputContainer.tsx」は一旦、「TodosInputPresenter.tsx」を返すだけにします。「TodosOutputContainer.tsx」も同様です。
//TodosInputContainer.tsx
export const TodosInputContainer = () => {
return <TodosInputPresenter />
}
//TodosOutputContainer.tsx
export const TodosOutputContainer = () => {
return <TodosOutputPresenter />
}
「TodosInputPresenter.tsx」には、入力部のコードを書きます。addTodo関数はエラーが出てしまいますが、そのままにしておきます。
//TodosInputPresenter.tsx
export const TodosInputPresenter = () => {
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const sendTodo = () => {
addTodo(title, content)
setTitle("")
setContent("")
}
return (
<form>
<label>
タイトル:
<input type="text" value={title} onChange={e => setTitle(e.target.value)} />
</label>
<label>
内容:
<input type="text" value={content} onChange={e => setContent(e.target.value)} />
</label>
<button type="button" onClick={() => sendTodo()}>送信</button>
</form>
)
}
「TodosOutputPresenter.tsx」には、出力部のコードを書きます。todosがないのでエラーが出ますが、これもそのままにしておきます。
//TodosOutputPresenter.tsx
export const TodosOutputPresenter = () => {
return (
<>
<div>-------------------------</div>
<h1>Todoリスト</h1>
{todos.map((todo : Todo)=> {
return (
<React.Fragment key={todo.id}>
<div>{todo.title} : {todo.isCompleted ? "完了" : "未完了"}</div>
<div>内容:{todo.content}</div>
<button type='button' onClick={() => toggleComplete(todo.id)}>{todo.isCompleted ? "戻す" : "完了"}</button>
<button type='button' onClick={() => removeTodo(todo.id)}>削除</button>
</React.Fragment>
)
})}
</>
)
}
所々、エラーは出ていますが、入力部と出力部を分割することができました。
ToDoを表示するコンポーネントの作成
出力部の子コンポーネントとして、ToDoを表示するコンポーネントを作成します。
containerディレクトリ下に「TodoOutputContainer.tsx」を、presenterディレクトリの下に「TodoOutputContainer.tsx」を新規作成します。
これを「TodosOutputPresenter.tsx」のToDoリストを表示している部分に入れます。現時点では何もPropsで与えていませんが、一旦これで置いておきます。
次のように書き換えましょう。
//TodosOutputPresenter
export const TodosOutputPresenter = () => {
return (
<>
<div>-------------------------</div>
<h1>Todoリスト</h1>
{todos.map((todo : Todo)=>
<TodoOutputContainer />
)}
</>
)
}
「TodoOutputContainer.tsx」は「TodoOutputPresenter.tsx」を返すのみにしておきます。
//TodoOutputContainer.tsx
export const TodoOutputContainer = () => {
return <TodoOutputPresenter />
}
そして、「TodoOutputPresenter.tsx」でToDoを表示するようにします。引数を入れるところから最後までを「memo」で囲むことでメモ化することができます。
todoや、その他の操作でエラーが出ていますが一旦、そのままで問題ありません。
//TodoOutputPresenter
export const TodoOutputPresenter = memo(() => {
return (
<>
<div>{todo.title} : {todo.isCompleted ? "完了" : "未完了"}</div>
<div>内容:{todo.content}</div>
<button type='button' onClick={() => toggleComplete(todo.id)}>{todo.isCompleted ? "戻す" : "完了"}</button>
<button type='button' onClick={() => removeTodo(todo.id)}>削除</button>
</>
)
});
Recoilと接続
コンポーネントの分割が終わったので、あとは「todoState.ts」で作成したカスタムフックをコンポーネント側で使うだけです。
まずは入力部から繋いでいきます。「TodosInputContainer.tsx」では、Todoを追加するaddTodoを使います。
//TodosInputContainer.tsx
export const TodosInputContainer = () => {
const { addTodo } = useTodoAction();
return <TodosInputPresenter addTodo={addTodo} />
}
「TodosInputPresenter.tsx」にPropsでaddTodoを渡します。
//TodosInputPresenter.tsx
type TodosInputPresenterProps = {
addTodo : (title: string, content: string) => void
}
export const TodosInputPresenter : React.FC<TodosInputPresenterProps> = ({
addTodo
}) => {
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const sendTodo = () => {
addTodo(title, content);
setTitle("")
setContent("")
}
/* 省略 */
これで入力部は大丈夫です。
出力部の「TodosOutputContainer.tsx」にはIDのリストを、ToDoを表示する「TodoOutputContainer.tsx」にはIDからToDoを取り出す操作、削除の操作、完了・未完了の切り替えの操作をカスタムフックから取得します。
「TodosOutputContainer.tsx」を編集します。
//TodosOutputContainer.tsx
export const TodosOutputContainer = () => {
const { todoIds } = useGetTodoAction();
return <TodosOutputPresenter todoIds={todoIds}/>
}
「TodosOutputPresenter.tsx」を編集します。このコンポーネントはメモ化します。todoIdsに更新がない限りは再レンダリングがされません。つまり、完了・未完了の切り替えの時はレンダリングがされません。
「TodoOutputContainer.tsx」でIDが必要になるので、Propsで渡します。
//TodosOutputPresenter.tsx
type TodosOutputPresenterProps = {
todoIds : number[]
}
export const TodosOutputPresenter : React.FC<TodosOutputPresenterProps> =memo(({
todoIds
}) => {
return (
<>
<div>-------------------------</div>
<h1>Todoリスト</h1>
{todoIds.map(todoId =>
<TodoOutputContainer todoId={todoId}/>
)}
</>
)
})
「TodoOutputContainer.tsx」では、親からきたIDを受け取ります。
「TodoOutputPresenter.tsx」にはIDからToDoを取り出す関数ではなく、取り出した後のToDoと削除、切り替えの関数を渡します。
ToDoを取り出す関数を渡さない理由としては、useGetTodo関数はメモ化されていないためです。この関数をPropsで渡してしまうと、何か更新が走るたびにReactは新しい関数としてuseGetTodoを受け取ってしまい、全てのToDoが再レンダリングされてしまいます。
//TodoOutputContainer.tsx
type TodoOutputContainerProps = {
todoId : number
}
export const TodoOutputContainer : React.FC<TodoOutputContainerProps> = ({
todoId
}) => {
const { useGetTodo } = useGetTodoAction();
const { removeTodo, toggleComplete } = useTodoAction();
const todo = useGetTodo(todoId);
const args = {
todo,
removeTodo,
toggleComplete
}
return <TodoOutputPresenter {...args} />
}
最後に「TodoOutputPresenter.tsx」を編集します。
//TodoOutputPresenter.tsx
type TodoOutputPresenterProps = {
todo: Todo | null,
removeTodo: (id: number) => void,
toggleComplete: (id:number) => void
}
export const TodoOutputPresenter : React.FC<TodoOutputPresenterProps>= memo(({
todo,
removeTodo,
toggleComplete
}) => {
if(!todo) return <></>;
return (
<>
<div>{todo.title} : {todo.isCompleted ? "完了" : "未完了"}</div>
<div>内容:{todo.content}</div>
<button type='button' onClick={() => toggleComplete(todo.id)}>{todo.isCompleted ? "戻す" : "完了"}</button>
<button type='button' onClick={() => removeTodo(todo.id)}>削除</button>
</>
)
});
パフォーマンスのチェック
メモやカスタムフックを用いて、多くの編集をしてきました。実際にどのくらいパフォーマンスが変わるかチェックしてみます。
注目する点は
- 入力部と出力部がお互いの影響で再レンダリングされていないか
- 変更のないToDoが無駄に再レンダリングされていないか
まずは入力部と出力部がお互いの影響で再レンダリングされていないかをチェックします。入力部・出力部・ToDoのコンポーネント部分にconsole.logを仕込みました。
入力部は文字を打ち込むたびに変更が起きてしまうので再レンダリングが頻繁に起こってしまっています。ですが、出力部には全く影響が出ていないことが確認できます。しっかりとコンポーネントを分割したおかげです。
また、今回はしませんでしたが、入力部もさらにタイトル部分、コンテンツ部分、ボタンでコンポーネントを分けることでさらに再レンダリングの範囲を狭めることが可能です。
次は、変更のないToDoが無駄に再レンダリングされていないかをチェックします。入力部がかなりログを吐いてしまうので入力部のログは消しています。
ToDo追加時に状態に更新があるのは、ToDoのIDリストと、追加されたToDoのみなので、出力部と追加されたToDoのみがレンダリングされています。一見全てが再レンダリングされているように見えますが、もしToDoがレンダリングされていれば、「ID:○のレンダリング」とログを出すはずなので、しっかりとメモ化されていることがわかります。
削除時は出力部のみ再レンダリングされています。変更のないToDoは追加と同様にメモ化されたものが使われています。
更新時はToDoのIDリストには変更がないため対象のToDoのみが再レンダリングされていることがわかります。
コンポーネントのメモ化と、関数のメモ化により、変更のないToDoには全く影響がないようになっています。しっかりとパフォーマンスが上がったかと思われます。
終わりに
今回の記事では、前回作成したRecoilを用いたToDoアプリの問題点を例にRecoilの安全な運用方法と、無駄な再レンダリングの削減方法について書かせていただきました。
Recoilにはまだまだ他にも機能があるのでご興味があれば公式サイトを調べてみてください!
最後まで読んでいただき、ありがとうございました。