この記事は RevComm Advent Calendar 2022 の 10 日目の記事です。
こんにちは!12 月 10 日のアドベントカレンダーを担当します、RevComm フロントエンドチーム所属の小山と申します。今回は、対象読者をフロントエンドの開発者として、「ペア設計」を通して、私の React に対する理解が深まったことを記事にしていきます。
ペア設計とは私の所属しているチームで実践している取り組みで、プロダクトの設計方針や懸念点を議題として、私が持っていって壁打ちをさせていただいているミーティングです。現在は週に 2 回、各 40 分程度行っており、フロントエンドチームの中でも JavaScript、TypeScript の開発経験が豊富な方にお願いしています。
ペア設計は RevComm 全体で導入されている制度ではありません。よりよいプロダクトを開発するために、このやり方で進めたいと提案して始めました。RevComm ではそれぞれのプロジェクトチームが自分たちにフィットする開発手法を創り出し実践しています。 今回の記事のテーマは、そんなペア設計の中で議題になったものです。では、そろそろ本題に入っていきましょう!
コンポーネントの外でフックを使ったときに生じる Invalid hook call エラー
私の担当しているプロダクトでは、Next.js を使っており、GraphQL のクライアントとして Apollo Client を使い、状態管理に Recoil を使っています。
開発を進めていく中で、Apollo Client の状況に応じて Recoil の atom の値を変更したいケースが出てきました。これができると、バックエンド側でエラーが起きたときに通知を出したり、フォームの値を保存中に変更できないようにマスクをかけたりということが一括してできるようになります。
これは調べていくと、ApolloLink という、Apollo Client とサーバーの間でデータの流れをカスタマイズできる仕組みを使うことで実装できそうなことがわかりました。しかし、コンポーネントに反映させるところでつまづきました。
試しに下記のように ApolloLink の引数に Recoil のカスタムフックを使った関数を渡してみたところ、トランスパイルはできました。
const sampleRecoilLink = new ApolloLink((operation, forward) => { const setSampleState = useSetRecoilState(sampleState); setSampleState(true); return forward(operation).map((data) => { return data; }); });
しかし、実行時に以下のようなエラーがコンソールに表示されました。
Error: Invalid hook call. Hooks can only be called inside of the body of a function component. …
普段 React のコードを書いているときには遭遇しないエラーなので調べたところ、Recoil のリポジトリに同様の issue が起票されていました。
https://github.com/facebookexperimental/Recoil/issues/289
issue をみると recoil-nexus というライブラリでも解決できるようです。ただ、この問題の解決だけのために理解せずに導入したくないなという気持ちでした。その他、ここだけ Apollo Client で状態管理をするやり方も検討しましたが、あまり良いやり方ではありません。そこで、ペア設計の時間で相談することにしました。
結果として、Recoil のフックを使用するタイミングでファクトリー関数を使うことによって調整することで解決しました。
Invalid hook call エラーが発生するコード例
まず、以下が Invalid hook call エラーが発生したコード例です。
ルートの(基底となる)コンポーネント内に ApolloProvider を書いて、client (後述)を読み込ませています。Recoil も使いたいので RecoilRoot も追加しています。
function MyApp({ Component, pageProps, router }: AppProps) { return ( <RecoilRoot> <ApolloProvider client={client}> <Component {...pageProps} /> </ApolloProvider> </RecoilRoot> ); }
client は別ファイルにて定義します。Apollo Client の以下のドキュメントを参考に実装すると、以下のようなコードになります。
https://www.apollographql.com/docs/react/data/subscriptions
const httpLink = new HttpLink({ uri: SAMPLE_HTTP_URL }); const wsLink = new GraphQLWsLink(createClient({ url: SAMPLE_WS_URL })); const splitLink = split( ({ query }) => { const definition = getMainDefinition(query); return definition.kind === 'OperationDefinition' && definition.operation === 'subscription'; }, wsLink, httpLink ); // 今回追加したいApolloLink const sampleRecoilLink = new ApolloLink((operation, forward) => { const setSampleState = useSetRecoilState(sampleState); // 実行時に Error: Invalid hook callとなる箇所 setSampleState(true); return forward(operation).map((data) => { return data; }); }); export const client = new ApolloClient({ // ApolloLinkを追加 link: from([sampleRecoilLink, splitLink]), cache: new InMemoryCache(), });
この書き方では setSampleState(true) の箇所で、Invalid hook call が発生します。
ファクトリー関数とフックの組み合わせ
私は原因が特定できていない状態だったのですが、ペア設計の中でヒントをもらえました。「フック を使用するのは React の中でなければダメで、Recoil の フック を使用するのは RecoilRoot コンポーネントより後でなければダメだよ」といった内容です。あまりピンとこなかったので、もうちょっと掘り下げてみると、「Recoil は基本 React 内でしか読めないので、 ApolloClient の生成をファクトリー関数で行い、Recoil の値が必要な場合は必要な情報を React 内からファクトリー関数に渡せばよい」とのこと。
聞いたときはなんとなくの理解だったのですが、手を動かしながら理解が深まりました。
// 関数に変更 const createSplitLink = () => split( ({ query }) => { const definition = getMainDefinition(query); return definition.kind === 'OperationDefinition' && definition.operation === 'subscription'; }, wsLink, httpLink ); // 関数に変更 const createSampleRecoilLink = (setSampleState) => new ApolloLink((operation, forward) => { setSampleState(true); return forward(operation).map((data) => { return data; }); }); // フックとして扱うためにプレフィックスを追加。 export const useClient = () => { const setSampleState = useSetRecoilState(sampleState); return new ApolloClient({ link: from([createSampleRecoilLink(setSampleState), createSplitLink()]), cache: new InMemoryCache(), }); };
sampleRecoilLink を関数にすることで、定義した段階で実行されないようになっています。その他、client をカスタムフックとして、その中で useSetRecoilState を使うように変更しています。
上記のコードでは、useClient が呼び出された段階で、Recoil の useSetRecoilState が使われます。これで、RecoilRoot 配下のコンポーネント内で使う準備ができました。
図で簡単に示すと以下のようになるかと思います。ファクトリー化とカスタムフックを利用することで、コンポーネントの内部でuseClient を実行したタイミングで Recoil の useSetRecoilValue が実行されます。
ApolloProvider を RecoilRoot の後に読み込ませるように調整
あともう少しだけルートコンポーネントの調整が必要です。RecoilRoot の中で useClient() が使われるように、コンポーネントを分離します。
function MyApp({ Component, pageProps, router }: AppProps) { return ( <RecoilRoot> <ApolloProviderWrapper Component={Component} pageProps={pageProps} router={router} /> </RecoilRoot> ); } const ApolloProviderWrapper = ({ Component, pageProps, router }: AppProps) => { const client = useClient(); return ( <ApolloProvider client={client}> <Component {...pageProps} /> </ApolloProvider> ); };
こうすると、以下の図のように Recoil で状態管理をしている内部で、フックを使うという構成にすることができます。
上記の手順で RecoilRoot の内側に Recoil のフックを入れて Recoil の状態を更新ができるようになりました。これで、Apollo Client の状態をみて、通信の途中でフォームの値を変更できないよう制御できるようになります。
終わりに
今回、ペア設計でもらえたヒントを元に実装を進めたおかげで、今まで自分が意識できていなかった React のルートコンポーネント周りでの読み込み順を意識できるようになりました。
ペア設計は、自分の理解できていないことを認識できる場になりますし、その他にもこちらで用意した実装方針が状況に合わせたベターなものになっているか確認できる機会としても機能しています。議題をしっかり持っていってわからないことに向き合うことで成長でき、プロダクトに生かすことができます。自分 1 人ではできない成長ができる Happy な場だと考えて、これからも向き合っていきたいです!
RevComm ではエンジニアを募集しています。このブログを読んで興味を持っていただけたら、ぜひ採用サイトをチェックしてみてください。