はじめに
最近apolloのSuspenseQueryを使う機会があり、onCompletedがないことを知ったのでその理由を色々と調べてみました。しかしこの辺り、特にデータフェッチ後の副作用的な処理の実装でonCompletedを使うべきかuseEffectを使うべきかについて深掘りしている日本語の記事が見つからなかったので具体例も含めてまとめておきます。
想定しているケース
本記事ではfetchしてきたデータを用いて副次的な処理をする場合を考えます。具体例として、プロフィール画面の実装を考えてみましょう。
import { gql, useQuery } from "@apollo/client";
import React, { useEffect } from "react";
export const GET_PROFILE = gql`
query GetProfile($userId: ID!) {
user(id: $userId) {
id
name
bio
}
}
`;
type ProfileProps = {
userId: string;
};
export const ProfilePage: React.FC<ProfileProps> = ({ userId }) => {
const { data } = useQuery(GET_PROFILE, {
variables: { userId },
});
return (
<div>
<h1>{data?.user.name}</h1>
<p>{data?.user.bio}</p>
</div>
);
};
このコードに、データフェッチ後に「ページタイトルを"{ユーザー名}のページ"に変更する」「localStorageに今見ているユーザーのユーザー名を保存する」の2つの処理を行うことを考えます。
この場合、ApolloのuseQueryでサポートされているonCompletedとReactの組み込み関数であるuseEffectではどちらを使うべきでしょうか?
まず結論
先に結論を書いておくと、 Reactの設計思想の観点からuseEffectを使うべきだと考えています。これはApolloのmaintainerもissueの中で触れています。
https://github.com/apollographql/apollo-client/issues/11306
以下ではその理由を掘り下げていきます。先に2つの具体的な方法とよく言われるメリットについて見ておきます。
onCompletedを用いた実装
上でも用いている、Apolloで最も標準的に使われるデータフェッチ用のhookであるuseQueryはonCompletedという、「データフェッチが完了したら発火するコールバック関数」のフィールドがあります。例えばこんな感じで書けます。
const { data } = useQuery(GET_PROFILE, {
variables: { userId },
onCompleted: (result) => {
document.title = `${result.user.name}のページ`;
localStorage.setItem(`userName_${userId}`, result.user.name);
},
});
メリットとしては
- useQueryの副作用として発火することが明確で直感的
- データフェッチに関連した処理が集約され、可読性が高い
などが挙げられます。
useEffectを用いた実装
useEffectはReactの組み込み関数であり、副作用を記述するために用意されているhookだとよく言われています。ドキュメントには コンポーネントを外部システムと同期させるためのhook と書かれています。
これを用いて実装するとこんな感じで書けます。
useEffect(() => {
if (data?.user?.name) {
document.title = `${data.user.name} - Profile`;
localStorage.setItem(`userName_${userId}`, data.user.name);
}
}, [data]);
メリットとしては
- 副作用であること、つまり画面のレンダリングが終わった後に行われても良い処理であることが明確化される
という点が挙げられます。これは↓のドキュメントにも明記されています。
onCompletedの敗北
ここまでのメリットだけ見ると、それぞれにメリットがあり、特に可読性という観点ではonCompletedに軍配が上がるように見えます。筆者もこれまでonCompletedをよく使っていました。ではなぜuseEffectを使うべきとされるのでしょうか?これにはReactの、特にv18以降により強く現れるようになった思想が強く関連しています。
React v18以降で現れたReactの思想
React v18は2022年の3月にリリースされているのでもう3年近く前ですが、ここでReactは並列レンダリングと呼ばれる機構を導入しました。ここで、重要な特性として「処理を途中で中断できる」「更新のレンダーを開始し、途中で一時停止し、後で再開することができます」ということが当時のリリースノートに書かれています。
この特性を用いて、最近多く用いられているSuspenseやTransitionといった機構が実現されているということは把握している人も少なくないと思います。この後掘り下げるのでTransitionについて少しだけ触れておくと、処理が少し遅れてもいいものを明示的に記述することで優先度が高い処理の完了を早め、パフォーマンスを高めるものです。
onCompletedと並列レンダリングの相性の悪さ
このissueの中で、apolloのmaintainerであるjerelmillerさんが(SuspenseQuery
における)onCompleted
はtransition
を中心とする機構と相性が良くないという趣旨の発言をしています。
これはライブラリそのものの設計方針の話とも取れますが、おそらくライブラリの使用者が実装する際の話を多分に含んでいると考えられます。例えば、Transitionを用いて実装する際にonCompletedというコールバック関数がどのような優先度え処理されるかは自明ではありません。つまり、上で書いた
const { data } = useQuery(GET_PROFILE, {
variables: { userId },
onCompleted: (result) => {
document.title = `${result.user.name}のページ`;
localStorage.setItem(`userName_${userId}`, result.user.name);
});
というコードのonCompletedは、実装者としては画面の更新に関わるわけでもなければ重要性も低いので優先度は間違いなく低いですが、onCompletedではこの制御はできません。また、内部的にレンダリングの完了との順序がどのようになっているかも不明なため、予期せぬ動作を引き起こす可能性があります。
useEffectを用いたtransitionの実装
一方で、useEffectでは非常に容易にTransitionを用いた実装が可能です。
const [, startTransition] = useTransition();
useEffect(() => {
if (data?.user?.name) {
startTransition(() => {
document.title = `${data.user.name} - Profile`;
});
}
}, [data]);
この処理はEffectの中に閉じていることから、 レンダリングが完了した後に、低い優先度で 実行されます。このような切り分けが容易に実装できるのがuseEffectの強みです。
根本的な思想の違い
このような書き方の容易性の違いが出る要因は、その設計思想の違いにあります。onCompletedは、思想としておそらく冒頭のメリットでも述べたようにデータフェッチに関する処理を集約することを目的として設計されています。
一方で、v18以降のReactやuseEffectではUIを状態管理から切り離し、純粋なレンダリングと副作用の分離を行うことを強く意識しています。これにより、レンダリングのパフォーマンスを向上させるとともに、レンダリング中に副作用が発生するなどの理由で予期せぬ挙動を示すことを防いでいます。
この思想はレンダリングと副作用を密に結合させてしまうonCompletedと相性が悪く、useQueryやSuspenseQueryでも使わないことが推奨され始めていると言えます。
また、Reactの特徴の1つとして「宣言的UI」が挙げられますが、onCompletedは命令的な処理の部類に属します。これも相性が悪い理由の1つになります。
一番メインのライブラリはReactなので、その思想に従っておこうという話ですね。
(レンダリングとデータフェッチを分離するようにすれば解決するのではという話もありますが、React + Apollo GraphQLではデータフェッチもレンダリングの一部として扱われていると思われます)
そのほかの理由
今あげたもの以外にもいくつかonCompletedが適切ではなくなる理由があります。以下にその理由を挙げておきます。
- クリーンアップができない
- useEffectで可能なこういった処理が書けません
useEffect(() => {
const timer = setTimeout(() => {
console.log('Delayed action');
}, 1000);
return () => clearTimeout(timer); // クリーンアップ処理
}, [data]);
- 複数のqueryから取得してくる場合の処理が書けないなど、柔軟性が低い
-
- StrictModeで複数回発火してしまうことを考えると、おそらくReact側では想定されていない動作である
まとめ
以上のように、onCompletedは一見直感的で便利な関数ですが、残念ながら最近のReactの思想とはいまいち噛み合っていません。
useEffectもuseEffectでそのエフェクトは不要かものようにアンチパターンとされている使用例が多く、扱い方に注意が必要なhookではありますが、今回のような用法は「外部と同期される」使い方なのでuseEffectの仕様が想定されているパターンとなります。
このような事情から、Apollo GraphQLのmaintainerも非推奨で、useEffectを使ってねという雰囲気を漂わせているんだと思います。それならそれでapollo側で明示的に非推奨にしてもいいような気がしますね。
Reactの進化に伴って、onCompletedのようなコールバックベースの処理は徐々に使いづらくなっています。代わりに、useEffectを活用して副作用を明示的に管理することで、Reactの設計哲学に即したコードを書くことがより主流になり始めています。Apollo GraphQLでもこの方向性を受け入れる設計変更が進んでいるように思えます。
参考
本文中であげたもののほか、この記事も参考にしています