2020/11/13
Reactの状態管理ライブラリー「Recoil」を触ってみました
Recoiとは、React上でわかりやすく状態管理を行うためのライブラリーで、Reactを開発しているFacebookが開発しています。
Reactはコンポーネント指向というコンポーネント(部品)単位の構造のツリー構造によって構成されています。
元々のReactの状態管理はコンポーネント単位が基本ですが、親の状態が子孫の状態に反映される場合も頻繁にあり、そういった場合は 親 > 子 のように情報を伝達する仕組みを使う必要がありました。
しかしこのツリー構造が深く広くなるほどその伝達は 親 > 子 > 孫 > ひ孫 > … のように複雑なものとなっていき、管理が難しくなります。
また状態管理で推奨されるアーキテクチャの Flux は情報の流れを確実なものにしますが、そのためのプログラムの設計・実装は簡単ではなく、メモリリークに繋がりやすい側面もありました。
そこで登場したのが Redux という Flux を拡張し、状態管理をグローバルに扱えるようにしてくれるライブラリでした。
この動きを受けて、Reactに公式としてReduxと同様に状態管理できる仕組みとして導入されたのが Context API です(過去に試してみた記事はこちら)。
公式ということもあり、ContextAPIは浸透してきましたが、まだ問題がありました。
ContextAPIはひとつのRootに一つの状態しか格納できなため、複数の状態を格納するためには複数のContextを用意する必要がありました。またこの構造は、コンポーネント指向のメリットであるコードの分割を困難にしてしまいます。
また、非同期なデータに対するサポートも含まれていなかったため、非同期に対応させるためにノウハウが必要でした。
こういった問題を解決するべくRecoilが登場してきました。
RecoilはひとつのRootを設置するだけで、複数のグローバルな状態を格納することができる仕組みを提供してくれます。
また、非同期をサポートしているため、非同期への対応に独自のノウハウも必要ありません。
[2020.12.04追記] 非同期に対応させる方法に関する記事を追加しました
私も試しに触ってみましたが、ContextAPIと比べると格段に簡単に利用することができました。
↓公式のチュートリアルをベースに試しに作ってみたDEMO
DEMO
試すにあたって、以下のサイトを参考にさせていただきました。
Recoil the next state management library for React | by Tharaka Romesh | The Startup | Medium
Recoil vs Redux | The Ultimate React State Management Face-Off | by Chandu | Medium
Recoil – Reactの新しい状態管理ライブラリ | infoQ
Recoil – Facebook’s own State Management Library – DEV
Recoilの導入方法はとてもシンプルです。
npm install recoil
もしくは yarn add recoil
によりrecoilをインストールし、AppなどのRecoilで状態管理したいツリーのルートにRecoilRootを設置します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | import React, {useState} from 'react'; import { RecoilRoot, atom, selector, useRecoilState, useRecoilValue, useSetRecoilState, } from 'recoil'; function App() { return ( <RecoilRoot> <main className="mainContent"> <TodoList /> </main> </RecoilRoot> ); } export default App; |
その後は、atom、selectorを中心に、RecoilのAPIを使ってRecioilRoot傘下のコンポーネントから状態を登録、変更することが可能になります。
今回は主要なAPIであるatomとselectorについて説明したいと思います。
atom
状態を登録するためのAPIで、Recoilの状態管理はここから始まります。
1 2 3 4 | const todoListState = atom({ key: 'todoListState', default: [] }); |
キー(key)と初期値(default)をオブジェクトとして引数に渡してあげるだけで、登録できます。
今回の例はtodoListなのでデフォルト値が空の配列ですが、
数値や文字列などを直接値として設定することも可能です。
selector
現在の状態からデータを処理して返すオブジェクトを登録するためのAPIです。フィルターなどに使うことができます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | const filteredTodoListState = selector({ key: 'filteredTodoListState', get: ({get}) => { const filter = get(todoListFilterState); const list = get(todoListState); switch (filter) { case 'Show Completed': return list.filter((item) => item.isComplete); case 'Show Uncompleted': return list.filter((item) => !item.isComplete); default: return list; } }, }); |
状態の取得には useRecoilState を使います。
1 | const [todoList, setTodoList] = useRecoilState(todoListState); |
値の取得には useRecoilValue を使います。
1 | const todoList = useRecoilValue(filteredTodoListState); |
selector で処理したデータを取得するためには useRecoilState の引数にselectorで登録したオブジェクトを指定します。
1 | const [filter, setFilter] = useRecoilState(todoListFilterState); |
状態の更新は useSetRecoilState で取得した関数を利用して行います。
1 2 3 4 5 6 7 8 9 10 11 | const setTodoList = useSetRecoilState(todoListState); setTodoList((oldTodoList) => [ ...oldTodoList, { id: getId(), text: inputValue, createAt: new Date(), isComplete: false, }, ]); |
コンポーネントのどこからでも扱える分、データの整理に注意が必要にはなりますが、
使い勝手はとてもいいと感じました。
今回は試すところまでできませんでしたが、非同期をサポートしているのもかなり大きいと思います。(ContextAPIで試したときはかなり苦労しました)
以下、ご参考までに今回試しに作ったTodoListのコード全文です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 | import React, {useState} from 'react'; import { RecoilRoot, atom, selector, useRecoilState, useRecoilValue, useSetRecoilState, } from 'recoil'; import './App.scss'; type TodoListType = { id: number, text: string, createAt: Date, completedAt?: Date, isComplete: boolean } const defaultTodoListState:TodoListType[] = []; const todoListState = atom({ key: 'todoListState', default: defaultTodoListState }); const todoListFilterState = atom({ key: 'todoListFilterState', default: 'Show All', }); const filteredTodoListState = selector({ key: 'filteredTodoListState', get: ({get}) => { const filter = get(todoListFilterState); const list = get(todoListState); switch (filter) { case 'Show Completed': return list.filter((item) => item.isComplete); case 'Show Uncompleted': return list.filter((item) => !item.isComplete); default: return list; } }, }); function TodoList() { const todoList = useRecoilValue(filteredTodoListState); return ( <div className="todoList"> <TodoListStats /> <TodoListFilters /> <TodoItemCreator /> <ul className="todoList__list"> {todoList.map((todoItem) => ( <TodoItem key={todoItem.id} item={todoItem} /> ))} </ul> </div> ); } function TodoItemCreator() { const [inputValue, setInputValue] = useState(''); const setTodoList = useSetRecoilState(todoListState); const addItem = () => { setTodoList((oldTodoList) => [ ...oldTodoList, { id: getId(), text: inputValue, createAt: new Date(), isComplete: false, }, ]); setInputValue(''); }; const onChange = ({target: {value}}: React.ChangeEvent<HTMLInputElement>) => { setInputValue(value); }; return ( <p className="todoListCreator"> <input type="text" value={inputValue} onChange={onChange} /> <button onClick={addItem}>Add</button> </p> ); } let id = 0; function getId() { return id++; } function TodoItem({item}: { key: number, item: TodoListType }) { const [todoList, setTodoList] = useRecoilState(todoListState); const index = todoList.findIndex((listItem) => listItem === item); const editItemText = ({target: {value}}: React.ChangeEvent<HTMLInputElement>) => { const newList = replaceItemAtIndex(todoList, index, { ...item, text: value, }); setTodoList(newList); }; const toggleItemCompletion = () => { const newList = replaceItemAtIndex(todoList, index, { ...item, completedAt: !item.isComplete ? new Date() : undefined, isComplete: !item.isComplete, }); setTodoList(newList); }; const deleteItem = () => { const newList = removeItemAtIndex(todoList, index); setTodoList(newList); }; return ( <li className="todoList__item"> <input type="text" value={item.text} onChange={editItemText} /> <input type="text" size={ 8 } readOnly value={ ( '00' + (item.createAt as Date).getHours() ).slice( -2 ) + ':' + ( '00' + (item.createAt as Date).getMinutes() ).slice( -2 ) + ':' + ( '00' + (item.createAt as Date).getSeconds() ).slice( -2 ) } /> <input type="checkbox" checked={item.isComplete} onChange={toggleItemCompletion} /> <input type="text" size={ 8 } readOnly value={ typeof item.completedAt != 'undefined' ? ( '00' + (item.completedAt as Date).getHours() ).slice( -2 ) + ':' + ( '00' + (item.completedAt as Date).getMinutes() ).slice( -2 ) + ':' + ( '00' + (item.completedAt as Date).getSeconds() ).slice( -2 ) : '' } /> <button className="delete" onClick={deleteItem}>X</button> </li> ); } function replaceItemAtIndex(todoList: TodoListType[], index: number, newValue: TodoListType) { return [...todoList.slice(0, index), newValue, ...todoList.slice(index + 1)]; } function removeItemAtIndex(todoList: TodoListType[], index: number) { return [...todoList.slice(0, index), ...todoList.slice(index + 1)]; } function TodoListFilters() { const [filter, setFilter] = useRecoilState(todoListFilterState); const updateFilter = ({target: {value}}: React.ChangeEvent<HTMLSelectElement>) => { setFilter(value); }; return ( <p className="todoListFilter"> <span className="todoListFilter__label">Filter:</span> <select value={filter} onChange={updateFilter}> <option value="Show All">All</option> <option value="Show Completed">Completed</option> <option value="Show Uncompleted">Uncompleted</option> </select> </p> ); } const todoListStatsState = selector({ key: 'todoListStatsState', get: ({get}) => { const todoList = get(todoListState); const totalNum = todoList.length; const totalCompletedNum = todoList.filter((item) => item.isComplete).length; const totalUncompletedNum = totalNum - totalCompletedNum; const percentCompleted = totalNum === 0 ? 0 : totalCompletedNum / totalNum; return { totalNum, totalCompletedNum, totalUncompletedNum, percentCompleted, }; }, }); function TodoListStats() { const { totalNum, totalCompletedNum, totalUncompletedNum, percentCompleted, } = useRecoilValue(todoListStatsState); const formattedPercentCompleted = Math.round(percentCompleted * 100); return ( <ul className="todoListStats"> <li>Total items: <span className="todoListStats__value">{totalNum}</span></li> <li>Items completed: <span className="todoListStats__value">{totalCompletedNum}</span></li> <li>Items not completed: <span className="todoListStats__value">{totalUncompletedNum}</span></li> <li>Percent completed: <span className="todoListStats__value">{formattedPercentCompleted}%</span></li> </ul> ); } function App() { return ( <RecoilRoot> <main className="mainContent"> <TodoList /> </main> </RecoilRoot> ); } export default App; |
Author Profile
NINOMIYA
Webデザイナー兼コーダー出身のフロントエンド開発者です。 UXデザインやチーム開発の効率化など、勉強中です。
SHARE