目玉機能満載の Apollo Client 3.8 のリリース紹介
先日 Apollo Client 3.8 がリリースされました。このバージョンは Apollo Client 史上最大のマイナーリリースで、待望の Suspense 対応や、他にも様々な機能が含まれています。今回は、リリースされた機能と、その使い方について紹介したいと思います!
Suspense 対応
まずは一番の目玉である Suspense 対応です。Relay や urql が Suspense に対応している中、ついに Apollo Client も Suspense に対応しました!
今回のリリースでは Suspense に対応した 3 つ hooks が追加されたので順番に紹介していきます。
useSuspenseQuery
useSuspenseQuery
はuseQuery
の Suspense 対応版のようなイメージで、リクエストが行われている最中は呼び出し元のコンポーネントをサスペンドさせます。
Apollo Client を使ったことある方であれば、一度は下記のような処理を書いたことがあるのではないでしょうか?
loading
によってローディング中の表示を制御し、data
はundefined
の可能性があるので、値が存在するかチェックをしてから使用します。
export const SomeComponent: React.FC = () => {
const { data, loading } = useQuery(SomeQuery);
if (loading) return <>Loading...</>;
return <>{data && <>{/* dataを使った表示 */}</>}</>;
};
これがuseSuspenseQuery
では、ローディング中はサスペンドしてくれるので、ローディング中の表示は親コンポーネントで指定します。また、コンポーネントがレンダリングされるのはリクエストが完了したタイミングになるので、data
がundefined
の可能性がなりくなり、存在のチェックを行う必要がなくなります!
export const UserWithUseSuspenseQuery: React.FC = () => {
return (
<Suspense fallback={<div>Loading...</div>}>
<User />
</Suspense>
);
};
const User: React.FC = () => {
const { data } = useSuspenseQuery(FetchUserQueryForSuspense);
return (
<div>
<h1>User</h1>
{/* dataの存在チェックをしなくてもいい! */}
<h2>name: {data.user.name}</h2>
</div>
);
};
また、Transitionsも利用可能で、refetch の際に fallback に指定したコンポーネントではなく、直前のデータでの表示を維持して UX の向上を図ることもできます。
import { startTransition } from "react";
const User: React.FC = () => {
const { data, refetch } = useSuspenseQuery(FetchUserQueryForSuspense);
const handleRefetch = () => {
startTransition(() => {
refetch();
});
};
// ...
};
エラーハンドリング
エラーハンドリングに関しても変更があります。今まではuseQuery
の返り値のerror
を使用することでエラーを表示していました。それがuseSuspenseQuery
ではReact Error Boundariesを最大限活用する方針になり、エラーが発生した場合はエラーを throw するようになっています。そのためローディングと同じように、親コンポーネント側でErrorBoundary
でラップして制御する形になります。
<ErrorBoundary fallback={<p>Something went wrong</p>}>
<User />
</ErrorBoundary>
errorPolicy
を指定することで、今まで通りerror
を使った処理を実装することもできます。
const { data, error } = useSuspenseQuery(SomeQuery, {
errorPolicy: "all",
});
useBackgroundQuery
, useReadQuery
ウォーターフォール問題
useSuspenseQuery
によってサスペンスに対応させたコンポーネントを実装することができるようになりました。ただ、コンポーネントの階層が深くなり、それぞれのコンポーネントでuseSuspenseQuery
を呼び出してしまうと、ウォーターフォール問題が発生してしまいます。
下記はネストしたコンポーネントでそれぞれuseSuspenseQuery
を呼び出し、ウォーターフォール問題が発生している例です。
User のローディングが完了した後に、Todos のローディングが発生しています。
export const UserWithUseSuspenseQuery: React.FC = () => {
return (
<Suspense fallback={<div>Loading...</div>}>
<User />
</Suspense>
);
};
const User: React.FC = () => {
const { data } = useSuspenseQuery(FetchUserQueryForSuspense, {
variables: { id: "1" },
});
const user = data.user;
if (!user) return <>Not found</>;
return (
<div>
<h1>User</h1>
<h2>name: {user.name}</h2>
<Suspense fallback={<div>Loading...</div>}>
<Todos />
</Suspense>
</div>
);
};
const Todos: React.FC = () => {
const { data } = useSuspenseQuery(FetchTodosQueryForSuspense);
const todos = data.todos;
return (
<div>
<h1>Todos</h1>
<ul>
{todos.map((todo: any) => (
<li key={todo.id}>
{todo.text} {todo.done ? "✅" : "❌"}
</li>
))}
</ul>
</div>
);
};
useBackgroundQuery
とuseReadQuery
によるウォーターフォール問題の解決
このウォーターフォール問題をuseBackgroundQuery
とuseReadQuery
を使用することで解決できます!
useBackgroundQuery
は呼び出されたタイミングでデータフェッチを開始します。useBackgroundQuery
から受け取ったqueryRef
を渡してuseReadQuery
を呼び出すことで、データの読み込みを行うことができます。データフェッチが完了する前にuseReadQuery
が呼び出された場合は、呼び出し元のコンポーネントがサスペンドされます。
そのため、トップレベルのコンポーネントであらかじめuseBackgroundQuery
を呼び出しておくことで、バックグラウンドでデータフェッチを走らせておけるので、ウォーターフォール問題を解決することができます!
export const UserWithUseBackgroundQuery: React.FC = () => {
// ここでデータフェッチ開始するので、Userがサスペンドされてもウォーターフォール問題は発生しない
const [queryRef] = useBackgroundQuery(FetchTodosQueryForBackground);
return (
<Suspense fallback={<div>Loading...</div>}>
<User queryRef={queryRef} />
</Suspense>
);
};
const User: React.FC<{
queryRef: QueryReference<FetchTodosQueryForBackgroundQuery>;
}> = ({ queryRef }) => {
const { data } = useSuspenseQuery(FetchUserQueryForBackground, {
variables: { id: "1" },
});
const user = data.user;
if (!user) return <>Not found</>;
return (
<div>
<h1>User</h1>
<h2>name: {user.name}</h2>
<Suspense fallback={<div>Loading...</div>}>
<Todos queryRef={queryRef} />
</Suspense>
</div>
);
};
const Todos: React.FC<{
queryRef: QueryReference<FetchTodosQueryForBackgroundQuery>;
}> = ({ queryRef }) => {
const { data } = useReadQuery(queryRef);
const todos = data.todos;
return (
<div>
<h1>Todos</h1>
<ul>
{todos.map((todo: any) => (
<li key={todo.id}>
{todo.text} {todo.done ? "✅" : "❌"}
</li>
))}
</ul>
</div>
);
};
パフォーマンス観点
useBackgroundQuery
ではデータフェッチの開始のみを行い、データの読み込みやレンダリングは担当せず、データの読み込みやレンダリングはuseReadQuery
が担当します。そのため、キャッシュが更新された場合、再レンダリングされるのはuseReadQuery
を呼び出しているコンポーネントのみで、useBackgroundQuery
を呼び出しているコンポーネントは再レンダリングされません。これにより、再レンダリングされるコンポーネントは下層のコンポーネントのみになるので、パフォーマンスが向上します。
useFragment
と@nonreactive
ディレクティブ
以前から experimental で公開されていたuseFragment
が、今回のリリースで Stable になりました!
useFragment
は定義した Fragment で定義したデータにアクセスするための hooks です。useQuery
などと同じように、アクセスしてる Fragment のデータに更新があった場合は、コンポーネントが再レンダリングが走り、常に最新のデータを取得することができます。
今までは Apollo Client 単体では Fragment Colocation を行うのが難しかったのですが、useFragment
によってそれが改善されます。
また、後述しますが、GraphQL Code Generator と組み合わせることで、パフォーマンス・開発者体験のどちらも高めることが可能になります。
const Todo: React.FC<{ id: string }> = ({ id }) => {
const { complete, data: todo } = useFragment({
fragment: TodoFragmentForUseFragment,
fragmentName: "TodoFragmentForUseFragment",
from: {
__typename: "Todo",
id,
},
});
if (!complete) return null;
return (
<li>
{todo.text} {todo.done ? "✅" : "❌"}
</li>
);
};
@nonreactive
ディレクティブ
@nonreactive
ディレクティブは Query のフィールドや、Fragment のスプレッド構文に対して使用できるディレクティブです。@nonreactive
ディレクティブを使用すると、そのフィールドや Fragment のサブツリーに含まれるデータに変更があったとしても、再レンダリングが発生しなくなります。
下記は Todo リストを表示し、その内の一つの Todo を更新する例です。
@nonreactive
ディレクティブを使用してない場合は、更新後にページ全体が再レンダリングされているのに対し、@nonreactive
ディレクティブを使用した場合はリスト内の該当の Todo 部分のみが再レンダリングされています。
@nonreactive
ディレクティブを使用しない場合
query FetchUserQueryForUseFragment($id: ID!) {
user(id: $id) {
id
name
todos {
id
...TodoFragmentForUseFragment
}
}
}
fragment TodoFragmentForUseFragment on Todo {
id
text
done
}
実装
import { Suspense } from "react";
import { graphql } from "../gql/gql";
import { useFragment, useMutation, useSuspenseQuery } from "@apollo/client";
const FetchUserQueryForUseFragment = graphql(/* GraphQL */ `
query FetchUserQueryForUseFragment($id: ID!) {
user(id: $id) {
id
name
todos {
id
...TodoFragmentForUseFragment
}
}
}
`);
export const UserWithUseFragment: React.FC = () => {
return (
<Suspense fallback={<div>Loading...</div>}>
<User />
</Suspense>
);
};
const User: React.FC = () => {
const userId = "1";
const { data } = useSuspenseQuery(FetchUserQueryForUseFragment, {
variables: { id: userId },
});
const user = data.user;
if (!user) return <>Not found</>;
return (
<div>
<h1>User</h1>
<h2>name: {user.name}</h2>
<h1>Todos</h1>
<ul>
{user.todos.map(({ id }) => (
<Todo key={id} id={id} />
))}
</ul>
</div>
);
};
const ToggleDoneMutation = graphql(/* GraphQL */ `
mutation ToggleDoneMutation($id: ID!, $done: Boolean!) {
updateTodo(input: { id: $id, done: $done }) {
id
done
}
}
`);
const TodoFragmentForUseFragment = graphql(/* GraphQL */ `
fragment TodoFragmentForUseFragment on Todo {
id
text
done
}
`);
const Todo: React.FC<{ id: string }> = ({ id }) => {
const [toggleDone] = useMutation(ToggleDoneMutation);
const { complete, data: todo } = useFragment({
fragment: TodoFragmentForUseFragment,
fragmentName: "TodoFragmentForUseFragment",
from: {
__typename: "Todo",
id,
},
});
if (!complete) return null;
return (
<li key={todo.id}>
{`${todo.text} `}
<span
onClick={() =>
toggleDone({ variables: { id: todo.id, done: !todo.done } })
}
style={{ cursor: "pointer" }}
>
{todo.done ? "✅" : "❌"}
</span>
</li>
);
};
@nonreactive
ディレクティブを使用した場合
query FetchUserQueryForUseFragmentNonreactive($id: ID!) {
user(id: $id) {
id
name
todos {
id
# 下記で@nonreactiveを指定
...TodoFragmentForUseFragmentNonreactive @nonreactive
}
}
}
fragment TodoFragmentForUseFragmentNonreactive on Todo {
id
text
done
}
実装(全体)
import { Suspense } from "react";
import { graphql } from "../gql/gql";
import { useFragment, useMutation, useSuspenseQuery } from "@apollo/client";
const FetchUserQueryForUseFragmentNonreactive = graphql(/* GraphQL */ `
query FetchUserQueryForUseFragmentNonreactive($id: ID!) {
user(id: $id) {
id
name
todos {
id
...TodoFragmentForUseFragmentNonreactive @nonreactive
}
}
}
`);
export const UserWithUseFragmentNonreactive: React.FC = () => {
return (
<Suspense fallback={<div>Loading...</div>}>
<User />
</Suspense>
);
};
const User: React.FC = () => {
const userId = "1";
const { data } = useSuspenseQuery(FetchUserQueryForUseFragmentNonreactive, {
variables: { id: userId },
});
const user = data.user;
if (!user) return <>Not found</>;
return (
<div>
<h1>User</h1>
<h2>name: {user.name}</h2>
<h1>Todos</h1>
<ul>
{user.todos.map(({ id }) => (
<Todo key={id} id={id} />
))}
</ul>
</div>
);
};
const ToggleDoneMutation = graphql(/* GraphQL */ `
mutation ToggleDoneMutation($id: ID!, $done: Boolean!) {
updateTodo(input: { id: $id, done: $done }) {
id
done
}
}
`);
const TodoFragmentForUseFragmentNonreactive = graphql(/* GraphQL */ `
fragment TodoFragmentForUseFragmentNonreactive on Todo {
id
text
done
}
`);
const Todo: React.FC<{ id: string }> = ({ id }) => {
const [toggleDone] = useMutation(ToggleDoneMutation);
const { complete, data: todo } = useFragment({
fragment: TodoFragmentForUseFragmentNonreactive,
fragmentName: "TodoFragmentForUseFragmentNonreactive",
from: {
__typename: "Todo",
id,
},
});
if (!complete) return null;
return (
<li key={todo.id}>
{`${todo.text} `}
<span
onClick={() =>
toggleDone({ variables: { id: todo.id, done: !todo.done } })
}
style={{ cursor: "pointer" }}
>
{todo.done ? "✅" : "❌"}
</span>
</li>
);
};
GraphQL Code Generator の Fragment Masking との棲み分け
GraphQL Code Generator のclient-preset
に組み込まれている Fragment Masking を使用することでも、Fragment Colocation を実現できます。
そちらとの棲み分けですが
- Query の定義は GraphQL Code Generator で生成されるgraphql 関数を使用
- データの取得は Apollo Client の
useFragment
を使用
がいいと思います。
理由は Fragment Colocation の強制をしつつ、パフォーマンスを向上させることができるからです。
GraphQL Code Generator の graphql 関数を使用すると良い理由
下記は GraphQL Code Generator の graphql 関数を使って Query を定義した例なのですが、生成される型情報に Fragment のフィールドの情報は含まれないので、Fragment で定義されているフィールドにはdata
からアクセスすることができません。
これにより、Fragment のデータにアクセスするためには、GraphQL Code Generator で生成されるヘルパーのuseFragment
か、Apollo Client のuseFragment
を使用する必要があるので、Fragment Colocation を強制することができます。
そのため、Query の定義には GraphQL Code Generator の graphql 関数を使用する方が良いです。
const FetchUserQueryForUseFragment = graphql(/* GraphQL */ `
query FetchUserQueryForUseFragment($id: ID!) {
user(id: $id) {
id
name
todos {
id
...TodoFragmentForUseFragment
}
}
}
`);
const TodoFragmentForUseFragment = graphql(/* GraphQL */ `
fragment TodoFragmentForUseFragment on Todo {
id
text
done
}
`);
const User: React.FC = () => {
const userId = "1";
const { data } = useSuspenseQuery(FetchUserQueryForUseFragment, {
variables: { id: userId },
});
console.log(data.text);
// コンパイルエラー、QueryのdataからはFragment内部のフィールドにはアクセスできない
// typescript: Property 'text' does not exist on type 'FetchUserQueryForUseFragmentQuery'. [2339]
// ...
};
useFragment
を使用すると良い理由
Apollo Client のGraphQL Code Generator では graphql 関数と一緒に、ヘルパー関数であるuseFragmentも生成されます。このヘルパーを利用することで、Fragment のフィールドにアクセスすることができます。ただ、このuseFragment
は内部的には型変換を行っているだけで、実際は hooks ではなくただのヘルパー関数です。そのため、Fragment のデータのみに変更があった場合も、Query の呼び出しを行っている親コンポーネント配下全てが再レンダリングされてしまいます。
そこで、データの取得には@nonreactive
と Apollo Client のuseFragment
を使用することで、Fragment のデータのみに変更が場合は、その Fragment に対応するコンポーネントのみが再レンダリングされ、パフォーマンスの向上を図ることができます。
removeTypenameFromVariables
Link
removeTypenameFromVariables
はリクエストの variables から__typename
を自動的に取り除いてくれる Apollo Link です。
Apollo Client ではキャッシュの都合で自動的に Query のフィールドに__typename
が追加されるようになっています。そのため、Query で取得したデータをそのまま Mutation の variables で使用してしまうと、variables に__typename
が含まれてしまいエラーになってしまうことが多々ありました。
これは個人的にデータの更新処理などでよく発生した問題で、その際は下記のように Mutation を実行する前に明示的に__typename
を取り除いていたのですが、非常にめんどくさかったです。
const { __typename: _, ...rest } = someData;
そこでremoveTypenameFromVariables
を使うことで、上記のような実装をすることなく、リクエストの variables から__typename
を取り除くことができます!
skipToken
hooks は条件付きで呼び出すことはできないので、skip オプションを使用する場合、型解決ができない場合がありました。
const { data } = useQuery(SomeQuery, {
variables: { id },
skip: !id,
});
// => Type 'number | undefined' is not assignable to type 'number'.
// Type 'undefined' is not assignable to type 'number'.ts(2769)
上記の例では id が存在する場合のみ Query を実行するので、variables に渡してる実データの id がundefined
になることはあり得ないのですが、型解決ができずコンパイルエラーになってしまいます。そのため、下記のように無理やり解決しなければいけませんでした。
const { data } = useSuspenseQuery(SomeQuery, {
variables: { id: id! },
skip: !id,
});
// or
const { data } = useSuspenseQuery(query, {
variables: { id: id ?? 0 },
skip: !id,
});
そこで、今回リリースされたskipToken
を使用することで、型安全に Query をスキップさせることができるようになりました!
import { skipToken } from "@apollo/client";
const { data } = useSuspenseQuery(
query,
id ? { variables: { id } } : skipToken
);
Document transforms
Apollo Client では Qeury を実行する際に自動的に__typename
フィールドが追加されます。これは GrahQL ドキュメントを変換する document transforms という内部的な機能によるものなのですが、この機能が今回のリリースで公開され、自由に利用できるようになりました!
これにより、リクエスト前に自由に GraphQL ドキュメントをカスタマイズすることができるようになります。
以前も Apollo Link によって実現はできたのですが、キャッシュが GraphQL ドキュメントの変更を認識できませんでした。それにより Link 内で安全に実行できる変換の種類が制限されてましたが、今回の機能が公開されたことで、より自由に GraphQL ドキュメントの変換が可能になりました。
__typename
とid
など、キャッシュの正規化に必ず必要なので、アプリケーション全体で漏れなく取得させたいフィールドがある場合や、逆に権限などの条件によっては含めたくないフィールドを削除するなどの使い方ができそうです。
公式ドキュメントに id フィールドを必ず含めるという実装のサンプルがあるので、興味のある方はご覧ください。
新しいエラー抽出メカニズム
エラーの抽出には処理コストがかかり、その結果バンドルサイズが増えてしまいます。これに対応するために、以前のバージョンではエラーメッセージを減らすことで、バンドルサイズの節約を行ってました。ただそれにより、エラーの原因のファイルをファイルシステムから特定する必要があったり、エラーに関する動的な情報が失われていたりしました。
上記の課題に対して今回のリリースで、エラーメッセージ自体はコアバンドルから省略することでバンドルサイズを減らし、代わりにエラーページへのリンクが含まれるようになりました。これにより、バンドルサイズを減らしつつ、エラーの詳細を提供できるようになっています!
下記はエラーメッセージとエラーページの例です。
An error occured! For more details, see the full error text at https://go.apollo.dev/c/err#%7B%22version%22%3A%223.8.1%22%2C%22message%22%3A69%2C%22args%22%3A%5B%222%22%5D%7D
開発環境では下記のようにloadErrorMessages
とloadDevMessages
を実行することで、エラーメッセージ全文を受け取ることができます。
import { loadErrorMessages, loadDevMessages } from "@apollo/client/dev";
if (process.env.NODE_ENV !== "production") {
loadErrorMessages();
loadDevMessages();
}
まとめ
Apollo Client 史上最大のマイナーリリースなだけあり、かなり大きめな機能がいくつもリリースされていて、リリースノートを読むのがとても楽しかったです!
特に Suspense 対応は React18 がリリースされてから、待ちに待った機能なので嬉しいです。
useBackgroundQuery
の lazy バージョンや、useFragment
のuseBackgroundQuery
対応など、現時点で実装されていない機能も今後追加される予定なので、引き続きキャッチアップしていこうと思います。
参考
Discussion