Apollo Client導入により状態管理の複雑性を削減し開発効率を向上させた話

f:id:y-koga-lvgs:20211206164154p:plain

はじめに

こんにちは、20卒でレバレジーズ株式会社に入社した古賀です。 現在私は、若年層領域の事業を複数展開するヒューマンキャピタル事業部に所属しており、営業支援システムの開発に携わっています。現在は、リプレイスに取り組んでいます。私を中心にバックエンド、フロントエンドの技術選定を行い、GraphQL、React、Apollo Clientを採用しました。

この記事では、Apollo Clientを採用した理由、デメリットを補うために行った設計上の工夫をお伝えします。なお、GraphQL、Reactを採用した理由については詳細に説明はしない予定です。

前提

バックエンドにはGraphQLを採用することにしました。採用した目的は下記となります。

  • 営業支援システムや周辺サービスに存在する、似たようなロジックをGraphQLで抽象化し実装速度を向上するため
  • クライアントファーストなAPI設計にすることで、フロントエンドリソース不足を解消するため

Apollo Clientとは、GraphQL専用APIクライアントを兼ねた状態管理ライブラリです。下記のような特徴があります。

  • 取得データ、ロード、エラーの状態のロジックがApollo ClientのフックAPIにカプセル化されており、これらに関して宣言的に記述可能
  • フェッチしたデータが自動的に正規化されキャッシュされる

Apollo Clientでは、キャッシュをReduxでいうstoreとして利用し状態管理することが可能です。詳しくは公式をご覧ください。

状態管理で抱えていた問題

フロントエンドはjQueryとReact/Reduxが混在。jQueryからReact/Reduxに書き換えていたため、「サーバーからデータ取得→store格納までの処理の記述量が多い」問題が発生していました。

状態管理ライブラリの選択

上記問題を解決するために、状態管理ライブラリをApollo Clientに選択しました。 選択の流れについて説明します。候補は、既存のReduxに加え、バックエンドがGraphQLなのでApollo Client、Relayとなり、Relayは情報が少ないこともあり除外。次に、Apollo Clientのメリット・デメリットを調査しました。

  • メリット
    • キャッシュ機構をstoreとして利用することで、サーバーから取得したデータをstoreに格納する処理が不要になり、コード量が削減される
    • storeの設計が不要になり設計工数が削減される
  • デメリット
    • どこからでもAPIリクエストしキャッシュが更新されるので、バグ発生箇所の当たりをつけづらく開発効率が低下する

「サーバーからデータ取得→store格納までの処理の記述量が多い」問題は解決できそうですが、新たなデメリットが発生しそうな懸念があったため、Fragment Colocationで解決できると考え、導入しました。

デメリットのカバー

子コンポーネントで欲しいデータをFragmentで定義、親コンポーネントで欲しいデータを子コンポーネントのFragmentを組み合わせて定義、最上位階層の子コンポーネントで親コンポーネントのFragmentを組み合わせてQueryを定義し一括取得する手法になります。詳細はApollo Clientの公式を御覧ください。

トップのコンポーネントで取得したデータは、コンポーネントの再利用性を高めるためにprops経由で子コンポーネントに流し込むことにしました。

ディレクトリ構成は下記になります。

lib
 ├── atoms
 ├── molecules
 ├── organisms
 │    └─ parts/
 │    │   └─ {コンポーネント名}.tsx
 │    │   └─ {コンポーネント名}.stories.mdx
 │    └─ {モデル名}/
 │          └─ {コンポーネント名}/
 │              └─ Container.tsx:この中にQuery, Mutation, Fragmentも書く。
 │              └─ Presentation.tsx
 │              └─ Story.stories.mdx
 └─── pages/
      └─ {画面名}/
          └─ Container.tsx
          └─ Presentation.tsx

下記サンプルコードになります。Fragment Colocationについて示したいため、Query部分のみ記載してます。フックや型はgraphql-codegenで生成したものを使用しています。 organisms/Job/JobClient/Container.tsx のFragmentをまとめ上げて取得していることが分かります。

Fragment Colocationとは別ですが、サーバーから取得しないが、コンポーネント間で共有したいデータはuseReducerを利用。

pages/Sample/Container.tsx

import React, { useEffect, useReducer } from 'react'
import { Presentation } from './Presentation'
import { gql } from '@apollo/client'
import { ClientTest, JobTest } from '@/ts/objects/organisms/Job/JobClient/Container'
import { useIndexQuery } from 'generated/graphql'

const INDEX_QUERY = gql`
    query index {
        clients {
            ...ClientTest
        }
        jobs {
            ...JobTest
        }
    }
    ${ClientTest}
    ${JobTest}
`

export interface Store {
    isInit: boolean
    selectedClient?: string
    selectedJob?: string
}

type ActionType = 'SET_INIT' | 'SET_VALUE' | 'SET_CLIENT'

export interface Action {
    type: ActionType
    payload?: any
}

const reducer: React.Reducer<Store, Action> = (store, action) => {
    switch (action.type) {
        case 'SET_INIT':
            return {
                isInit: false,
                selectedClient: String(action.payload.clients[0].id),
                selectedJob: String(action.payload.jobs[0].id),
            }
        case 'SET_VALUE':
            return {
                ...store,
                [action.payload.name]: action.payload.values,
            }
        case 'SET_CLIENT':
            return {
                ...store,
                selectedClient: action.payload.values,
                selectedJob: action.payload.jobs !== 0 ? String(action.payload.jobs[0].id) : '1',
            }
        default:
            return store
    }
}

export const Container: React.FC = () => {
    const { data, loading, error } = useIndexQuery()
    const initialStore: Store = {
        isInit: true,
    }

    const [store, dispatch] = useReducer(reducer, initialStore)

    useEffect(() => {
        if (!data || !store.isInit) {
            return
        }

        dispatch({
            type: 'SET_INIT',
            payload: data,
        })
    }, [data])

    if (store.isInit && loading) {
        // 必要に応じてカスタマイズ
        return <div>Loading...</div>
    }

    if (error) {
        // 必要に応じてカスタマイズ
        return <div>Error...</div>
    }

    return <Presentation data={data} store={store} dispatch={dispatch} />
}

pages/Sample/Presentation.tsx

import React from 'react'
import { JobContainer } from '@/ts/objects/organisms/Job/JobClient/Container'
import { Store, Action } from './container'

interface Props {
    data: any
    store: Store
    dispatch: React.Dispatch<Action>
}

export const Presentation: React.FC<Props> = ({ data, store, dispatch }) => {
    return (
        <>
            <JobContainer
                clients={data.clients}
                jobs={data.jobs}
                selectedClient={store.selectedClient ?? '1'}
                selectedJob={store.selectedJob ?? '1'}
                dispatch={dispatch}
            />
        </>
    )
}

organisms/Job/JobClient/Container.tsx

import React from 'react'
import { ClientTestFragment, JobTestFragment } from 'generated/graphql'
import { gql } from '@apollo/client'
import { JobPresentation } from '@/ts/objects/organisms/Job/JobClient/Presentation'

export const ClientTest = gql`
    fragment ClientTest on Client {
        id
        name
    }
`

export const JobTest = gql`
    fragment JobTest on Job {
        id
        name
        clientId
    }
`

interface Props {
    clients: Array<ClientTestFragment>
    jobs: Array<JobTestFragment>
    selectedClient: string
    selectedJob: string
    dispatch: React.Dispatch<any>
}

export const JobContainer: React.FC<Props> = ({ clients, jobs, selectedClient, selectedJob, dispatch }) => {
    const handleChangeClient: React.ChangeEventHandler<HTMLSelectElement> = e => {
        const nextClientId = e.target.value ? String(e.target.value) : String(clients[0].id)
        dispatch({
            type: 'SET_CLIENT',
            payload: {
                name: 'selectedClient',
                values: nextClientId,
                jobs: jobs.filter((job: JobTestFragment) => String(job.clientId) === nextClientId),
            },
        })
    }

    const handleChangeJob: React.ChangeEventHandler<HTMLSelectElement> = e => {
        dispatch({
            type: 'SET_VALUE',
            payload: {
                name: 'selectedJob',
                values: e.target.value ? String(e.target.value) : String(jobs[0].id),
            },
        })
    }

    return (
        <JobPresentation
            clients={clients}
            jobs={jobs}
            selectedClient={selectedClient}
            selectedJob={selectedJob}
            handleChangeClient={handleChangeClient}
            handleChangeJob={handleChangeJob}
        />
    )
}

organisms/Job/JobClient/Presentation.tsx

import React from 'react'
import { ClientTestFragment, JobTestFragment } from 'generated/graphql'

interface Props {
    clients: Array<ClientTestFragment>
    jobs: Array<JobTestFragment>
    selectedClient: string
    selectedJob: string
    handleChangeClient: React.ChangeEventHandler<HTMLSelectElement>
    handleChangeJob: React.ChangeEventHandler<HTMLSelectElement>
}

export const JobPresentation: React.FC<Props> = ({ clients, jobs, selectedClient, selectedJob, handleChangeClient, handleChangeJob }) => {
    return (
        <>
            <div>
                <label>
                    企業:
                    <select value={selectedClient} onChange={handleChangeClient}>
                        {clients.map((client: ClientTestFragment) => {
                            return (
                                <option value={client.id} key={client.id}>
                                    {client.name}
                                </option>
                            )
                        })}
                    </select>
                </label>
                <label>
                    求人:
                    <select value={selectedJob} onChange={handleChangeJob}>
                        {jobs
                            .filter((job: JobTestFragment) => String(job.clientId) === selectedClient)
                            .map((job: JobTestFragment) => {
                                return (
                                    <option value={job.id} key={job.id}>
                                        {job.name}
                                    </option>
                                )
                            })}
                    </select>
                </label>
            </div>
        </>
    )
}

導入して分かったこと

実装したメンバーの声も含め、よかったこと、困ったことを紹介します。

  • よかったこと
    • storeに格納する処理がなくなったので、想定通りコード量が減った
    • store設計が不要になったので、設計工数を削減できた
    • 「前提」の章で述べたとおり、非同期処理に関わるデータ、ロード、エラーの状態を宣言的に扱えて書きやすかった
    • graphql-codegenによる型生成のおかげで、型安全かつ簡単にFragment Colocationを実現できた(型安全性なフロントエンド開発についてはこちら
    • サーバーから取得しないが、コンポーネント間で共有したいデータはuseReducerで管理した。管理したいデータ数がそんなに多くないこと、objectやarray等の複雑になりうるデータ構造を管理することも少なかったことから、useReducerでも問題なかった
  • 困ったこと
    • react-selectの仕様で、コンポーネントに渡すために整形する処理を完全に消すことができなかった
    • クエリやFragmentは元々他ディレクトリに切り出していたが、それぞれpages/{モデル名}配下、organisms/{モデル名}配下のContainer.tsxに定義。 理由は下記の通りです。
      • クエリやFragment毎にディレクトリを切ると行ったり来たりが多く開発が大変になるため、特に慣れてない初期は大変だと判断した
      • PresentationにUI部分は切り出しており、ContainerにクエリやFragmentを記載してもファイルサイズが長大になりすぎないため
    • Apollo Clientとは関係ないが、データを受け取るコンポーネントの再利用を考えると、Atomic Designには適切な置き場所がないので、propsをバケツリレーで渡していくことになる

最後に

Reduxのstore構造の複雑さの軽減、コード量の削減、(Apollo Clientではないですが)GraphQL周辺ツールの充実さによって、開発効率を向上させることができました。 私は新卒2年目ですが、この技術選定を担当させていただきました。レバレジーズでは、経験が浅くても提案が受け入れてもらえる環境があります。このような環境に興味がある方はぜひ一緒に働きましょう。