Next.js + Meerdere Apollo-clients & GraphQL-bronnen

Paul Grieselhuber

Paul Grieselhuber

Apr 23, 2019

Next.js + GraphQL blijkt een behoorlijk goede manier te zijn om zowel kleine als grootschalige statische websites te bouwen, vooral als je gebruik maakt van de statische HTML-exports van Next.js (zie my post over WPGraphQL voor een how-to over dit gebruik van GraphQL).

Een van de vragen die veel ontwikkelaars lijken te hebben, en die een verbazingwekkende hoeveelheid Googlen, onderzoek en testen vergt om uit te zoeken, is het bouwen van een van deze sites met twee GraphQL gegevensbronnen.

Een beetje achtergrond en boilerplate

Om de weg te bereiden voor de oplossing, volgt hier waarom dit een beetje een uitdaging wordt. De basisconfiguratie voor een Apollo client in Next.js source bestaat uit drie bestanden:

  • Onze component /pages/_app.js
  • /lib/init-apollo.js
  • /lib/with-apollo-client.js

Laten we eerst eens kijken naar _app.js:

import App, { Container } from "next/app";
import React from "react";
import withApolloClient from "../lib/with-apollo-client";
import { ApolloProvider } from "react-apollo";

class MyApp extends App {
  render() {
    const { Component, pageProps, apolloClient } = this.props;
    return (
      <Container>
        <ApolloProvider client={apolloClient}>
          <Component {...pageProps} />
        </ApolloProvider>
      </Container>
    );
  }
}

export default withApolloClient(MyApp);

De essentie hier is dat we withApolloClient and wrap the rest of our app in that component, passing the apolloClient importeren dat als resultaat wordt geleverd door props. Dit geeft onze kindcomponenten toegang tot de gegevens die afkomstig zijn van de GraphQL bron waarmee Apollo is verbonden. Verder.

In init-apollo.js maken we de Apollo client aan en vertellen we waar deze verbinding mee moet maken:

import { ApolloClient, InMemoryCache, HttpLink } from "apollo-boost";
import fetch from "isomorphic-unfetch";

let apolloClient = null;

// Polyfill fetch() on the server (used by apollo-client)
if (!process.browser) {
  global.fetch = fetch;
}

function create(initialState) {
  // Check out https://github.com/zeit/next.js/pull/4611 if you want to use the AWSAppSyncClient
  return new ApolloClient({
    connectToDevTools: process.browser,
    ssrMode: !process.browser, // Disables forceFetch on the server (so queries are only run once)
    link: new HttpLink({
      uri: "https://api.graph.cool/simple/v1/cixmkt2ul01q00122mksg82pn", // Server URL (must be absolute)
      credentials: "same-origin", // Additional fetch() options like `credentials` or `headers`
    }),
    cache: new InMemoryCache().restore(initialState || {}),
  });
}

export default function initApollo(initialState) {
  // Make sure to create a new client for every server-side request so that data
  // isn't shared between connections (which would be bad)
  if (!process.browser) {
    return create(initialState);
  }

  // Reuse client on the client-side
  if (!apolloClient) {
    apolloClient = create(initialState);
  }

  return apolloClient;
}

Vrij duidelijk wat hier gebeurt, maar houd die linkregel in de gaten, daar komen we nog op terug...

En tot slot with-apollo-client.js:

import React from "react";
import initApollo from "./init-apollo";
import Head from "next/head";
import { getDataFromTree } from "react-apollo";

export default (App) => {
  return class Apollo extends React.Component {
    static displayName = "withApollo(App)";
    static async getInitialProps(ctx) {
      const { Component, router } = ctx;

      let appProps = {};
      if (App.getInitialProps) {
        appProps = await App.getInitialProps(ctx);
      }

      // Run all GraphQL queries in the component tree
      // and extract the resulting data
      const apollo = initApollo();
      if (!process.browser) {
        try {
          // Run all GraphQL queries
          await getDataFromTree(
            <App
              {...appProps}
              Component={Component}
              router={router}
              apolloClient={apollo}
            />
          );
        } catch (error) {
          // Prevent Apollo Client GraphQL errors from crashing SSR.
          // Handle them in components via the data.error prop:
          // https://www.apollographql.com/docs/react/api/react-apollo.html#graphql-query-data-error
          console.error("Error while running `getDataFromTree`", error);
        }

        // getDataFromTree does not call componentWillUnmount
        // head side effect therefore need to be cleared manually
        Head.rewind();
      }

      // Extract query data from the Apollo store
      const apolloState = apollo.cache.extract();

      return {
        ...appProps,
        apolloState,
      };
    }

    constructor(props) {
      super(props);
      this.apolloClient = initApollo(props.apolloState);
    }

    render() {
      return <App {...this.props} apolloClient={this.apolloClient} />;
    }
  };
};

Hier geven we twee verschillende versies van onze HOC App component (die later de kindcomponenten in _app.js omhult): de eerste is voor SSR-modus, de tweede is voor al het andere.

Apollo verwacht één client

Je hebt waarschijnlijk de impliciete aanname in alle bovenstaande logica opgemerkt dat er precies één client is.

Het probleem is dat ik meerdere gegevensbronnen wil, en ik wil ze bevragen in de stijl die de bibliotheken die ik gebruik in mijn project voor ogen hebben. En ik wil niet iets in elkaar hacken dat mijn vermogen om te profiteren van alle geweldige functies van Apollo zal vernietigen, enz.

Een optie die naar voren kwam was schema stitching, maar dat leek veel meer dan ik wilde doen om alleen maar twee of meer clients te kunnen bevragen.

Na veel discussies, veel onderzoek, en meer keren refactoren dan ik zou willen toegeven, realiseerde ik me dat het neerkomt op waar je echt "meerdere" van wilt hebben.

De oplossing

Dus wat is het antwoord hier? Wil ik meerdere clients? Meerdere aanbieders?

Nee, je zit er ver naast. En waarschijnlijk omdat ik je misleid heb met de titel. Of misschien omdat de Angular Apollo-bibliotheek deze optie heeft, en het in feite met meerdere clients wordt gedaan.

Maar we zitten in de React-wereld, en het antwoord is: meerdere koppelingen in je ene Apollo-client.

Gelukkig hoeven we daarvoor maar twee van onze drie bestanden minimaal aan te passen.

In init-apollo.js:

import { ApolloLink } from "apollo-link";
import { ApolloClient, InMemoryCache, HttpLink } from "apollo-boost";
import fetch from "isomorphic-unfetch";

let apolloClient = null;

// Polyfill fetch() on the server (used by apollo-client)
if (!process.browser) {
  global.fetch = fetch;
}

// Create First Link
const firstLink = new HttpLink({
  uri: "https://www.firstsource.com/api",
  headers: yourHeadersHere,
  // other link options...
});

// Create Second Link
const secondLink = new HttpLink({
  uri: "https://www.secondsource.com/api",
  headers: yourHeadersHere,
  // other link options...
});

function create(initialState) {
  // Check out https://github.com/zeit/next.js/pull/4611 if you want to use the AWSAppSyncClient
  return new ApolloClient({
    connectToDevTools: process.browser,
    ssrMode: !process.browser, // Disables forceFetch on the server (so queries are only run once)
    link: ApolloLink.split(
      (operation) => operation.getContext().clientName === "second", // Routes the query to the proper client
      secondLink,
      firstLink
    ),
    cache: new InMemoryCache().restore(initialState || {}),
  });
}

export default function initApollo(initialState) {
  // Make sure to create a new client for every server-side request so that data
  // isn't shared between connections (which would be bad)
  if (!process.browser) {
    return create(initialState);
  }

  // Reuse client on the client-side
  if (!apolloClient) {
    apolloClient = create(initialState);
  }

  return apolloClient;
}

link parameter van de Apollo-client om te redden

Let op de wijzigingen:

  • We hebben onze eerste link weggelaten, voor de leesbaarheid, omdat we een tweede zullen maken (merk op dat je alle opties die je nodig hebt kunt doorgeven aan Apollo link modules hier).
  • We hebben ApolloLink geïmporteerd, die als beschikbare methode .split() heeft. Deze methode neemt ten minste twee parameters (hoewel het niet echt zinvol is om er minder dan drie door te geven):
  • De eerste is een functie om te beslissen welke link moet worden gebruikt
  • De tweede is de te gebruiken link als deze functie true teruggeeft.
  • De derde is de link die moet worden gebruikt als de functie false retourneert.

Op dit punt begrijp je waarschijnlijk de essentie van wat hier aan de hand is.

Er gaat niets boven een beetje context om het te verduidelijken...

De vraag blijft echter, hoe vertellen we een component welke GraphQL API hij moet bevragen? Door de context van de query te gebruiken. Hier is een voorbeeld:

const { data, error, loading } = useQuery(GET_STUFF, {
  context: { clientName: "second" },
});

Opmerking: Ik gebruik react-apollo-hooks' useQuery om de query uit te voeren, maar werkt precies hetzelfde door de Query component te gebruiken als je geen react hooks gebruikt.

Het punt van dat voorbeeld is dus dat we de waarde van context kunnen instellen op wat we maar willen in onze query en vervolgens op de netwerksite (in onze ApolloLink methode in init-apollo.js). Zorg er alleen voor dat je op beide plaatsen dezelfde waarden gebruikt.

Wat ik graag doe is de link van mijn Apollo client als volgt instellen:

// ...stuff
    link: ApolloLink.split(
      operation => operation.getContext().clientName === "second", // Routes the query to the proper client
      secondLink,
      firstLink
    ),
// ...stuff

Merk op dat de "firstLink" als tweede wordt weergegeven, waardoor het feitelijk de standaard gegevensbron is om te gebruiken als de contextwaarde die is ingesteld in de query niet overeenkomt met de waarde waar de split functie naar zoekt. Dit betekent dat we alleen context hoeven in te stellen als we onze "niet-standaard" client willen gebruiken.

With-apollo-client bijwerken om ons volledig gerenderde HTML-exports te geven

Ok, we komen nu op het laatste stuk, het enige wat we moeten doen is onze with-apollo-client.js bewerken zodat deze overeenkomt met het volgende:

import React from "react";
import initApollo from "./init-apollo";
import Head from "next/head";
import { getDataFromTree } from "react-apollo";
import { getMarkupFromTree } from "react-apollo-hooks";
import { renderToString } from "react-dom/server";

export default (App) => {
  return class Apollo extends React.Component {
    static displayName = "withApollo(App)";
    static async getInitialProps(ctx) {
      const { Component, router } = ctx;

      let appProps = {};
      if (App.getInitialProps) {
        appProps = await App.getInitialProps(ctx);
      }

      // Run all GraphQL queries in the component tree
      // and extract the resulting data
      const apollo = initApollo();
      if (!process.browser) {
        try {
          // Run all GraphQL queries
          await getDataFromTree(
            <App
              {...appProps}
              Component={Component}
              router={router}
              apolloClient={apollo}
            />
          );
          // Create static markup for SSR HTML export
          await getMarkupFromTree({
            renderFunction: renderToString,
            tree: (
              <App
                {...appProps}
                Component={Component}
                router={router}
                apolloClient={apollo}
              />
            ),
          });
        } catch (error) {
          // Prevent Apollo Client GraphQL errors from crashing SSR.
          // Handle them in components via the data.error prop:
          // https://www.apollographql.com/docs/react/api/react-apollo.html#graphql-query-data-error
          console.error("Error while running `getDataFromTree`", error);
        }

        // getDataFromTree does not call componentWillUnmount
        // head side effect therefore need to be cleared manually
        Head.rewind();
      }

      // Extract query data from the Apollo store
      const apolloState = apollo.cache.extract();

      return {
        ...appProps,
        apolloState,
      };
    }

    constructor(props) {
      super(props);
      this.apolloClient = initApollo(props.apolloState);
    }

    render() {
      return <App {...this.props} apolloClient={this.apolloClient} />;
    }
  };
};

Merk op dat de enige veranderingen / toevoegingen de twee laatste imports zijn (getMarkupFromTree & renderToString), en de toevoeging van de getMarkupFromTree sectie, die ons toestaat om volledig gerenderde statische HTML exports te hebben van Next.js.

Maar wat als ik meer dan twee gegevensbronnen nodig heb in mijn applicatie?

Grappig dat je dat vraagt, want de bovenstaande truc (de .split() operation on ApolloLink) only works with up to two links. Bit of a chin scratcher at first, until you realize that the second link passed to the .split method does not have to be an HttpLink.

Deze tweede link kan een andere ApolloLink zijn! Zoals je hieronder kunt zien, kunnen we gewoon links aan elkaar blijven rijgen totdat we de context van alle mogelijke gegevensbronnen hebben gecontroleerd:

// Create First Link
const firstLink = new HttpLink({
  uri: "https://www.firstsource.com/api",
  headers: yourHeadersHere,
  // other link options...
});

// Create Second Link
const secondLink = new HttpLink({
  uri: "https://www.secondsource.com/api",
  headers: yourHeadersHere,
  // other link options...
});

// Create Third Link
const thirdLink = new HttpLink({
  uri: "https://www.thirdsource.com/api",
  headers: yourHeadersHere,
  // other link options...
});

const otherLinks = ApolloLink.split(
  (operation) => operation.getContext().clientName === "third", // Routes the query to the proper client
  thirdLink,
  firstLink
);

function create(initialState) {
  const client = new ApolloClient({
    connectToDevTools: process.browser,
    ssrMode: !process.browser, // Disables forceFetch on the server (so queries are only run once)
    link: ApolloLink.split(
      (operation) => operation.getContext().clientName === "second", // Routes the query to the proper client
      secondLink,
      otherLinks
    ),
    cache: new InMemoryCache({ fragmentMatcher }).restore(initialState || {}),
  });
  return client;
}

Bonus: maar hoe zit het met meerdere query's?

Als je verbinding maakt met meerdere clients, is de kans groot dat je ergens in je project twee queries in hetzelfde component moet uitvoeren. Natuurlijk zouden we compose van react-apollo in ons project kunnen importeren, maar dat is in dit geval echt niet nodig (source). We kunnen gewoon onze eigen hook maken (als je React hooks gebruikt). Dit ziet er ongeveer zo uit:

const queryMultiple = () => {
  const res = useQuery(GET_POST, {
    variables: {
      uri: props.uri,
    },
  });
  const res2 = useQuery(GET_SHOP, {
    context: { clientName: "second" },
  });
  return [res, res2];
};

const [res, res2] = queryMultiple();
const { data, error, loading } = res;
const { data: secondData, error: secondError, loading: secondLoading } = res2;

Hat tip naar @ThomasK33 voor zijn hulp om dit uit te zoeken.

Bonus #2: Hoe zit het met muteren op meerdere bronnen?

Vanaf de meest recente update hier (en ik zal dit bericht bijwerken als de situatie verandert), lijkt het antwoord "het hangt er vanaf" te zijn.

Als je een <Mutation> HOC gebruikt, kun je eenvoudig context doorgeven aan de bewerking (docs), en je gegevensbron selecteren op een vergelijkbare manier als we hierboven hebben gedaan.

Als je echter context wilt doorgeven aan useMutation vanuit de onofficiële react-apollo-hooks bibliotheek, dan lijk je op dit moment pech te hebben. Het goede nieuws is dat de officiële Apollo React Hooks bibliotheek momenteel in beta is, en het lijkt een zekerheid dat zij context als een eigenschap in hun versie van useMutation zouden opnemen, dus een oplossing ligt hier waarschijnlijk om de hoek.

Paul Grieselhuber

Paul Grieselhuber

Founder, President

Paul has extensive background in software development and product design. Currently he runs rendr.

Boek een kennismakingsgesprek met onze productexperts.

Ons team van experts in web- en mobiele applicaties kijkt ernaar uit om uw volgende project met u te bespreken.

Boek een gesprek 👋