Next.js で開発していると、複数のページの getServerSideProps
で同じようなコードを度々書くことになると思う。例えば 「GraphQL クライアントでクエリを fetch して、そのレスポンスをpageProps
に焼き込むロジック」であったり、「(マルチテナントアプリケーションにおいて) リクエストヘッダーからテナントを特定するロジック」であったり。こうした大部分のページで使われるロジックのコードは、何度も書かずに済むよう、何らかの共通化をしたくなる。
すでに色々な人が似たような動機でgetServerSideProps
を共通化する方法を紹介していると思う。それを参考にしながら id:mizdra も自分流の共通化方法を実践している。何度か Next.js アプリケーションを作っているうちに、自分の型のようなものが身についてきたので、それを紹介してみる。
はじめに結論
こう書いてます。
// lib/get-server-side-props.ts import { ApolloClient, NormalizedCacheObject } from '@apollo/client'; import { GetServerSideProps, GetServerSidePropsContext } from 'next'; type TenantName = 'Tenant-A' | 'Tenant-B' | 'Tenant-C'; // getServerSideProps の共通化とは関係ないので実装は省略し、型だけ定義しておく declare function getTenantNameFromHostHeader(context: GetServerSidePropsContext): TenantName | undefined; export declare function createApolloClient( cache: NormalizedCacheObject | undefined, tenantName: TenantName, context?: GetServerSidePropsContext, ): ApolloClient<NormalizedCacheObject>; /** `const outerGSSP = withTenantGSSP(innerGSSP)` の `outerGSSP` に渡される context の型 */ type WithTenantOuterContext = GetServerSidePropsContext; /** `withTenantGSSP(innerGSSP)` の `innerGSSP` に渡される context の型 */ type WithTenantInnerContext = WithTenantOuterContext & { tenantName: TenantName }; /** `withTenantGSSP(innerGSSP)` の `innerGSSP` から返すべき pageProps の型 */ type WithTenantInnerPageProps = {}; /** `const outerGSSP = withTenantGSSP(innerGSSP)` の `outerGSSP` から返すべき pageProps の型 */ export type WithTenantOuterPageProps = WithTenantInnerPageProps & { tenantName: TenantName }; /** * Host ヘッダーからテナント名を取得し、innerGSSP にテナント名を渡す HoF。 * また、テナント名をコンポーネントから参照できるよう、pageProps に焼き込む。 */ function withTenantGSSP<P extends { [key: string]: any } = { [key: string]: any }>( innerGSSP: (context: WithTenantInnerContext) => ReturnType<GetServerSideProps<P & WithTenantInnerPageProps>>, ): (context: WithTenantOuterContext) => ReturnType<GetServerSideProps<P & WithTenantOuterPageProps>> { return async (context) => { const tenantName = getTenantNameFromHostHeader(context); if (tenantName === undefined) throw new Error('Host ヘッダーからテナントの特定に失敗しました。'); const innerResult = await innerGSSP({ ...context, tenantName }); if (!('props' in innerResult)) return innerResult; return { ...innerResult, props: { ...(await innerResult.props), tenantName, }, }; }; } type WithApolloOuterContext = GetServerSidePropsContext & { tenantName: TenantName }; type WithApolloInnerContext = WithApolloOuterContext & { apolloClient: ApolloClient<NormalizedCacheObject> }; type WithApolloInnerPageProps = {}; export type WithApolloOuterPageProps = WithApolloInnerPageProps & { initialApolloState: NormalizedCacheObject }; /** * Apollo クライアントを innerGSSP に渡しつつ、Apollo クライアントのキャッシュを pageProps に焼き込む HoF。 * 焼きこまれたキャッシュは、_app.tsx で Apollo クライアントを初期化する際に使われる。 */ function withApolloGSSP<P extends { [key: string]: any } = { [key: string]: any }>( innerGSSP: (context: WithApolloInnerContext) => ReturnType<GetServerSideProps<P & WithApolloInnerPageProps>>, ): (context: WithApolloOuterContext) => ReturnType<GetServerSideProps<P & WithApolloOuterPageProps>> { return async (context) => { const apolloClient = createApolloClient(undefined, context.tenantName, context); const innerResult = await innerGSSP({ ...context, apolloClient }); if (!('props' in innerResult)) return innerResult; return { ...innerResult, props: { ...(await innerResult.props), initialApolloState: apolloClient.cache.extract(), }, }; }; } export type WithGlobalOuterContext = GetServerSidePropsContext; export type WithGlobalInnerContext = WithTenantInnerContext & WithApolloInnerContext; export type WithGlobalInnerPageProps = WithTenantInnerPageProps & WithApolloInnerPageProps; export type WithGlobalOuterPageProps = WithTenantOuterPageProps & WithApolloOuterPageProps; /** よく使われる HoF を合成した HoF */ export function withGlobalGSSP<P extends { [key: string]: any } = { [key: string]: any }>( innerGSSP: (context: WithGlobalInnerContext) => ReturnType<GetServerSideProps<P & WithGlobalInnerPageProps>>, ): (context: WithGlobalOuterContext) => ReturnType<GetServerSideProps<P & WithGlobalOuterPageProps>> { return withTenantGSSP(withApolloGSSP(innerGSSP)); }
// pages/_app.tsx import '../styles/globals.css'; import type { AppProps } from 'next/app'; import { ApolloProvider } from '@apollo/client'; import { createApolloClient, WithTenantOuterPageProps, WithApolloOuterPageProps } from '../lib/get-server-side-props'; // withGlobalGSSP などで囲われていれば、OuterPageProps 相当のプロパティが pageProps に入っているはず。 // ただしページによっては withGlobalGSSP などで囲ってなかったりするので、 Partial<> で optional にする type CustomAppProps = AppProps<Partial<WithTenantOuterPageProps & WithApolloOuterPageProps>>; export default function App({ Component, pageProps }: CustomAppProps) { if (!pageProps.tenantName) throw new Error('pageProps.tenantName is required.'); const apolloClient = createApolloClient(pageProps.initialApolloState, pageProps.tenantName); return ( <ApolloProvider client={apolloClient}> <Component {...pageProps} /> </ApolloProvider> ); }
// pages/index.tsx import { GetServerSideProps } from 'next'; import { withGlobalGSSP } from '../lib/get-server-side-props'; import { gql, useQuery } from '@apollo/client'; const QUERY = gql` query TopPage { hello } `; export default function Home() { const { loading, error, data } = useQuery(QUERY); return ( <main> <p>top page.</p> <div> {loading && <p>loading...</p>} {error && <p>error: {error.message}</p>} {data && <p>data: {JSON.stringify(data)}</p>} </div> </main> ); } export const getServerSideProps = withGlobalGSSP(async (context) => { await context.apolloClient.query({ query: QUERY }); console.log(context.tenantName); return { props: {} }; }) satisfies GetServerSideProps;
以下のリポジトリにコード全文があります。
ポイントをかいつまんで紹介
- それぞれのページの固有の
getServerSideProps
(GSSP) のロジックが実行される前後で共通のロジックを呼び出したいことがあるので、High-order Function (HoF) として共通化してます- 簡単に言うと関数を引数にとって、関数を返す関数のこと
- (React Hooks 登場以前の) Redux 触ったことある人なら馴染みあると思う
innerGSSP
がページの固有の GSSP で、それを HoF に渡すと、「共通ロジック前半部分の実行」=>「innerGSSP
の実行」=>「共通ロジック後半部分の実行」みたいな順で実行されていく- 「共通ロジック後半部分」で得たデータを
innerGSSP
に渡したり、innerGSSP
の返り値を「共通ロジック後半部分」で拡張してから返したりできる
- 「共通ロジック後半部分」で得たデータを
- よく使う HoF を合成した
withGlobalGSSP
という HoF を用意してるexport const getServerSideProps = withGlobalGSSP(async (context) => { ... }) satisfies GetServerSideProps;
と書いて使う
- Inner と Outer の型をちゃんと表現してるので、 HoF の適用順序を間違えたら型エラーが出るようになってる
withApolloGSSP
はtenantName
を要求するので、先にwithTenantGSSP
を呼ばないといけない
感想
withApolloGSSP
やwithGlobalGSSP
などを使うページコンポーネント側はすごくスッキリする- けど HoF 実装側は大変なことになってる!!!
- 何とかならないものか
- 厳密に型付けするのを諦めて、もう少しナイーブにやったらメンテナンスしやすい形にできないかな
- そのためには HoF やめる必要がある
- HoF や HoC のように High-order XXX を使うと、inner から出入りする型をジェネリクスで表現する都合上 High-order XXX のシグニチャが長くなってしまう問題が知られてる
- HoF ではなくただの関数として実装できるものはそう実装する、HoF じゃないとできないものは HoF で実装する、くらいが塩梅として良いのかなあ
- 例えば
withTenantGSSP
は廃止して、都度tenantName
が必要なところでgetTenantNameFromContext(...)
を呼び出すようにするとか
- 例えば
- そのためには HoF やめる必要がある
- 皆さんはどうしてますか?