Reactで複雑な状態管理を行わずに、レンダリングを最適化する
IT技術
はじめに
みなさんこんにちは!
フロントエンドエンジニアのずおです。
突然ですが、みなさんはReactで状態管理を行う際に、どのような手法を用いますか?
もちろん、useStateやuseEffectなどをはじめとするHooksや、コンテキストなどを使用し、状態管理をされていると思います。このような状態管理の手法は非常に便利ですし、Reactを使用して開発を行っていく上では、欠かせないものですよね!
しかし、開発するアプリケーションが大規模になればなるほど、状態管理の複雑性が増していき、コードが煩雑になったり、逆にパフォーマンスを下げてしまったりなど、状態管理の仕組みを深く理解した上で、コードを書いていく必要があるため、難易度が高いと言われています。
今回は、複雑な状態管理を行わずに、レンダリングを最適化するというお題で記事を書きたいと思います。よろしくお願いします。
コンポーネントの再レンダリングが発生するタイミングはいつ?
本題に入る前に、コンポーネントが再レンダリングされるタイミングについてさらっと触れておきます。再レンダリングの発生タイミングは以下の4つだと言われています。
- propsが更新された時
- stateが更新された時
- 親コンポーネントが再レンダリングされた時
- 参照しているコンテキストが更新された時
詳細については、この記事では割愛しますが、頭の片隅に置いていただき、記事を読んでいただけたら幸いです。
レンダリングを確認する
では本題です。
簡単なTODOアプリを作成してレンダリングを追っていきたいと思います!
今回は、viteを用いて開発環境を整えました。
主に使用するsrc配下の構成は以下のとおりです。
1├── src
2│ ├── App.jsx // 親コンポーネント
3│ ├── components // 子コンポーネント
4│ │ ├── Navbar.jsx
5│ │ ├── Sidebar.jsx
6│ │ └── TodoList.jsx
TODOアプリの概要
- TodoList: フォーム内でタスクを追加すると、下にチェックボックス形式でTODOリストが作成される。
- Navbar: TODOリストにチェックを入れるとDoneになり、NavbarのDoneのカウントが1増える。
- Sidebar: レンダリングを確認するためのもので、ただの静的なコーディング。(機能なし)
- 作成されたTODOリストはLocalStorageに保存される。
至ってシンプルな実装です。
上記の機能を、Reactで実現するためには、
親コンポーネントであるApp.jsxファイル内で状態管理を行い、それを子コンポーネントであるNavbar、TodoListコンポーネントにpropsとして渡してあげる必要があります。
実際に、ReactのHooksを用いて状態管理を行ってみます。レンダリングされたかどうかを確認するためにログ確認も行います。(CSSはtailwindを使用しています)
1
2import { Navbar } from './components/Navbar'
3import { Sidebar } from './components/Sidebar'
4import { TodoList } from './components/TodoList'
5import { useState, useEffect } from 'react'
6
7const LOCAL_STORAGE_KEY = 'TODOS'
8function App() {
9 console.log('Rendering App')
10
11 const [todos, setTodos] = useState(() => {
12 const value = localStorage.getItem(LOCAL_STORAGE_KEY)
13 if (value === null) return []
14 return JSON.parse(value)
15 })
16
17 const addTodo = (name) => {
18 setTodos((prevTodos) => {
19 return [...prevTodos, { id: crypto.randomUUID(), name, complete: false }]
20 })
21 }
22
23 const toggleTodo = (id, completed) => {
24 setTodos((prevTodos) => {
25 return prevTodos.map((todo) => {
26 if (todo.id === id) {
27 return { ...todo, completed }
28 }
29 return todo
30 })
31 })
32 }
33
34 useEffect(() => {
35 localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(todos))
36 }, [todos])
37
38 return (
39 <div>
40 <Navbar todos={todos} />
41 <div className="min-h-screen pt-14">
42 <main className="flex-1 p-10 flex justify-center mr-32">
43 <TodoList addTodo={addTodo} toggleTodo={toggleTodo} todos={todos} />
44 </main>
45 <Sidebar />
46 </div>
47 </div>
48 )
49}
50
51export default App
1
2import { useState } from 'react'
3
4export const TodoList = ({ addTodo, toggleTodo, todos }) => {
5 console.log('Rendering TodoList')
6
7 const [newTodoName, setNewTodoName] = useState('')
8
9 const handleSubmit = (e) => {
10 e.preventDefault()
11
12 addTodo(newTodoName)
13
14 setNewTodoName('')
15 }
16
17 return (
18 <div className="w-full">
19 <form
20 onSubmit={handleSubmit}
21 className="bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4"
22 >
23 <label
24 className="block text-gray-700 text-sm font-bold mb-2"
25 htmlFor="new-todo"
26 >
27 New Task
28 </label>
29 <input
30 className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
31 id="new-todo"
32 type="text"
33 placeholder="Add a new task"
34 value={newTodoName}
35 onChange={(e) => setNewTodoName(e.target.value)}
36 />
37 <div className="pt-2 flex justify-center">
38 <button
39 className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline"
40 type="submit"
41 >
42 TODO!!
43 </button>
44 </div>
45 </form>
46 <ul className="list-inside">
47 {todos.map((todo) => (
48 <li
49 key={todo.id}
50 className={
では、TODOリストにチェックを入れた場合、どのコンポーネントがレンダリングされているのかを確認します。
結果は、全てのコンポーネントがレンダリングされていますね。
これは、状態が変更されると、親のコンポーネントが再レンダリングされ、それにより子コンポーネントも再レンダリングされてしまうからです。
少なくとも、Sidebarに関しては、レンダリングさせる必要はないですよね!
この問題を解決するために、メモ化などを使用して、不要なレンダリングを防ぐことは可能です。この程度のアプリケーションでは、問題にはならないと思いますが、大規模開発になると、その管理は複雑になります。また、Hooksには、トップレベルでしか使用できない、if文の中では使用しないなどのルールがあり、処理自体がややこしいという部分もあると思います。
そのため今回は、そのような問題を排除してくれるpreact signalsを使用してレンダリングを最適化します!
Signalsとは
At its core, a signal is an object with a .value property that holds some value. Accessing a signal's value property from within a component automatically updates that component when the value of that signal changes.
In addition to being straightforward and easy to write, this also ensures state updates stay fast regardless of how many components your app has. Signals are fast by default, automatically optimizing updates > > behind the scenes for you.要約
signalsは、valueプロパティを持つオブジェクトです。コンポーネント内からsignalsのvalueプロパティにアクセスすると、シグナルの値が変更されたときに、そのコンポーネントが自動的に更新されます。
分かりやすく書きやすいだけでなく、アプリのコンポーネントの数に関係なく、状態の更新を高速に保つことができます。
https://preactjs.com/blog/introducing-signals/
とても魅力的ですね、実際に使用してみましょう。
レンダリングを最適化する
ライブラリをインストールし、コードを書き換えます。
1npm i @preact/signals-react
1
2import { Navbar } from './components/Navbar'
3import { Sidebar } from './components/Sidebar'
4import { TodoList } from './components/TodoList'
5import { signal, effect } from '@preact/signals-react'
6
7const LOCAL_STORAGE_KEY = 'TODOS'
8const todos = signal(getTodos())
9
10function getTodos() {
11 const value = localStorage.getItem(LOCAL_STORAGE_KEY)
12 if (value === null) return []
13 return JSON.parse(value)
14}
15effect(() => {
16 localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(todos.value))
17})
18
19function App() {
20 console.log('Rendering App')
21
22 return (
23 <div>
24 <Navbar todos={todos} />
25 <div className="min-h-screen pt-14">
26 <main className="flex-1 p-10 flex justify-center mr-32">
27 <TodoList todos={todos} />
28 </main>
29 <Sidebar />
30 </div>
31 </div>
32 )
33}
34
35export default App
1
2import { useState } from 'react'
3
4export const TodoList = ({ todos }) => {
5 console.log('Rendering TodoList')
6
7 const [newTodoName, setNewTodoName] = useState('')
8
9 const addTodo = (e) => {
10 e.preventDefault()
11 todos.value = [
12 ...todos.value,
13 { id: crypto.randomUUID(), name: newTodoName, complete: false },
14 ]
15 setNewTodoName('')
16 }
17
18 const toggleTodo = (id, completed) => {
19 todos.value = todos.value.map((todo) => {
20 if (todo.id === id) {
21 return { ...todo, completed }
22 }
23 return todo
24 })
25 }
26
27 return (
28 <div className="w-full">
29 <form
30 onSubmit={addTodo}
31 className="bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4"
32 >
33 <label
34 className="block text-gray-700 text-sm font-bold mb-2"
35 htmlFor="new-todo"
36 >
37 New Task
38 </label>
39 <input
40 className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
41 id="new-todo"
42 type="text"
43 placeholder="Add a new task"
44 value={newTodoName}
45 onChange={(e) => setNewTodoName(e.target.value)}
46 />
47 <div className="pt-2 flex justify-center">
48 <button
49 className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline"
50 type="submit"
51 >
52 TODO!!
53 </button>
54 </div>
55 </form>
56 <ul className="list-inside">
57 {todos.value.map((todo) => (
58 <li
59 key={todo.id}
60 className={
コードが簡潔になりました。
改めてチェックを入れた場合、どのコンポーネントがレンダリングされたかを確認します。
結果は、NavbarとTodoListだけがレンダリングされるようになりました!
Sidebarコンポーネントはもちろん、親のコンポーネントであるAppもレンダリングされていません。
これはコンポーネントの外で、状態管理を行うことにより、独立してデータの受け渡しを行うことができるからです。また、独立させたことによって、テストを書くのが非常に簡単になりますよね!
使用したsignalsの関数
signal(initialValue)
signalのvalueプロパティの値を参照して、値の読み取りや、更新を行う関数。
該当コードはこちらです。
1
2const todos = signal(getTodos())
3
4function getTodos() {
5const value = localStorage.getItem(LOCAL_STORAGE_KEY)
6if (value === null) return []
7return JSON.parse(value)
8}
ちなみにsignalの中身はこのような感じです。
このvalueプロパティを介して状態管理を行なっています。
effect(fn)
signalの値を参照し、変更された時に再評価されます。
useEffectと異なり、第二引数に依存配列を定義することなく、自動的に依存関係を管理してくれます。
該当コードはこちらです。
1
2effect(() => {
3 localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(todos.value))
4})
終わりに
いかがだったでしょうか。
preactのsignalsを使用すると、簡単に状態管理を行いつつ、レンダリングを最適化することができたのではないかと思います。現状は、メンテナンス性や拡張性などの問題もあるかもしれませんが、signalsを使用した状態管理が主流になる日も来るかもしれませんね!
最後まで読んでいただき、ありがとうございました。
ライトコードでは、エンジニアを積極採用中!
ライトコードでは、エンジニアを積極採用しています!社長と一杯しながらお話しする機会もご用意しております。そのほかカジュアル面談等もございますので、くわしくは採用情報をご確認ください。
採用情報へ
愛媛県の田舎町で生まれ育ちましたずおと申します。 趣味はサウナと居酒屋巡りです。 未経験で入社させていただいたので、いち早く戦力になれるように日々頑張ります! 座右の銘は「悩んでるひまに、一つでもやりなよ」 ドラえもんの名言です。よろしくお願いします。