【LINE証券 FrontEnd】Recoilを使って安全快適な状態管理を手に入れた話

こんにちは。LINEフィナンシャル開発センター フロントエンドエンジニアの峯です。
先日新卒としてLINE証券プロジェクトに配属となり、最初のタスクとしてRecoilによる状態管理の導入に取り組みました。
その際、なぜRecoilにしたのか、また本番開発にRecoilを使うにあたって設計に気をつけたことなどを本記事でご紹介しようと思います。

技術選定

いままでの状態と課題

LINE証券フロントエンドではReact+Typescriptによる開発を採用しており、いままでのグローバルな状態管理にはUnstated を用いていました。

Unstatedでは Container classを状態の単位とし、その内部の stateを setState によって更新します。状態を使用したいコンポーネント側では、<Subscribe> コンポーネントによって情報を読み出すことができます。

一方ご存知の通り、React 16.8以降ではhooksという概念が導入され、コンポーネント内の状態管理は class componentと state/setStateによるものからfunction componentと useState によるものへと移行し、よりシンプルな記述が可能となりました。

これに対して前述のUnstatedはclass componentを念頭において設計されたものであり、このhooks時代に対応した新しいグローバル状態管理の方法を模索する必要がありました。

まず自然な後継者として Unstated Next があります。これはUnstatedのhooksへの拡張となっていますが、

  • ContainerひとつごとにProviderを設置する必要がある (Unstatedと同様)
  • 最終更新が2020/5 (執筆時点で1年前)

など、本番開発に長期的に用いるにはやや不安がありました。

Recoil

Recoil (https://recoiljs.org/) はfacebookが2020年5月頃に発表した状態管理ライブラリで、

  • シンプルな記法
    • atomだけで状態が定義できる
    • hookのみで状態をsubscribeできて、コンポーネント​ツリーの変更が不要
  • <RecoilRoot>による統一的なProviderの提供
  • 2021/6現在、活発に開発されている

などの嬉しさをもっています。

導入時点ではbeta(v0.3.0)でしたが、移行の容易さや今後の発展を考慮してRecoilを採用することにしました。

Recoilでの状態管理

Recoilの基本的な記法については、公式の Getting Started などを参照いただくことにし、ここでは大規模アプリケーション開発にRecoilを導入するにあたっての設計思想とTodoリストの実装例をお伝えします。

1. atom key, selector keyを一元管理する

Recoilでは、それぞれのatomとselectorを区別するkeyをアプリケーション全体を通してユニークにする必要があります。

将来的に状態の数が増えてもこの制約が守られることを担保するために、以下のようなenumを作成してkeyの管理場所としました。

// RecoilKeys.ts
​
export enum RecoilAtomKeys {
  TODO_STATE = 'todoState',
  NOTICE_STATE = 'noticeState'
}
​
export enum RecoilSelectorKeys {
  TODO_TODOS = 'Todo_todos',
  TODO_TODO_ITEM = 'Todo_todoItem',
  NOTICE_HAS_UNREAD_NOTICE = 'Notice_hasUnreadNotice'
}
// todoState.ts
​
import { atom } from 'recoil'
import { RecoilAtomKeys, RecoilSelectorKeys } from './RecoilKeys'
​
type TodoItem = {
  id: string
  label: string
}
​
type TodoState = {
  todos: TodoItem[]
}
​
const todoState = atom<TodoState>({
  key: RecoilAtomKeys.TODO_STATE,
  default: {
    todos: []
  }
})

2. atom, selectorを直接exportしない

Recoilは強力な状態管理機能を提供するライブラリですが、その強力さゆえに、atomやselectorをexportしてアプリ側からアクセス可能にすると、将来的に状態設計者の意図しない状態変更などが行われる可能性があります。これは、atomやselectorを直接使用することによって状態に対する任意の操作が行えるようになるためです。

よって今回の設計では、atomやselectorそのものではなく、用途に応じたカスタムフックのみをexportするようにしました。

exportするカスタムフックは、 書き込みのためのactionsと読み取りのためのselectorsの2種類です。

以下のように実装することで、この定義ファイル外では

  • Todoを追加する (useAddTodoItem)
  • すべてのTodoを読み出す (useTodos)
  • Idを指定してTodoを読み出す(useTodoItem)

の3種類の操作しかできないようになり、安全性が保たれます。

また、各用途ごとに共通の実装を用いることで、利用箇所ごとに個別の実装を書くよりもバグの起きにくい環境を作ることができると期待されます。

Actions

状態に変化を与えるためのコールバックとして、actionsをexportします。

actions内には関数を返す関数を定義することに注意してください。
Tip: Recoil stateなどをラップするコールバックを書くには、useRecoilCallback (または、setのみで良い場合useSetRecoilState)を使用します。

// todoState.ts
 
import { useRecoilCallback } from 'recoil'
​
type TodoActions = {
  useAddTodoItem: () => (label: string) => void
}
​
export const todoActions: TodoActions = {
  // Todoを追加する
  useAddTodoItem: () =>
    useRecoilCallback(({ set }) => (label: string) => {
      set(todoState, (prev) => {
        const newItem: TodoItem = {
          id: createNewId(),
          label
        }
        return {
          ...prev,
          todos: [...prev.todos, newItem]
        }
      })
    }, [createNewId]),
 
  // useSetRecoilStateを用いる場合
  useAddTodoItem: () => {
    const setState = useSetRecoilState(todoState)
 
    return React.useCallback(
      (label: string) =>
        setState((prev) => {
          const newItem: TodoItem = {
            id: createNewId(),
            label
          }
          return {
            ...prev,
            todos: [...prev.todos, newItem]
          }
        }),
      [createNewId]
    )
  }
}

Selectors

状態を読み出すためのコールバックとして、selectorsをexportします。

各selector内では単なる読み出し以外に、情報の加工(フィルタリングなど)を記述することができます。

Tip: Recoil selectorに引数を渡して条件分岐したいときは、 selectorFamily を使用します。

// todoState.ts
 
type TodoSelectors = {
  useTodos: () => TodoItem[]
  useTodoItem: (id: string) => TodoItem | undefined
}
​
// すべてのTodoを読み出す
const todosSelector = selector<TodoItem[]>({
  key: RecoilSelectorKeys.TODO_TODOS,
  get: ({ get }) => get(todoState).todos
})
​
// IDで指定したTodoを読み出す
const todoItemSelector = selectorFamily<TodoItem | undefined, string>({
  key: RecoilSelectorKeys.TODO_TODO_ITEM,
  get: (id) => ({ get }) => {
    const todos = get(todoState).todos
    return todos.find((v) => v.id === id)
  }
})
​
export const todoSelectors: TodoSelectors = {
  useTodos: () => useRecoilValue(todosSelector),
  useTodoItem: (id: string) => useRecoilValue(todoItemSelector(id))
}

この設計の利点

以上の2点に注意した設計についてもう一度利点をまとめます。

まず、コードの安全性に対する利点として

  • atom key, selector keyが重複する可能性を最低限にできる
  • 状態に対する操作を、状態設計者の意図の範囲内に収めることができる

ということは先述しました。

加えて、チームでの大規模開発に対する利点として

  • ActionsとSelectorsという「作法」を定義したことで、今後ほかの人が状態を設計したいと考えたとき、自動的に安全な設計を提供することができる
  • Recoil特有の要素 (useRecoilValueなど)をカスタムフックの定義内に押し込めたことによって、Recoilについて深く知らない人でも安全な状態管理の恩恵を受けられる
    • さらに、今後Recoilよりも良いライブラリが現れたとき、カスタムフックの内部実装のみを変更し、アプリケーション側はそのまま使えるようにできる可能性がある

ということが挙げられます。

これらのことによって、今後管理すべき状態が増えていったとしても、安全性と保守性を損なわない設計になったのではないかと思います。

まとめ

本記事では、React+TypescriptとRecoilを用いた、大規模開発を見据えた状態管理の設計と実装についてお伝えしました。

個人的なRecoilへの感想としては、useStateをグローバルに拡張したものとしてかなり自然で、「顧客が本当に必要だった状態管理やん…」と思ったりしています。

ぜひ皆さんもRecoilで快適な状態管理を体験してみてください!

この記事は、【LINE証券 FrontEnd】シリーズの第5回です。今後もLINE証券メンバーが様々な有用情報をお届けする予定です。お楽しみに!

【採用情報】