Recoilで快適フロントエンド開発

最近社内向けのツールを新規で開発していて、React + Next.js と合わせてRecoilRelayを使うことにしたら大変素晴らしかったので、(すでに世の中に紹介記事がたくさんありますが)ご紹介します。

Recoilだけで結構な量になってしまったので、Relayについてはまた後日別の記事を書きます。

(追記:書きましたがRelay使わなくなったという記事になっちゃいました:GraphQLライブラリをApollo→Relay→Urqlにハシゴした話

Recoil

Reactの提供元であるFacebook改めMetaが開発中の新しい状態管理ライブラリです。

  • アプリケーション内に複数のデータストアを持てる
  • 非同期対応
  • React hooks前提

が特徴です。個人的に、Redux + middleware周りの全てをまるっと置き換えてくれると期待しています。

※ 2021年11月現在、recoilはまだexperimentalフェーズです。記事ではバージョン0.5.2を使用していますが、この先APIが大きく変更される可能性があることをご了承ください。

おことわり

筆者の藤田はReactフロントエンドのガチ開発歴が5ヶ月と浅く、数年前にはReduxの難しさに挫折したことがあるくらいなので、「わかってない」事を書いてる可能性があります。もし誤りを見つけたら優しくTwitterで教えていただけると喜びます。

使用ライブラリ

本記事で使用するライブラリです。

Recoilの基本のキ

Recoilは、ものすごくざっくり言うと、ReactのuseStateみたいな状態を保持するhookをコンポーネントをまたいで使えるというものです。

RecoilRootで囲む

アプリケーションのルートコンポーネントを<RecoilRoot>で囲みます。Next.jsなら_app.tsxとかでやるのが楽でしょう。

return (<RecoilRoot>
  (コンポーネントのルート)
</RecoilRoot>

atomの例

最も基本的な状態保持の単位である”atom”を使った文字列入力フィールドを3ステップで作ってみます。(atomのリファレンスはこちら

1. atomを宣言する

// コンポーネント関数の外でstring型のatomを宣言する
const nameAtom = atom<string>({
  key: 'components.my.nameAtom',  // globalに一意なキー
  default: ''
})

コンポーネントの外で宣言します。データストアを気軽に作る感覚です。繰り返し実行される場所には書けません。

keyの文字列はなんでも構いません。<RecoilRoot>以下で他とかぶらないよう、ディレクトリ名などを含めるといいのではないでしょうか。

2. コンポーネント内で値を取得して表示する

const MyInputField: React.FC = () => {
  const [name, setName] 
      = useRecoilState(nameAtom)
  return (
    <input id="name-input" 
           type="text" 
           value={name}
           onChange={/* 次のステップで書く */} />
  )
}

useRecoilStateが返すのは現在atomに格納されている値と、atomの値を更新するsetter関数です。useStateと同じですね。

もちろんuseStateと同様、一つのコンポーネント中でいくつでもatomを参照することができます。

3. 値を更新する

onChange={ev => 
  setName(ev.target.value)
}

useRecoilStateの第2返り値として受け取ったsetter関数をイベントハンドラ中で呼ぶとatomの値を変更します。

atomが更新されると、そのatomの値を参照しているコンポーネントはすべて再描画されます。

たったこれだけで、useContextとかいろいろ駆使しなくても、複数のコンポーネントをまたいだ状態変数が作れます。素敵。

おまけ1: useRecoilStateの部分hook

コンポーネント中で値を参照するけどsetは要らない場合はuseRecoilValueというhookを使います。

逆にatomの更新時に再描画されたくないけどsetterだけ欲しい場合はuseSetRecoilStateというhookを使います。

レンダリング時には値が要らないけどボタンクリック時にその瞬間判明している情報を元に非同期で値を取って…みたいな複雑なことをしたい場合はuseRecoilCallbackというhookがありますが、ちょっとややこしくてそれだけで記事が一つ書けちゃうので割愛します。

おまけ2: ほぼ同時にatomを更新する場合でも大丈夫

複数コンポーネントから同じatomを参照できるので、一つのatomのsetterが複数箇所からほぼ同時に呼ばれる場合が考えられます。

その際useRecoilStateで取得したときの古い値をsetterに直接渡してしまうと、他のコンポーネントからの更新内容を上書きしてしまう恐れがあります。

const MyStructView: React.FC = () => {
  // このコンポーネントではMyStructオブジェクトの一部だけ更新したい
  const [struct, setStruct] = useRecoilState<MyStruct>(structAtom)

  return (<input type="checkbox" onChange={(ev) => {
    // 最初に取得したstructを元にした「値」ではなく、 
    // MyStructの最新の値を部分更新する「関数」を渡す 
    setStruct((currentStruct) => ({
      ..currentStruct,
      checkValue: ev.target.checked
    }))
  }} />)
}

それを防ぐため、上のコードのようにsetterに値を渡す代わりに、更新用の関数を渡します。Reducerを超簡単に書けるイメージです。

Recoilは内部でちゃんとキューイングしていて、渡された関数を順番に実行してatomを更新するので、非同期に複雑な更新がかかるケースでも安全に使えます。

selectorの例

atomの値をもとに計算した別の値や、複数のatomの値を組み合わせた値を扱えるのがselectorです。(selectorのリファレンスはこちら

用途は無限にありますが、たとえば、<form>中に含まれる複数の入力フィールドの値を集めて一つのオブジェクトにするコードを2ステップで書いてみましょう。

1. selectorの宣言

export type FormData = {
  name: string
  age: number
  email: string
}
export const formDataSelector = selector<FormData>({
  key: 'components.my.formDataSelector',
  // 複数のatomの値を組み合わせて返す
  get: ({get}) => {
    const name = get(nameAtom)
    const age = get(ageAtom)
    const email = get(emailAtom)
    return {name, age, email}
  }
}

前の項で作ったnameAtomに加えて、年齢を格納しているageAtom、メールアドレスのemailAtomの値も組み合わせます。この例ではgetしか作っていないので読み取り専用のselectorになります。

setも定義すれば、複数のatomの値をまとめて更新するようなsetterを持つselectorになります。

2. selectorの値を取得

const MyForm: React.FC = () => {
  const formData = useRecoilValue(formDataSelector)
  return (<form onSubmit={() => /* formDataを送信するコード */}>
    :
    :
  </form>)
}

selectorとatomは同じRecoilStateという型なので、同じhook関数で値にアクセスできます。

selectorの値を参照しているコンポーネントは、そのselectorが参照しているatomの値が更新されると再描画されます。

Recoilの応用

非同期のselectorでRest APIを利用する

selectorのgetにはPromiseを返す関数を指定できます。したがってselector中でネットワーク上のRest APIにfetchできます。

そのような非同期のselectorに対するuseRecoilValueuseRecoilStateは、Promiseが完了していれば内部の値を返し、完了していなければPromiseオブジェクトをthrowします。この仕様は、React 16で追加されたReact.Suspenseを利用するためのものです。

これを利用すると、「初回表示時にネットワーク上のAPIをfetchして表示し、再描画時はキャッシュから値を取って表示する」という挙動が以下のように楽に作れます。

なお、残念ながら2021年12月現在、Next.jsバージョン12.0.4ではSSRでSuspenseがサポートされておらずエラーになってしまいます。SSRが無くてもいい場合は、<React.Suspense>より上の階層のコンポーネントをdynamic importしてSSRしないようにすることでSuspenseが使えます。(サンプルコードはこちらです

Next.jsの将来のバージョンとReact 18でこのあたりがサポートされるようです

1. atomとselectorの準備

type MyApiResponse = {
  :
  (APIからのレスポンスの型)
  :
}

// キャッシュ置き場
const myApiValueAtom = atom<MyApiResponse | null>({
  key: 'components.my.myApiValueAtom',
  default: null
})

const myApiValueSelector = selector<MyApiResponse>({
  key: 'components.my.myApiValueSelector',
  get: async ({get}) => {
    const currValue = get(myApiValueAtom)
    if (currValue) {
      // atomにキャッシュが保存されていれば返す
      return currValue
    } else {
      // APIから値を取って返す
      const response = await fetch('https://.......')
      return await response.json() as MyApiResponse
    }
  },
  // コンポーネント側で使いやすいようにsetterも定義
  // コンポーネント側で直接myApiValueAtomを更新しても
  // 同じです。
  set: ({set}, newValue) => {
    if (newValue instanceOf DefaultValue) {
      // キャッシュクリア用のお約束
      return newValue
    } else {
      // atomにキャッシュとして保存する
      set(myApiValueAtom, newValue)
    }
  }
})

これくらいで準備完了です。

2. コンポーネントで値を表示

const Inner: React.FC = () => {
  // 値が取得できるまではPromiseをthrowするので次の行が実行されない
  const [myApiValue, store] = useRecoilState<MyApiResponse>(myApiValueSelector)
  useEffect(() => {
    store(myApiValue) // 初回fetchで返った値をselectorに保存する
  }, [])
  return (<div>
    :
    (myApiValueの内容を表示)
    :
  </div>)
}

const Outer: React.FC = () => {
  // throwされたPromiseが完了するまでfallbackを表示する
  return (<React.Suspense fallback={<i>loading...</i>}>
    <Inner />
  </React.Suspense>)
}

throwでコンポーネントのレンダリング処理を中断してくれるということは、コンポーネント中で「Promiseが未完了ならloading表示して、エラーなら…」のように、値が取れていない状態を考える必要がなく、条件分岐が1段か2段無くせるということです。嬉しい。

ちなみにPromiseが失敗した場合のため、React.Suspenseと同じくReact v16で導入されたError Boundariesを作ってコンポーネントを囲んでおくと、ちゃんとエラー表示もできます。

カスタムフックを定義してさらに便利に

さらに、useRecoilStateuseEffectの部分を以下のようにカスタムフックにするともっと使いやすくなります。

// MyApiResponseをAPIから取得してキャッシュもするカスタムフック
const useMyApi = (): MyApiResponse => {
  const [myApiValue, store] = useRecoilState<MyApiResponse>(myApiValueSelector)
  useEffect(() => {
    store(myApiValue) // 初回fetchで返った値をselectorに保存する
  }, [])
  return myApiValue
}

すると上で書いたコンポーネントInnerは、こうなります。

const Inner: React.FC = () => { 
  const myApiValue = useMyApi()
  return (<div>
    :
    (myApiValueの内容を表示)
    :
  </div>) }

非同期でfetchしたり値を保持したりしてるのに、まるでメモリ上からぱっと値を取ってきたみたいですね。

個人的なベストプラクティスとしては、ここで書いたコンポーネント本体以外の全てを別ファイルで定義し、コンポーネントで必要な型とカスタムフックだけをexportして、atomやselectorは外部から隠蔽するのが良いと思っています。

超重要!なatomFamilyとselectorFamily

atom + useRecoilStateuseStateと大きく違うのは、コンポーネントの初回レンダリング時に初期値を渡せないことです。

たとえばあるコンポーネントで 「id=”001″ のオブジェクトの情報」を表示して「id=”002″のオブジェクトの情報」に切り替えるとします。

もしatomだけを愚直に使うなら、切り替える際にatomの状態をいったんクリアしてから…とか本質的でない処理を書く必要があります。

それに対する解もちゃんと用意されています。atomFamilyselectorFamilyです。

type Book = {
  id: string
  title: string
  author: string
}
// idごとに別の値を保持するatom
const bookAtom = atomFamily<Book | null, string>({
  key: 'components.my.bookAtom',
  default: null
})

これは同じ型のatomをidの数だけ発生させるイメージで、idが異なればちゃんと初期値が返るようになっています。

コンポーネント中でidを渡して使います。

const BookView: React.FC<{id:string}> = ({id}) => {
  // idが違えば別のatom
  const [book, setBook] = useRecoilState<Book | null>(bookAtom(id))
   :
  return (略)
}

selectorFamilyも同様にidなどのパラメータを渡せるので、例えば初回描画時はidをパラメータとしてAPIをfetchして、次からはatomFamilyに覚えている値をさっと返す、といったことが簡単にできます。

こんな重要な機能なのに、ドキュメントがちょっと隠れたところにあるんですよねぇ。

サンプルを作ってみました

今回ご紹介した機能を使ったちょっとしたサンプルです。APIをフェッチするタイミングがわかりやすいように1秒のウェイトを入れています。一度呼んだAPIを再度呼んでいないのがわかると思います。

ソースコードはこちらで公開しています

APIはBSDライセンスで公開されているPokéAPIを使わせてもらっています。

Recoilを採用した結果

フロントエンド開発が楽しくなりました!😀

  • 今書いているコンポーネントで使うステートオブジェクトだけに集中できる
  • atomとselectorはコンポーネントから独立して存在するので、テストがかんたんに書ける(テストの書き方はこちらです
  • 状態更新用メソッドを親コンポーネントから子コンポーネントに受け渡すとか考えなくていい
  • アプリケーションの本質とは違うボイラープレートがReduxよりずっと少ない
  • APIが少なく理解しやすい
  • Recoilしか必要ないのでpackage.jsonが小さい

以上、新しい状態管理ライブラリRecoilについてご紹介しました。

 

※ このブログはヌーラバーブログリレー2021 6日目の記事です。明日はAngelaさんの記事です。お楽しみに!

開発メンバー募集中

より良いチームワークを生み出す

チームの創造力を高めるコラボレーションツール

製品をみる