Firebase をフロントエンドから適切に隠蔽するための「Hooks Injection パターン」

取締役の小笠原(@yamitzky)です。

JX通信社では、React 製のフロントエンドでも Clean Architecture で設計するなど、なるべく特定のバックエンドに依存しない設計を心がけたりもするのですが、一方で Firebase をラップした react-firebase-hooks などの「便利な Hooks」を使って開発スピードを加速したい、という課題を持っていました。

そして先日、次の記事が話題になっていました。まさに「どう Firebase を隠蔽するか」と「どう Firebase を活用するか」を両立する悩みです。

blog.ojisan.io

結論を言うと「Hooks そのものを注入する」のが筋が良いのではないか と思っています。個人的に「Hooks Injection パターン」と名付けたこの方法をご紹介したいと思います。

今回の記事の完成形はソースコードを公開し、デモアプリFirestore に依存しない Storybookも公開しています。

github.com

「便利な Hooks」とは

react-firebase-hooks や react-apollo などの、特定のバックエンドを手軽に使える Hooks ライブラリを、僕は勝手に「便利な Hooks」と呼んでいます。例えば react-firebase-hooks をつかうと、次のようなシンプルなコードを書くだけで、Firestore を扱えます。Firestore なので、もちろん変更はリアルタイム反映されます。

import React from 'react'

type Todo = { id: string; title: string }

// 記事一覧ページ
export const TodoList: React.FC = () => {
  const [todos, loading, error] = useCollectionData<Todo>(firebase.firestore().collection('todos'))
  if (error) {
    return <div>error!</div>
  }
  if (loading) {
    return <div>loading...</div>
  }
  return (
    <ul>
      {todos.map((todo) => <li key={todo.id}>{todo.title}</li>)}
    </ul>
  )
}

react-apollo も useQuery() を呼び出すだけで、任意の GraphQL のバックエンドを適切なライフサイクル*1で扱えます。この2つは似たような思想のライブラリと言えるでしょう。つまりここで言う「便利な Hooks」は「特定のバックエンドに密結合にする代わりに、めちゃくちゃ便利にバックエンドを使えるようになる Hooks」です。この手軽さは、Hooks のない時代には考えられないものでした。

コンポーネントと Hooks (バックエンド)を密結合にさせてしまうのは大変便利なのですが、密結合の弊害もあります。例えば「GraphQL や Firebase を捨てたい!」となったときに UI (コンポーネント)を書き換えないといけません。また、コンポーネントに副作用がある(通信が発生する等)と、テストしづらく、Storybook などにも載せづらい等、再利用性の低いコンポーネントになってしまっています

疎結合にする方法はいくつかあるのでご紹介します。ただしここでは「react-firebase-hooks のような便利な Hooks を諦めない」という縛りを入れています。

疎結合にする方法1:Presentational Component と Container Component の分離

上記例の TodoList を疎結合にするだけであれば、なるべく外界(Firebase)との接点を限定的にする、という方針があるでしょう。いわゆる Presentational Component と Container Component の分離です。これは、Hooks が発明される前から(Redux の文脈とかでも)言われていた方法です。

// TodoListContainer.tsx = Container Component
export const TodoListContainer: React.FC = () => {
  const [todos, loading, error] = useCollectionData<Todo>(firebase.firestore().collection('todos'))
  if (error) {
    return <div>error!</div>
  }
  if (loading) {
    return <div>loading...</div>
  }
  return <TodoList todos={todos} />
}

// TodoList.tsx = Presentational Component
export const TodoList: React.FC<{ todos: Todo[] }> = ({ todos }) => {
  return (
    <ul>
      {todos.map((todo) => <li key={todo.id}>{todo.title}</li>)}
    </ul>
  )
}

これで TodoList は再利用性が高まり、テストしやすくなりました。今後 UI の責務は TodoList が担い、通信などの責務は Container Component の方に逃がすことができます。一方で Container Component の方は引き続き副作用を持ってテストしづらいまま です。

しかしこれは単純すぎる例です。実際のアプリケーションでは、Container Component が様々なデータ(認証や関連データetc...)を扱う必要があり、どんどんファットになっていきます。Presentational / Container Component の分離という方法は王道ではあるものの、Hooks のメリットがどんどん薄れてしまうような良くない手段である と考えています(あくまで個人的な意見です)。

疎結合にする方法2:クライアントを Injection する

Apollo のような「便利な Hooks」ライブラリでは、通信クライアントを注入することができます。次の例は、Apollo を使った場合のイメージです*2

// App.tsx = 一番上に存在する root component
export const App: React.FC = () => {
  const client = new ApolloClient(/* 略 */)
  return (
    <ApolloProvider client={client}>
      <Routes />
    </ApolloProvider>
  )
}

// TodoList.tsx
export const TodoList: React.FC = () => {
  const { loading, error, data: todos } = useQuery(QUERY)
  if (error) {
    return <div>error!</div>
  }
  if (loading) {
    return <div>loading...</div>
  }
  return (
    <ul>
      {todos.map((todo) => <li key={todo.id}>{todo.title}</li>)}
    </ul>
  )
}

TodoList の実装は何も変えていませんが、テストはしやすくなっています。テスト時や Storybook で TodoList を利用する際には、適当なモックのクライアントを注入できるので、副作用がコントロールできているのです。

react-firebase-hooks には直接的にはこの仕組みはありませんが、React の Context API と useContext を使えば実現できるでしょう。

// Context.ts
export const ClientContext = React.createContext()

// App.tsx
export const App: React.FC = () => {
  const client = firebase.firestore()
  return (
    <ClientContext.Provider value={client}>
      <Routes />
    </ClientContext.Provider>
  )
}

// hooks/todo.tsx
export const useTodos: { todos?: Todo[]; loading: boolean; error?: Error } {
  const client = useContext(ClientContext)
  const [todos, loading, error] = useCollectionData<Todo>(client.collection('todos'))
  return { todos, loading, error }
}

// TodoList.tsx
export const TodoList: React.FC = () => {
  const { todos, loading, error } = useTodos()
  if (error) {
    return <div>error!</div>
  }
  if (loading) {
    return <div>loading...</div>
  }
  return (
    <ul>
      {todos.map((todo) => <li key={todo.id}>{todo.title}</li>)}
    </ul>
  )
}

これで Firebase そのものに依存することなく、react-firebase-hooks を使うことができました。client を差し替えればテストもできそうです。また、useTodos() のようなカスタム Hooks を定義してみたことで、TodoList の実装もスッキリしてきました。

しかし useTodos()Firebase のライブラリインターフェースそのものに依存 しています。UI 層が間接的にライブラリのインターフェースに引っ張られてしまっており、今回の場合はこの方法はナンセンスだと思います(※Context を通じて注入するパターン自体はいい方法です)*3

疎結合にする方法3:Hooks 自体を Injection する

最後に紹介するのが、本記事の趣旨である「Hooks Injection パターン」です。

「方法2」は、UI から使う Hooks が Firebase のようなライブラリのインターフェースに引っ張られてしまっていることが問題でした。そこで、Hooks が使う Client を Injection するのではなく Hooks 自体を Injection すると、冗長さを減らしながらも、さらに疎結合にできているのではないか と思います。

// hooks/todo.ts
export type TodoHooks = {
  useTodos(): { todos?: Todo[]; loading: boolean; error?: Error }
}
export const TodoHooksContext = React.createContext<TodoHooks>()
export const useTodos: { todos?: Todo[]; loading: boolean; error?: Error } {
  return useContext(TodoHooksContext).useTodos()
}

// hooks/todo/firestore.ts
export const useTodos: { todos?: Todo[]; loading: boolean; error?: Error } {
  const [todos, loading, error] = useCollectionData<Todo>(firebase.firestore().collection('todos'))
  return { todos, loading, error }
}

// App.tsx
import { TodoHooksContext } from './hooks/todos'
import * as firestoreTodoHooks from './hooks/todos/firestore'

export const App: React.FC = () => {
  return (
    <TodoHooksContext.Provider value={firestoreTodoHooks}>
      <Routes />
    </TodoHooksContext.Provider>
  )
}

// TodoList.tsx
import { useTodos } from './hooks/todos'
export const TodoList: React.FC = () => {
  const { todos, loading, error } = useTodos()
  if (error) {
    return <div>error!</div>
  }
  if (loading) {
    return <div>loading...</div>
  }
  return (
    <ul>
      {todos.map((todo) => <li key={todo.id}>{todo.title}</li>)}
    </ul>
  )
}

TodoList.tsx は次の制約を満たしています。

  • コンポーネントは Firestore に依存しておらず、Hooks の実装の詳細が何であるかを知らない
  • コンポーネントからのカスタム Hooks の使い方が自然
  • hooks/todo/firestore.ts のみが Firestore のライブラリのインターフェースに依存している
  • 新たな class の定義などは不要で、カスタム Hooks を自然に実装すれば良い
  • テストや Storybook での Hooks の実装が差し替え容易
  • ちゃんと型安全

冗長になった部分として、Hooks のインターフェースを別に定義したり、Provider を通じて Injection していますが*4

Firestore をやめる方法

疎結合にできたので、Firestore をやめて別のバックエンドを採用したい場合でも、差し替え容易です。

例えば、次のように実装を変えたファイルを作って、Context に渡すパッケージを変更するだけです。

// hooks/todo/rest-api.ts
export const useTodos: { todos?: Todo[]; loading: boolean; error?: Error } {
  const [todos, setTodos] = useState(null)
  useEffect(() => {
    axios.get('/todos').then(r => setTodos(r))
  }, [])
  return { todos, loading: /* 略 */, error: /* 略 */ }
}

認証

認証も同様に react-firebase-hooks を諦めずに疎結合にできます。同様に定義していけば、次のように認証することもできます。(あまり良いコンポーネント分割ではない例ですが)

// TodoList.tsx
import { useTodos } from './hooks/todos'
import { useAuth } from './hooks/auth'

export const TodoList: React.FC = () => {
  const { user } = useAuth()
  const { todos } = useTodos({ user }) // ユーザーの記事一覧を見る
  return (
    <div>
      <h1>{user.name} さんの記事一覧</h1>
      <ul>
        {todos.map((todo) => <li key={todo.id}>{todo.title}</li>)}
      </ul>
    </div>
  )
}

おわりに

今回は、Hooks 自体を Injection することで、react-firebase-hooks のような便利な Hooks を諦めず、UI と Firebase を疎結合にする、、、といった方法を紹介しました。

実務でも使っているパターンではあるのですが、最近思いついただけなので、色々と穴があるかもしれません。何かありましたら、ご指摘をいただけると嬉しいです。

また、JX通信社では React/TypeScript/Firestore などを使いながらプロダクト開発をする学生インターンを募集していますので、ぜひよろしくお願いいたします。

www.wantedly.com

*1:変なムダリクエストが発生しない、適切にキャッシュされる、などを指しています

*2:実際には本ブログのために動かしてるわけではないので、間違ってたらすみません

*3:あえて触れてないのですが、Firebase そのものではなく、それをラップしたクライアントを注入する方法の方がベターだと思います。しかし方法3の方が Hooks っぽい責務の分割になると思っています

*4:関数を増やしても Provider は一つで良いです。けど、意味的な単位でわけた方が良いと思います(hooks/user.ts、hooks/todo.ts、hooks/commment.ts など)))、疎結合にしたいのであれば流石に許容範囲でしょう。ただ、 ./hooks/todo.ts の中で useContext を通じて Hooks の実体を呼び出すコードが書かれているのはちょっと微妙だなと思っています((デコレータなど使えば回避策はありそうですが