まくろぐ

読書メモ『夏への扉』ロバート・A・ハインライン

更新:
作成:

タイムトラベルものの小説のひとつ、というかそのジャンルを確立させたとも言われているのがこの『夏への扉』です。 ロバート・A・ハインラインがはるか昔の1956年に書いたものですが、今読んでもまったく色褪せておらず、夏になると読み直したくなるという人が多いというのも納得です(別に夏に読む必要はないけどね)。 日本で SF ランキングを取ると今でも上位に出てくるので、日本人の感性に合っているのかもしれません。 そして、長い年を経て(65年!)、今年ついに映画化 されました。 なんと舞台は日本です。

原作では1970年と2000年の間を時間旅行する設定になっており、ハインラインが1950年代に想像した2000年の世界が描かれているのですが、さすがにインターネットの登場までは予想できなかったらしく、情報のやりとりが手紙だったり、新聞が配達チューブで届くところなんかは時代を感じさせます。 それでもリアリティが損なわれていないのはさすがです。

ちなみに、物語に登場する牡猫のピートは、ハインラインが実際に飼っていた猫のピクシーがモデルみたいです。 ピートは決しておとなしい猫ではないけれど、その振る舞いの描写から、著者の猫への愛が伝わってきます。 しかし、この傑作をハインラインはわずか13日間で書き上げてしまったというのは本当でしょうか?

ストーリーまとめ

以下、あらすじ、まとめ、ネタバレ注意です。 章のタイトルは私が勝手につけました。

第1章 逃亡

主人公(ダン・デイビス)は、コールドスリープして未来にいきたい理由をつらつらと語り出す。 ベル・ダーキンと、マイルズ・ジェントリーのいなくなった世界に行きたい。 もうあの2人と関わりたくない。 目標は30年後の西暦2000年だ。 猫のピートも連れていきたい。

コールドスリープに入る前の身体検査で医者に言われた。

「きみはどのくらいの期間このどんちゃん騒ぎを続けている?」

▼デイビスは黙って「2週間あまりです」と答えるが、まだ読者には何のことなのか分からない。

デイビスは手続きを済ませ、翌日コールドスリープに入ることが決まった。

第2章 裏切り

人間の新陳代謝を止める方法は、1930年代から理論的には実現可能であることが知られていた。 しかし、実際に何に使われていたかというと、戦争だった。 人間を兵器のように保存しておいて、必要なときに蘇生させるのだ。 戦争が終わると、この技術がコールドスリープとして保険会社の商品として扱われるようになった。

デイビスはドライブインで食事を済ませて落ち着くと、本当にコールドスリープに入っていいのか自問自答を始める。 この状況から逃げ出すことが正しい答えなのか? ベルとマイルズの問題は片付けなくていいのか? いや、今からマイルズのところに行ってぶん殴ってやる!

マイルズ・ジェントリー は、徴兵時代の親友だった。 その娘の リッキー(フレデリカ)が、猫のピートの世話をしてくれていた。

デイビスとマイルズは、一緒に事業を始めた。 ハイヤーガールの制作だ。 賢い掃除ロボットしてヒットし、その後、ベル・ダーキン という女性が仲間に加わった。 株式会社の登録も済ませ、開発のほとんどを担当していたデイビスは51%の株式を保有することになった。 デイビスは容姿端麗なベルに惚れて、事業の拡大が落ち着き次第結婚しようという約束をした。 だから、持株の一部も彼女に譲渡した。

一方、マイルズの娘のリッキーは激しく嫉妬していた。 小さい頃、デイビスと恋人ごっこをして将来結婚しようと約束していたからだ(年は大分離れているが)。

ベルは猫のピートのことも好きだった(かのように見せかけていた)。 それはデイビスが好きなものを自分も好きなのだと思わせるためだった。

ある日、マイルズの提案で緊急の株主総会が開かれることになる(3人だけだが)。 マイルズからのとんでもない株主提案は2対1で可決されることになり、デイビスはベルから「相当なおバカさんね」と捨てゼリフを吐かれる。 デイビスの知らないうちに、マイルズとベルは結婚していた。 2人に裏切られたデイビスは解雇され、いつの間にかベルによって強制的にサインさせられていた書類によって、5年間は同業種に就くことさえ許されない状態になっていた。 最後に発明した「万能フランク」も消え去っていた。

第3章 口論

デイビスがマイルズの家に到着すると、ベルもそこにいて、3人の壮絶な口論が始まる。 デイビス vs マイルズ&ベルという構図だったが、最後に「どうしてベルは2人に近づいたのか?」という核心に迫りそうになると、ベルの態度が豹変する。 そのとき、デイビスはうかつにもベルに背中を見せてしまった。

第4章 冷凍睡眠

ベルが本性を表し、デイビスは薬で催眠状態にされてしまう。 デイビスの荷物の中からコールドスリープの書類を見つけたベルは策略を練り始める。 ベルは書類を偽造するために、マイルズに会社のタイプライターを取りに行かせようとするが、そのとき、デイビスが乗ってきたはずの車がなくなっていることに気づく。

翌朝、まだ催眠状態のデイビスは2人に連れられてコールドスリープのための冷凍場(サンクチュアリ)までやってきた。 デイビスはなされるがままにコールドスリープに入ってしまう。

第5章 目覚め

コールドスリープから目覚めたデイビスは、色々な感情が混ざり合っていたが、意識や記憶ははっきりしていた。 まずは何よりも西暦2000年の様子を見たかった。 そういえば、猫のピートは入れてもらえなかったが、30年後のこの世界でもまだどこかで生きているだろうか。

ドクターの話を聞く限り、2000年の世界に来ていることは間違いなさそうだ。 新聞のレイアウトはそれほど変わっていないが、印刷された写真が立体に見える。 よく見ると、冷凍場から発表される「入場者」と「退場者」のリストが載っている。

デイビスはリッキーの所在を確かめることを何より先にしようと考えた。 冷凍場を後にすると、保険会社のオフィスで「シュルツ夫人」なる人物から電話があったと知らされる。

第6章 西暦2000年

サンクチュアリ(冷凍場)を出るとき、いろいろな事情で結局全財産は 400 ドルしか残っていなかった。 滑走道路のステーションで一晩過ごそうとしただけで30日の禁固刑を言い渡されてしまったが、2日目にはなんとかグレート・ロサンゼルスで職に就くことができた。 それは失業対策のための水増し雇用だったが、デイビスは新世界のロサンゼルスを気に入った。 風邪というものは一掃され、重力制御法が発見されて物体の移動が楽になっていた。

デイビスは技術職に戻りたかったので、(30年前に自分が作った)ハイヤーガール株式会社に就職した。 憎きベルとマイルズはそこにはおらず、金銭的な問題もあって、デイビスは2人を探すことを諦めてしまう。 ただし、たった1人の肉親であるリッキーの行方だけはつきとめたかった。 コールドスリープに入る前に、ハイヤーガールの株をリッキーに譲渡するように手配していたのだが、信託先のバンク・オブ・アメリカの記録にはリッキーの名前はなかった。

デイビスの技術知識はもはや時代遅れのものになっていたので、ハイヤーガールは主に宣伝効果を目的としてデイビスを雇うことにした。 いくつかの雑誌に創業者デイビスの広告が載った頃、再びミセス・シュルツなる人物から電話がかかってきた。 会社で電話に応じると、それはあのベルだった。

第7章 シュルツ夫人

デイビスはベルに会うことにした。 正直なところ、ベルのことに関して興味はなくなっていたが、リッキーの居場所を知っているに違いなかったからだ。

ベルはデイビスを見ると喜びをあらわにした。 1970年のことはデイビスのためにやったことで、マイルズはその2年後に死んだと言う。 死んだ理由はわからない。 最後に発明した「万能フランク」はコールドスリープ前に消え去ってしまったが、デイビスが盗んだのだと言う。

結局ベルからは、リッキーのお祖母さんの名前がヘイニカーとかなんとかいう名であることを聞いて、その場を後にする(どうやら H で始まる名前のようだ)。

第8章 手がかり

デイビスはリッキーを本格的に探し始めた。 リッキーに譲渡されるように手配していた株券は ハイニック という人間に渡っていた。 ベルの仕業に違いない。 ベルは何もかも根こそぎ奪おうとしているのか!

「アラジン工業」という会社が所有しているロボットの特許について調べてみると、なんと自分(デイビス)が発明者になっている。 次に「製図機ダン」の特許についても調べてみると、それも自分が発明したことになっている。 確かに自分と同じような発想の発明品だが、こんなものを作った覚えはない。 コールドスリープで記憶喪失になってしまったのだろうか?

よき同僚である チャック に相談してみると、コールドスリープではなく、すでに時間旅行(タイムトラベル)ができるようになっているのだという。 ただし、それは軍の機密事項になっており、一般には知られていない。 そのマシンには欠点があり、2つのものを同時に転送しなければならず、どちらか一方が過去へ、もう一方が未来へ行ってしまうという。 そして、行ったが最後で元の時間には戻って来られない。 しかもどの年代に飛ぶかもうまく制御できないらしい。

デイビスは初めのうちは30年前に戻って事実を明らかにすればいいと思っていたが、チャックの話を聞いているうちに、それがとても馬鹿げた考えだと気がついた。

デイビスは新聞の蘇生者リストの中から F・V・ハイニック という名前を見つけた。 デイビスにはそれがリッキーの祖母の名前だということが直感的にわかった。 ハイニックというのは、リッキーに渡すはずだった株券の所有者の名前だ。 慌てて冷凍場に電話をすると、ハイニックはすでに出て行ったという。 何が起きているんだ?

第9章 時間旅行

冷凍場に行くと、リッキーの写真を見せてもらうことができた。 彼女は20くらいの歳になっていたが、デイビスにはすぐにそれがリッキーだということがわかった。 どうやらリッキーは、祖母が死んだ直後にコールドスリープに入り、今はブローリーにいるらしい。 調べてみると、ユマですでに誰かと結婚していた。 崩れ落ちるデイビス。

デイビスはリッキーを探すことを諦め、トウィッチェル博士 と会うことにした。 同僚のチャックが、タイムマシンを作ったと言っていた人物で、一週間後には研究室を見学させてもらえることになった。 なんとか時間旅行の話に持ち込み、「時間転移の射程は正確に測定できないでしょう」と煽ると、博士はそんなことはないと言う。 そして、2枚の貨幣をマシンにかけて目の前から消してみせた。 今から一週間プラスマイナス6秒の誤差で転送したのだという。 一枚は一週間後にここにいれば現れるのを見ることができる。 もう一枚は博士のポケットに入っていた。 一週間前に博士はその貨幣をすでに手にしていたのだ。

博士は過去の苦い経験から二度と人間をこのマシンで転送しないと決めていた。 だがデイビスは博士をわざと怒らせて、その流れで自分を過去に転送させようとした。 自分が以前コールドスリープに入った日の6ヶ月前だ。 博士は憤り、デイビスをコールドスリープにかけた。

第10章 準備

デイビスの身体は転落し、どこかに叩きつけられた。 目を開けると、素っ裸の2人の男女が立っている。 全く恥ずかしがる気配がなく、どうやら過去ではなく未来に来てしまったようだ。 と思ったら、そこは単なるヌーディストクラブで、実際には1970年らしい。 うまくいった!

その男 ジョン・サットン は弁護士で、とても協力的であったおかげで、デイビスは数週間後には事務室を一室借りることができた。 まずはそこで「製図機ダン」の制作に取り掛かった。

▼ここで、これらの発明品の特許にデイビスの名前があったことが明らかになる。

デイビスは偶然にも若い頃のトウィッチェル博士に出会った。 30年後の世界で、博士が「どこかで会ったことがある」と言っていたのは正しかった。 また、私立探偵を雇ってベル(元婚約者)のことを調べさせると、想像した通り結婚詐欺の常習犯だった。

デイビスとジョンは、「製図機ダン」や「護民官ピート」を売るための会社を作ることにした。 会社の名前はもちろん「アラジン自動工業商会」で、場所はロサンゼルスだ。 あの忌まわしい1970年12月2日まで時間がない。急がなければ。

第11章 約束

1970年12月3日の夕刻、デイビスはロサンゼルスのマイルズ家の近くまでタクシーでやってきた。 あの壮絶な口論が繰り広げられる(た)ところだ。 しばらくすると、一台の車がやってきて停車した。 近づいてみると、まさしく自分の車だ。

デイビスはいつも車のトランクにスペアキーを入れていたので、その鍵を取り出して車に乗り込んだ。 マイルズの家のガレージに回ると、そこには(過去に)会社を乗っ取られたときに姿を消した発明品「万能フランク」があった。 デイビスはフランクをバラバラに分解して車に詰め込んだ。

▼最終的にフランクが消えたのはデイビスのせいだったということが明らかになる。

騒動の中で家から飛び出してきた猫のピートと車に乗り込み、ビッグベアのガールスカウトのキャンプへ向かった。 リッキーがいるところだ。 そこへ向かう道中で、フランクの部品を片っ端から投げ捨てていった。

リッキーはすでにマイルズとベルの結婚のことを知っていた。 ベルのことが嫌なので、お祖母さん(ハイニック)のところで暮らすつもりだという。 デイビスはリッキーに30年間のお別れになることを説明し、ハイヤーガールの株券の裏書きとしてリッキーの名前を書いた。 本名は フレデリカ・ヴァージニア・ハイニック

▼ここで、株券がハイニックという人物に渡っていた理由や、蘇生者リストにあった F・V・ハイニックというのがリッキー自身であったことが明らかになる。

どうしてもデイビスとピートと別れたくないというリッキーに、デイビスは将来コールドスリープに入ればいいという提案をする。 それは、リッキーが21歳になったとき、つまり、お祖母さんが亡くなって、株によるお金も溜まっている頃だ。 リッキーが未来に来たときに結婚するという約束をして2人は別れた。

デイビッドは町の冷凍場(サンクチュアリ)へ向かい、今度は猫のピートとともに再び30年の眠りについた。蘇生日程は2001年4月27日。 リッキーのいるあの暖かい夏の扉を開くんだ。。。

第12章 夏への扉

デイビスは何事もなく目を覚ました。 そして、リッキーの蘇生予定日の5月1日に冷凍場へ行き、無事2人(と1匹)は再開を果たした。 2人はユマの群役場へ行き、正式に籍を入れた。

▼リッキーが結婚した相手はデイビスだった。

デイビスはトウィッチェル博士にこれまでの経緯や謝罪の手紙を送り、博士に関する本を書くことにした。 タイトルは『埋もれた天才』だ。

リッキーとこれまでのことをいろいろと話した。 ある時点で自分は2人いたことになる。 そういえば、もうひとりの自分がサンクチュアリを出たという記事を見なかった。 でもそんなタイムパラドックスに関して心配などしない。 僕の夏への扉は見つかったんだ。

関連記事

Apollo Client の fetchMore を自動で呼び出して GitHub GraphQL の100件制限を乗り越える (useAutoFetchMore)

更新:
作成:

何をするか?

GitHub の GraphQL API で Issue 情報などを取得しようとすると、リソース制限 のため一度に 100 件までの情報しか取得できません。 Apollo Client が提供する useQueryuseLazyQuery などの React フック関数を使用すると、戻り値で返される fetchMore 関数を使って追加読み込み(ページネーション処理)を行うことができますが、この関数の使用例として提示されているものは、ユーザーによるボタンクリックなどを必要とするものばかりです。 ここでは、useQuery 実行後に自動で fetchMore を繰り返し呼び出して、100 件を超える情報を取得する方法の例を示します。

fetchMore のための設定

前提として、Apollo Client の fetchMore 関数の基本的な使い方は理解しているものとします(下記記事などを参考にしてください)。

今回サンプルコードで使う GraphQL クエリには、次のような search コネクションが含まれていることを想定しています。 ページネーションの対象となるのは、この search コネクション部分です。

query QueryIssues($cursor: String) {
  search(type: ISSUE, first: 100, after: $cursor, query: "...") {
    ...
  }
}

そのため、ApolloClient に設定するキャッシュのフィールドポリシーとして、search フィールドの値が fetchMore 時にマージされるように設定しておきます。 cache オブジェクトの生成時に呼び出している relayStylePagination 関数あたりがポイントです。

GitHubApolloProvider.tsx
import * as React from 'react'
import {
  ApolloClient,
  ApolloLink,
  ApolloProvider,
  createHttpLink,
  InMemoryCache,
} from '@apollo/client'
import { relayStylePagination } from '@apollo/client/utilities'
import { setContext } from '@apollo/client/link/context'
import { Auth } from '@/utils/auth'

const httpLink = createHttpLink({
  uri: 'https://api.github.com/graphql',
})

const authLink = setContext((_, { headers }) => {
  // Get the authentication token from local storage if it exists
  const token = Auth.getToken()

  // Return the headers to the context so httpLink can read them
  return {
    headers: {
      ...headers,
      authorization: token ? `Bearer ${token}` : '',
    } as ApolloLink,
  }
})

// GraphQL cache with field policies
const cache = new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        search: relayStylePagination(['type', 'query']), // ★
      },
    },
  },
})

// Create a GraphQL client
const apolloClient = new ApolloClient({
  link: authLink.concat(httpLink),
  cache,
})

export const GitHubApolloProvider: React.FC = (prop) => {
  return <ApolloProvider client={apolloClient}>{prop.children}</ApolloProvider>
}

あとは、上記ファイルで export されている GitHubApolloProvider コンポーネントをトップレベルのコンポーネントとして配置すれば、それ以下の階層で useQuery フックが適切に動作するようになります。 例えば、Next.js を使っている場合なら、カスタム App コンポーネントで次のような感じで配置すればよいでしょう。

pages/_app.tsx
function MyApp({ Component, pageProps }: AppProps): JSX.Element {
  return (
    <>
      <Head>
        <title>My App</title>
        <meta name="description" content="すんごいアプリ" />
      </Head>
      <GitHubApolloProvider>
        <Component {...pageProps}></Component>
      </GitHubApolloProvider>
    </>
  )
}

export default MyApp

fetchMore を自動呼出しするためのフック関数 (useAutoFetchMore) を作る

fetchMore を自動で繰り返し呼び出す仕組みですが、ここでは useEffect を使って、pageInfo.hasNextPage の値などが変化したときに fetchMore を呼び出すようにしてみます。 この実装をコンポーネントのコードに入れてもいいのですが、ある程度汎用的に使える処理なので、useAutoFetchMore フック関数として定義します。

src/hooks/useAutoFetchMore.ts
import { useEffect, useRef } from 'react'
import { ApolloError } from '@apollo/client'

/**
 * ApolloClient (useQuery) の fetchMore を自動で繰り返し呼び出すためのフック関数です。
 *
 * 各パラメーターには、useQuery() の戻り値をそのまま設定します。
 * ページネーションは Relay スタイル(pageInfo や edges)で提供されていることを前提とし、
 * fetchMore の呼び出しごとにクエリ変数 (variables) の cursor の値が更新されていきます。
 * 手違いによる大量呼び出しを防ぐため、最大呼び出し回数 (maxAutoFetch) を設定できます。
 */
export function useAutoFetchMore(
  loading: boolean,
  error: ApolloError | undefined,
  pageInfo: { hasNextPage: boolean; endCursor: string | null } | undefined,
  fetchMore: any, // eslint-disable-line
  maxAutoFetch = 2
): void {
  const hasNextPage = pageInfo?.hasNextPage ?? false
  const endCursor = pageInfo?.endCursor

  // fetchMore を自動で呼び出した回数を保持しておく(呼び出しすぎ防止)
  const count = useRef(0)

  // しかるべきタイミングで fetchMore を自動呼出しする
  useEffect(() => {
    if (error || loading || count.current >= maxAutoFetch) return
    if (hasNextPage) {
      count.current += 1
      // eslint-disable-next-line @typescript-eslint/no-unsafe-call
      fetchMore({ variables: { cursor: endCursor } })
    }
  }, [loading, error, hasNextPage, endCursor, fetchMore, maxAutoFetch])
}

useAutoFetchMore フックの使用例

次のサンプルコンポーネントは、上記で作成した useAutoFetchMore フックを使用して、GitHub の apollographql/apollo-client リポジトリの Issue 情報を連続取得します。 一度に取得する件数は first: 100 のように最大 100 件に設定することができますが、ここでは連続して取得していることが分かるように first: 5 として 5 件ずつ取得するようにしています。

src/pages/sample.tsx
import { gql, useQuery } from '@apollo/client'
import { FC } from 'react'
import { useAutoFetchMore } from '@/hooks/useAutoFetchMore'

const QUERY_ISSUES = gql`
  query QueryIssues($cursor: String) {
    search(
      type: ISSUE
      query: "repo:apollographql/apollo-client is:issue is:open"
      first: 5
      after: $cursor
    ) {
      edges {
        node { ... on Issue { id number title } }
      }
      pageInfo { endCursor hasNextPage }
    }
  }
`

// 本来は useQuery の型パラメーターをちゃんと指定して ESLint 警告を取り除くべき
/* eslint-disable */
const SamplePage: FC = () => {
  const { loading, error, data, fetchMore } = useQuery(QUERY_ISSUES)
  useAutoFetchMore(loading, error, data?.search.pageInfo, fetchMore)

  if (error) return <p>{error.message}</p>
  if (loading) return <p>Loading ...</p>

  const { search } = data
  return (
    <ol>
      {search.edges.map(({ node: issue }) => (
        <li key={issue.id}>
          <b>#{issue.number}</b> {issue.title}
        </li>
      ))}
    </ol>
  )
}
/* eslint-enable */

export default SamplePage

ポイントは、下記のフック呼び出し部分です。 useQuery フックに続けて useAutoFetchMore フックを呼び出しておくことで、内部で fetchMore が自動的に繰り返し呼ばれるようになります。

const { loading, error, data, fetchMore } = useQuery(QUERY_ISSUES)
useAutoFetchMore(loading, error, data?.search.pageInfo, fetchMore)

内部で fetchMore が呼び出されるごとにコンポーネントの再描画が行われるため、段階的に表示量が増えていく振る舞いになります。

応用(useLazyQuery)

Apollo Client において、任意のタイミング(ボタンクリックなど)で GraphQL クエリを発行したいときは、useQuery の代わりに useLazyQuery フックを使用します。

今回実装した useAutoFetchMore フックは、そのまま useLazyQuery にも適用することができます(クエリが実行されるまでは、内部的に hasNextPage == false と同じ振る舞いになるようにしているので)。 次のサンプルコードでは、ユーザーが Get Issues ボタンをクリックしたときに fetchMore の自動実行を開始するようにしています。

src/pages/test.tsx(抜粋)
/* eslint-disable */
const TestPage: FC = () => {
  const [doQuery, { called, loading, error, data, fetchMore }] =
    useLazyQuery(QUERY_ISSUES)
  useAutoFetchMore(loading, error, data?.search.pageInfo, fetchMore)

  if (error) return <p>{error.message}</p>
  if (loading) return <p>Loading ...</p>

  const searchEdges = called ? data.search.edges : []
  return (
    <>
      <button onClick={() => doQuery()}>Get Issues</button>
      <ol>
        {searchEdges.map(({ node: issue }) => (
          <li key={issue.id}>
            <b>#{issue.number}</b> {issue.title}
          </li>
        ))}
      </ol>
    </>
  )
}
/* eslint-enable */

関連記事

Apollo Client の Pagenation 機能を使って GraphQL API を呼び出す

更新:
作成:

Apollo Client の Pagination 機能

GraphQL API では柔軟なクエリ発行が可能ですが、多数の要素を取得する場合は、Pagenation 処理 により何度かに分けて API 呼び出しを行う必要があります。 例えば、GitHub の GraphQL API では一度のクエリで取得可能な要素数は 100 件までであり、それを超える情報を取得する場合に Pagination 処理が必要です。

Apollo Client には、GraphQL の Pagination 処理を簡単に扱うための仕組み(fetchMore 関数)が用意されています。

と言っても、そこまで簡単ではないので、ここでは GitHub の GraphQL API における Pagination 処理の具体的な実装例を紹介します。

Pagination の実装例(フィールドポリシーを使う方法)

次のサンプルコードは、GitHub の myorg/myrepo リポジトリの Issue リストを表示する IssueList コンポーネントの実装例です。 Issue の数が 100 件を超える場合は、「さらに読み込む」ボタンを表示し、このボタンが押されたときに Pagination 処理(fetchMore 関数)で次のデータを取得するようにしています。

Apollo クライアントの useQuery 関数が返す fetchMore 関数を呼び出すと、再度 GraphQL クエリを実行することができます。 このとき、オプションで variables パラメータの値(クエリ変数)を変更できるので、Issue の読み出し開始位置を示す after の値を進めていくことで、100 件を超えるデータを順番に読み出すことができます。

IssueList.tsx
import { gql, useQuery } from '@apollo/client'
import { FC } from 'react'

// GraphQL クエリ
const QUERY_ISSUES = gql`
  query QueryIssues($cursor: String) {
    search(first: 100, after: $cursor, type: ISSUE,
        query: "repo:myorg/myrepo is:issue is:open") {
      edges {
        node {
          ... on Issue { id number title }
        }
      }
      pageInfo { hasNextPage endCursor }
    }
  }
`

// "さらに読み込む" ボタン
function createFetchMoreButton(pageInfo, fetchMore): JSX.Element | null {
  if (!pageInfo.hasNextPage) {
    return null
  }

  return (
    <button onClick={() => {
      fetchMore({
        variables: { cursor: pageInfo.endCursor }
      })
    }}>さらに読み込む</button>
  )
}

export const IssueList: FC = () => {
  const { loading, error, data, fetchMore } = useQuery(QUERY_ISSUES)
  if (error) return <p>{error.message}</p>
  if (loading) return <p>Loading ...</p>
  const { search } = data
  const { pageInfo } = search

  return <>
    <ul>
      {search.edges.map(({ node }) => (
        <li key={node.id}>
          {node.number}: {node.title}
        </li>
      ))}
    </ul>
    {createFetchMoreButton(pageInfo, fetchMore)}
  </>
}

実はこれだけでは、不十分で、ApolloClient インスタンスを生成するときにキャッシュの フィールドポリシー というものを設定しておく必要があります。 ApolloClient(の InMemoryCache)は、デフォルトの動作として、クエリ結果を別々のオブジェクトとしてキャッシュしようとするので、fetchMore で複数回に分けて取得したデータをマージして返して欲しい場合は、InMemoryCache オブジェクトを次のようにカスタマイズしなければいけません。

GitHubApolloProvider.tsx
import { ApolloClient, ApolloProvider, InMemoryCache } from '@apollo/client'
import { relayStylePagination } from '@apollo/client/utilities'

// ...

const cache = new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        search: relayStylePagination(['type', 'query']),
      },
    },
  },
})

// Creates a GraphQL client
const apolloClient = new ApolloClient({
  link: authLink.concat(httpLink),
  cache
})

export const GitHubApolloProvider: React.FC = (prop) => {
  return <ApolloProvider client={apolloClient}>{prop.children}</ApolloProvider>
}

上記の例では、クエリ結果の search フィールドをページネーションによって繰り返し取得した場合に、結果のリストをマージして返すように指定しています。 relayStylePagination 関数は Apollo が提供しているユーティリティ関数で、Relay-style connections によるページネーション処理を行う場合に使用することができます。 Relay-style というのは、上記の GitHub GraphQL レスポンスのように、pageInfoedges というフィールドがある場合に採用されているページネーションスタイルだと考えればよいです。

relayStylePagination 関数には keyArgs と呼ばれる引数を渡すことができ、クエリ時にどのパラメーターが異なっていたら別のキャッシュとして管理するかを指定します。 search クエリ (Connection) は、type 引数あるいは query 引数に違う値が指定された場合は、全然違う情報を取得することになるので結果をマージされては困ります。 そこで、上記のように ['type', 'query'] と指定するわけです。

☝️ edges と nodes Relay スタイル (Cursor Connections) では、ページネーション用に pagesInfoedges などのオブジェクトを使用します。 一方で、GitHub の GraphQL スキーマでは、edges 以下の各 node にアクセスするためのショートカットとして nodes が定義されています。 多くのケースではこの nodes オブジェクトを参照することでクライアントコードが簡潔になるのですが、Apollo client が提供する relayStylePagination() 関数を使用する場合は、edges を参照するような GraphQL クエリを発行しないとうまく動作しないようですので注意してください。

ページネーションをどのように実装しているかは、GraphQL のサーバーによって異なるので、それに応じてフィールドポリシーの設定方法も変える必要があります。 フィールドポリシーの詳しい設定方法は、下記の Apollo Client ドキュメントを参照してください。

Pagination の実装例(fetchMore の updateQuery オプションを使う方法)

下記の updateQuery を使ったページネーション処理は、2021-09-15 時点で deprecated になっています。前述のキャッシュの「フィールドポリシー」を使った方法を使ってください。

このコードを実行すると、次のように警告が出ます。

The updateQuery callback for fetchMore is deprecated, and will be removed in the next major version of Apollo Client.

このあたりの変更は地味につらい。。。

IssueList.tsx
import { FC } from 'react'
import { gql, useQuery } from '@apollo/client'

// GraphQL クエリ
const QUERY_ISSUES = gql`
  query QueryIssues($cursor: String) {
    search(first: 100, after: $cursor, type: ISSUE,
        query: "repo:myorg/myrepo is:issue is:open") {
      edges {
        node {
          ... on Issue { id number title }
        }
      }
      pageInfo { hasNextPage endCursor }
    }
  }
`

// "さらに読み込む" ボタン
function createFetchMoreButton(pageInfo, fetchMore) {
  if (!pageInfo.hasNextPage) {
    return null
  }

  return (
    <button onClick={() => {
      fetchMore({
        variables: {cursor: pageInfo.endCursor},
        updateQuery: (prevResult, {fetchMoreResult}) => {
          if (!fetchMoreResult) return prevResult
          return fetchMoreResult
        }
      })
    }}>さらに読み込む</button>
  )
}

export const IssueList: FC = () => {
  const {loading, error, data, fetchMore} = useQuery(QUERY_ISSUES)
  if (error) return <p>{error.message}</p>
  if (loading) return <p>Loading ...</p>
  const {search} = data
  const {pageInfo} = search

  return <>
    <ul>
      {search.edges.map(({ node }) => (
        <li key={node.id}>{node.number}: {node.title}</li>
      ))}
    </ul>
    {createFetchMoreButton(pageInfo, fetchMore)}
  </>
}

ポイントは、Apollo Client の useQuery フックが返す fetchMore 関数の使い方です。 上記の例では、「さらに読み込む」ボタンが押された時に、この fetchMore 関数を呼び出しています。

fetchMore({
  variables: {cursor: pageInfo.endCursor},
  updateQuery: (prevResult, {fetchMoreResult}) => {
    if (!fetchMoreResult) return prevResult;
    return fetchMoreResult;
  }
});

fetchMore 関数を呼び出すと、variables パラメータで指定した変数値を使って再度 GraphQL クエリが実行され、updateQuery パラメータで指定した関数が返す値で再描画が行われます。 fetchMoreResult には、新しいクエリでサーバーから返された値が格納されているため、これをそのまま返すことで、次のページの情報を描画することができます。

もし、前回取得したデータとマージして表示したいのであれば、次のような感じで、戻り値をうまいこと加工してやります。 この例では、search.nodes のフィールドをマージしています。

fetchMore({
  variables: {cursor: pageInfo.endCursor},
  updateQuery: (prevResult, {fetchMoreResult}) => {
    if (!fetchMoreResult) {
      return prevResult;
    }
    return {
      search: {
        __typename: fetchMoreResult.search.__typename,
        issueCount: fetchMoreResult.search.issueCount,
        nodes: [...prevResult.search.nodes, ...fetchMoreResult.search.nodes],
        pageInfo: fetchMoreResult.search.pageInfo,
      }
    };
  }
});

search フィールドの値を漏れなく列挙するのが面倒な場合は、Object.assign() による Shallow マージの仕組みを使って、次のように記述することもできます。

return {
  search: Object.assign({}, fetchMoreResult.search, {
    nodes: [...prevResult.search.nodes, ...fetchMoreResult.search.nodes]
  })
};

関連記事

メニュー

まくろぐ
サイトマップまくへのメッセージ