🍣

目玉機能満載の Apollo Client 3.8 のリリース紹介

2023/08/13に公開

先日 Apollo Client 3.8 がリリースされました。このバージョンは Apollo Client 史上最大のマイナーリリースで、待望の Suspense 対応や、他にも様々な機能が含まれています。今回は、リリースされた機能と、その使い方について紹介したいと思います!

Suspense 対応

まずは一番の目玉である Suspense 対応です。Relay や urql が Suspense に対応している中、ついに Apollo Client も Suspense に対応しました!
今回のリリースでは Suspense に対応した 3 つ hooks が追加されたので順番に紹介していきます。

useSuspenseQuery

useSuspenseQueryuseQueryの Suspense 対応版のようなイメージで、リクエストが行われている最中は呼び出し元のコンポーネントをサスペンドさせます。
Apollo Client を使ったことある方であれば、一度は下記のような処理を書いたことがあるのではないでしょうか?
loadingによってローディング中の表示を制御し、dataundefinedの可能性があるので、値が存在するかチェックをしてから使用します。

export const SomeComponent: React.FC = () => {
  const { data, loading } = useQuery(SomeQuery);

  if (loading) return <>Loading...</>;
  return <>{data && <>{/* dataを使った表示 */}</>}</>;
};

これがuseSuspenseQueryでは、ローディング中はサスペンドしてくれるので、ローディング中の表示は親コンポーネントで指定します。また、コンポーネントがレンダリングされるのはリクエストが完了したタイミングになるので、dataundefinedの可能性がなりくなり、存在のチェックを行う必要がなくなります!

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>
  );
};

useBackgroundQueryuseReadQueryによるウォーターフォール問題の解決

このウォーターフォール問題をuseBackgroundQueryuseReadQueryを使用することで解決できます!
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ディレクティブ

https://speakerdeck.com/kazukihayase/reacttographqlteshi-xian-suruxuan-yan-de-tetahuetuti?slide=14

以前から 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]

  // ...
};

Apollo Client のuseFragmentを使用すると良い理由

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 ドキュメントの変換が可能になりました。

__typenameidなど、キャッシュの正規化に必ず必要なので、アプリケーション全体で漏れなく取得させたいフィールドがある場合や、逆に権限などの条件によっては含めたくないフィールドを削除するなどの使い方ができそうです。

公式ドキュメントに id フィールドを必ず含めるという実装のサンプルがあるので、興味のある方はご覧ください。

https://www.apollographql.com/docs/react/data/document-transforms/#write-your-own-document-transform

新しいエラー抽出メカニズム

エラーの抽出には処理コストがかかり、その結果バンドルサイズが増えてしまいます。これに対応するために、以前のバージョンではエラーメッセージを減らすことで、バンドルサイズの節約を行ってました。ただそれにより、エラーの原因のファイルをファイルシステムから特定する必要があったり、エラーに関する動的な情報が失われていたりしました。

上記の課題に対して今回のリリースで、エラーメッセージ自体はコアバンドルから省略することでバンドルサイズを減らし、代わりにエラーページへのリンクが含まれるようになりました。これにより、バンドルサイズを減らしつつ、エラーの詳細を提供できるようになっています!

下記はエラーメッセージとエラーページの例です。

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

https://go.apollo.dev/c/err#{"version"%3A"3.8.1"%2C"message"%3A69%2C"args"%3A["2"]}

開発環境では下記のようにloadErrorMessagesloadDevMessagesを実行することで、エラーメッセージ全文を受け取ることができます。

import { loadErrorMessages, loadDevMessages } from "@apollo/client/dev";

if (process.env.NODE_ENV !== "production") {
  loadErrorMessages();
  loadDevMessages();
}

まとめ

Apollo Client 史上最大のマイナーリリースなだけあり、かなり大きめな機能がいくつもリリースされていて、リリースノートを読むのがとても楽しかったです!
特に Suspense 対応は React18 がリリースされてから、待ちに待った機能なので嬉しいです。
useBackgroundQueryの lazy バージョンや、useFragmentuseBackgroundQuery対応など、現時点で実装されていない機能も今後追加される予定なので、引き続きキャッチアップしていこうと思います。

https://github.com/KazukiHayase/apollo3.8-sample

参考

https://www.apollographql.com/blog/announcement/frontend/wait-for-it-announcing-apollo-client-3-8-with-react-suspense-integration/
https://github.com/apollographql/apollo-client/releases/tag/v3.8.0

株式会社BuySell Technologies

Discussion