2020/05 Facebook から新しい状態管理ライブラリ Recoil がリリースされました。 まだ実験的な実装のようですが、これまでの状態管理のアプローチにない魅力があったので試していきます。

Recoil の何が嬉しい?

React で一番有名な状態管理ライブラリといえば Redux ですが、 Redux は root コンポーネントなど上位のコンポーネントで Provider を設定し、 1 アプリケーションに巨大な state ツリーを 1 つ持ちます。 これではコンポーネントからでも Redux が持つすべての state にアクセス出来ます。

グローバルな変数が使いやすいのは当たり前で、それが複雑化してくると急に影響範囲がわからなくなったり意図しない副作用に悩まされます。 そんな背景もありコンポーネント内に状態を持つ分割統治がトレンドになってきていて Recoil はこの問題を解決する 1 つの選択肢だと思っています。

Recoil は <RecoilRoot> 以下のコンポーネントでのみ state にアクセスできるので、スコープを絞った状態を扱えるようになります。

Context API でもスコープを絞った実装は可能なのですが、 Context 内の state が更新された際に購読してるコンポーネントが再レンダリングされてしまいパフォーマンスが悪くなったりコンポーネントの状態がリセットされてしまう問題がありました。 この部分は Recoil と Redux はうまくやってくれています。

Recoil チュートリアルの解説

Recoil公式のチュートリアル に沿って Recoil を使った Todo アプリの実装します。 基本的にはチュートリアルのコードをそのまま紹介しているのですが、記述が省略されている箇所も多いので最後に実行出来るようにいくらか手を加えています。

コードだけ GitHub を見たい方は記事下にリンクを貼っています。

・ToDo アイテムを追加する
・ToDo アイテムを編集する
・ToDo アイテムを削除する
・ToDo アイテムをフィルタリングする
・状態ごとの統計を表示する
といった機能を実装していきます。

準備

まずはチュートリアルで省略されてる部分の解説から。

React 環境を用意して npm もしくは yarn で recoil をインストールします。

npm install recoil
yarn add recoil

Recoil で状態管理をする部分を Redux や Context の Provider に相当する RecoilRoot 囲みます。 今回の解説では最小構成で作るので /src/index.tsx の RecoilRoot を読み込んでいます。 実際のアプリケーションでは、どこかのページの中の Todo を扱うコンポーネントで RecoilRoot を読み込むことでスコープを最小限に絞ります。

import * as React from 'react'
import * as ReactDOM from 'react-dom'
import { RecoilRoot } from 'recoil'

import { TodoList } from '~/components/TodoList'

ReactDOM.render(
  <>
    <RecoilRoot>
      <TodoList />
    </RecoilRoot>
  </>,
  document.getElementById('root')
)

Atoms

Atoms はデータストアのことで、 key はアプリケーションを通してユニークであること、default には初期値を設定します。 これで Todo のアイテムを入れる todoListState の準備はできました。

import { atom } from 'recoil'

export const todoListState = atom({
  key: 'todoListState',
  default: [],
})

TodoList コンポーネントから先ほど作った todoListState を読み取るには useRecoilValue() を使用します。 TodoListFilters, TodoListStats コンポーネントは後で作成するのでコメントアウトしています。

import * as React from 'react'
import { useRecoilValue } from 'recoil'

import { todoListState } from '~/recoil/atoms'

import {
  // TodoListStats,
  // TodoListFilters,
  TodoItemCreator,
  TodoItem,
} from '~/components'

export const TodoList = () => {
  const todoList = useRecoilValue(todoListState)

  return (
    <>
      {/* <TodoListStats /> */}
      {/* <TodoListFilters /> */}
      <TodoItemCreator />

      {todoList.map((todoItem) => (
        <TodoItem key={todoItem.id} item={todoItem} />
      ))}
    </>
  )
}

新しい Todo アイテムを追加する TodoItemCreator を作成します。 Atoms を更新するためには useSetRecoilState() に更新対象の todoListState を渡すことで setter 関数を取得します。 Redux の Actions に相当するものは用意されていないので setTodoList には更新後の値を直接渡します。

import * as React from 'react'
import { useSetRecoilState } from 'recoil'

import { todoListState } from '~/recoil/atoms'

// utility for creating unique Id
let id = 0
const getId = () => {
  return id++
}

export const TodoItemCreator = () => {
  const [inputValue, setInputValue] = React.useState('')
  const setTodoList = useSetRecoilState(todoListState)

  const addItem = () => {
    setTodoList((oldTodoList) => [
      ...oldTodoList,
      {
        id: getId(),
        text: inputValue,
        isComplete: false,
      },
    ])
    setInputValue('')
  }

  const onChange = ({ target: { value } }) => {
    setInputValue(value)
  }

  return (
    <div>
      <input type="text" value={inputValue} onChange={onChange} />
      <button onClick={addItem}>Add</button>
    </div>
  )
}

リストの 1 件毎にあたる TodoItem コンポーネントを作成します。 このコンポーネントでは、テキストの変更 / 項目の削除 / タスクの状態(boolean)を変更ができます。

さきほど使った useSetRecoilState() は setter 関数を返すだけのものでしたが、useRecoilState() だと [値, setter関数] が返ってきます。 React.useState() と同様ですね。

import * as React from 'react'
import { useRecoilState } from 'recoil'

import { todoListState } from '~/recoil/atoms'

const replaceItemAtIndex = (arr, index, newValue) => {
  return [...arr.slice(0, index), newValue, ...arr.slice(index + 1)]
}

const removeItemAtIndex = (arr, index) => {
  return [...arr.slice(0, index), ...arr.slice(index + 1)]
}

export const TodoItem = ({ item }) => {
  const [todoList, setTodoList] = useRecoilState(todoListState)
  const index = todoList.findIndex((listItem) => listItem === item)

  const editItemText = ({ target: { value } }) => {
    const newList = replaceItemAtIndex(todoList, index, {
      ...item,
      text: value,
    })

    setTodoList(newList)
  }

  const toggleItemCompletion = () => {
    const newList = replaceItemAtIndex(todoList, index, {
      ...item,
      isComplete: !item.isComplete,
    })

    setTodoList(newList)
  }

  const deleteItem = () => {
    const newList = removeItemAtIndex(todoList, index)

    setTodoList(newList)
  }

  return (
    <div>
      <input type="text" value={item.text} onChange={editItemText} />
      <input
        type="checkbox"
        checked={item.isComplete}
        onChange={toggleItemCompletion}
      />
      <button onClick={deleteItem}>X</button>
    </div>
  )
}

ここまでで Todo アプリケーションの単純な一覧表示と、テキストの変更 / 項目の削除 / タスクの状態(boolean)を変更が出来るようになりました。

Selectors

Atoms だけでも十分使えるのですが Selectors という概念もあって、こちらは Atoms の state を加工した値を返す関数を定義します。 Vuex でいうところの getters にあたります。 Recoil チュートリアルでは Todo リストをタスクの状態でフィルターしたり、状態毎の件数を取得します。

まずは、いまどういったフィルターをかけているか 'Show All' | 'Show Completed' | 'Show Uncompleted' のいずれかが入る todoListFilterState を Atoms に追加します。

export const todoListFilterState = atom({
  key: 'todoListFilterState',
  default: 'Show All',
})

Atoms の todoListState から todoListFilterState の条件でフィルタした filteredTodoListState を作成します。

import { selector } from 'recoil'

import { todoListState, todoListFilterState } from '~/recoil/atoms'

export 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
    }
  },
})

TodoList コンポーネントで todoListState をフィルタした filteredTodoListState を取得するように差し替えます。 これで全件(todoListState)表示されていたリストがフィルタされたリスト(filteredTodoListState)に置き換わります。

// import { todoListState } from '~/recoil/atoms'
import { filteredTodoListState } from '~/recoil/selectors'

  //const todoList = useRecoilValue(todoListState)
  const todoList = useRecoilValue(filteredTodoListState)

TodoListFilters コンポーネントを追加します。このコンポーネントでは select 要素が変更されると todoListFilterState を更新していきます。 作成し終えたら /src/components/TodoList.tsx の TodoListFilters をコメントアウトしている箇所を外してください。

import * as React from 'react'
import { useRecoilState } from 'recoil'

import { todoListFilterState } from '~/recoil/atoms'

export const TodoListFilters = () => {
  const [filter, setFilter] = useRecoilState(todoListFilterState)

  const updateFilter = ({ target: { value } }) => {
    setFilter(value)
  }

  return (
    <>
      Filter:
      <select value={filter} onChange={updateFilter}>
        <option value="Show All">All</option>
        <option value="Show Completed">Completed</option>
        <option value="Show Uncompleted">Uncompleted</option>
      </select>
    </>
  )
}

これで Todo リストのフィルタ機能が実装できました。

つぎに Todo リスト内の件数や割合
・ToDo アイテムの総数
・完了したアイテムの総数
・未完了アイテムの総数
・完了したアイテムの割合
を取得するセレクタ todoListStatsState を追加します。

export const todoListStatsState = selector({
  key: 'todoListStatsState',
  get: ({ get }) => {
    const todoList = get(filteredTodoListState)
    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,
    }
  },
})

todoListStatsState を表示する TodoListStats コンポーネントを作成します。 作成し終えたら /src/components/TodoList.tsx の TodoListStats をコメントアウトしている箇所を外してください。

import * as React from 'react'
import { useRecoilValue } from 'recoil'

import { todoListStatsState } from '~/recoil/selectors'

export const TodoListStats = () => {
  const {
    totalNum,
    totalCompletedNum,
    totalUncompletedNum,
    percentCompleted,
  } = useRecoilValue(todoListStatsState)

  const formattedPercentCompleted = Math.round(percentCompleted * 100)

  return (
    <ul>
      <li>Total items: {totalNum}</li>
      <li>Items completed: {totalCompletedNum}</li>
      <li>Items not completed: {totalUncompletedNum}</li>
      <li>Percent completed: {formattedPercentCompleted}</li>
    </ul>
  )
}

filteredTodoListState, todoListStatsState 2 つの selector を作り、状態でフィルタしたリスト, 状態ごとの件数や割合が取得できるようになりました。

まとめ

Recoil は Hooks の後に生まれたこともあり扱いやすく、カスタムフックとも相性が良さそうな印象でした。

スコープを絞れるのは React に限らず、 Redux / Vuex になんでも登録していたフロントエンドから脱却するトレンド的な部分もあるのでこの流れは加速するでしょう。 データフローを整えるためとても有用なので、正式リリースされたら積極的に使ってみたいです。

この記事のコードは GitHub で公開しています。

https://github.com/ria3100/react-recoil-example