Post

Delivering Truly Self-Contained React Components with Apollo

Introduction

We’ve all been there: trying to deliver self-contained components across teams with React, only to hit stumbling blocks regarding data handling. You go through the effort to create a component, package it up, and get it ready, but then the consuming team needs to understand how to get the data to make the component work.

As a principal architect on a platform team, I wanted to solve this problem and deliver a genuinely self-contained component—one that a consuming team could import and use with no setup required. In this post, I’ll walk through how I achieved that with thoughtful use of Apollo.

The Challenge with Apollo

The first issue I encountered was that our Apollo configuration was becoming unwieldy—every consuming team’s Apollo setup knew about every AppSync for every service. This wouldn’t do. Initially, I tried using context names to choose which link to use. Below is an example of using Apollo Link with multiple endpoints:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const httpLinks = ApolloLink.split(
  (operation) => operation.getContext().endpoint === AuditEndpointName,
  auditLink,
  ApolloLink.split(
    (operation) => operation.getContext().endpoint === CommentEndpointName,
    commentLink,
    ApolloLink.split(
      (operation) => operation.getContext().endpoint === CompanyEndpointName,
      companyLink,
      ApolloLink.split(
        (operation) => operation.getContext().endpoint === IntegrationEndpointName,
        integrationLink,
        ApolloLink.split(
          (operation) => operation.getContext().endpoint === NotificationEndpointName,
          notificationLink,
          ApolloLink.split(
            (operation) => operation.getContext().endpoint === CompanySubscriptionEndpointName,
            companySubscriptionLink,
            auditLink,
          ),
        ),
      ),
    ),
  ),
);

This configuration would be complicated to maintain as we added more services and scaled. There had to be a better way!

The Not so Better Way

My next idea was to have multiple Apollo clients without them conflicting. Unfortunately, simply nesting Apollo providers like this:

1
2
3
4
5
<ApolloProvider>
  <ApolloProvider>
    ...
  </ApolloProvider>
</ApolloProvider>

It wouldn’t work. The providers would overwrite each other’s context, leading to conflicts. There seemed to be no clean way to handle multiple links besides adding more and more conditions.

Enter React Context

What if we created our own context instead of relying on the default ApolloProvider?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
  ApolloCache,
  ApolloClient,
  DefaultContext,
  DocumentNode,
  LazyQueryHookOptions,
  LazyQueryResultTuple,
  MutationHookOptions,
  MutationTuple,
  OperationVariables,
  QueryHookOptions,
  QueryResult,
  SubscriptionHookOptions,
  SubscriptionResult,
  TypedDocumentNode,
} from '@apollo/client';
import { createContext, useContext } from 'react';

export const CompanySubscriptionEndpointName = 'companySubscription';

export type CompanyServiceContextType = {
  useQuery<TData = any, TVariables extends OperationVariables = OperationVariables>(
    query: DocumentNode | TypedDocumentNode<TData, TVariables>,
    options?: QueryHookOptions<TData, TVariables>,
  ): QueryResult<TData, TVariables>;
  // ... other hooks for mutation, subscription, lazy query
  companyServiceApolloClient: ApolloClient<any> | undefined;
};

export const CompanyServiceContext = createContext<CompanyServiceContextType>({
  useQuery: () => {
    throw new Error('useQuery is not implemented');
  },
  // ... other default hook implementations
  companyServiceApolloClient: undefined,
});

export function useCompanyServiceContext(): CompanyServiceContextType {
  return useContext(CompanyServiceContext);
}

By creating a custom CompanyServiceContext, we established our own provider independent of Apollo’s default provider. We could then build our context into a provider component:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import React from 'react';
import { CompanyServiceContext, CompanyServiceContextType } from '../../hooks/use-company-service-context';
import { useConfigureApolloCompany } from '../../hooks/use-configure-apollo-company';
import { useCompanySettings } from '../../hooks/use-company-settings';
import {
  DocumentNode,
  OperationVariables,
  QueryHookOptions,
  QueryResult,
  TypedDocumentNode,
  useQuery as useQueryApollo,
  ApolloCache,
  // ... other Apollo imports
} from '@apollo/client';

export function CompanyServiceProvider({ children, devToolsEnabled }: { children: React.ReactNode; devToolsEnabled: boolean }): JSX.Element | null {
  const { companySettings } = useCompanySettings();
  const client = useConfigureApolloCompany(companySettings, devToolsEnabled);

  function useQuery<TData = any, TVariables extends OperationVariables = OperationVariables>(
    query: DocumentNode | TypedDocumentNode<TData, TVariables>,
    options?: QueryHookOptions<TData, TVariables>,
  ): QueryResult<TData, TVariables> {
    return useQueryApollo(query, { ...options, client });
  }

  const value: CompanyServiceContextType = {
    useQuery,
    // ... other hooks (useMutation, useSubscription, etc.)
    companyServiceApolloClient: client,
  };

  return (companySettings && client && <CompanyServiceContext.Provider value={value}>{children}</CompanyServiceContext.Provider>) || null;
}

With this provider, consuming teams could use the <CompanyServiceProvider> and the useCompanyServiceContext Hook to get a client—or any of the usual Apollo methods—without needing to know about or set up the Apollo client itself.

Building Reusable Hooks and Components

This new setup allowed us to build encapsulated hooks that could be delivered to teams, like so:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import { useCompanyServiceContext } from '../use-company-service-context';
import { CustomerQuery, CustomerQueryVariables } from './useCustomer.generated';
import { gql } from '@apollo/client';

export type UseCustomerResult = {
  customer: CustomerQuery['customer'] | undefined;
  error?: ApolloError;
  loading: boolean;
};

const CustomerDocument = gql`
  query customer($companyId: String!) {
    customer(companyId: $companyId) {
      createdAt
      domainName
      id
      name
      updatedAt
      socPartner {
        id
        firstName
        lastName
        email
      }
      applications
      workdayCustomerIds
      workdayCustomers {
        name
        id
      }
    }
  }
`;

export function useCustomer(companyId: string): UseCustomerResult {
  const { useQuery } = useCompanyServiceContext();
  const { data, error, loading } = useQuery<CustomerQuery, CustomerQueryVariables>(CustomerDocument, {
    variables: { companyId },
  });

  return { customer: data?.customer, error, loading };
}

Notice how the useCustomer hook calls useCompanyServiceContext() to get useQuery? The consuming team doesn’t need to know any of the setup required to call that AppSync endpoint.

This leads to fully composable components. Using this hook, we can create a component that lists companies in a table, and any team can use that component without having to set up anything. It becomes an actual “drop-in” UI piece.

Conclusion

With this approach, I could deliver React components that were truly self-contained—wrapping their own data-fetching requirements inside contexts and hooks that abstracted away the complex Apollo client configuration. The consuming teams can now focus solely on building their features without getting bogged down in GraphQL setup. If you have run into similar challenges, I hope this approach gives you some inspiration for improving the developer experience in your team.

This post is licensed under CC BY 4.0 by the author.