Managing State with React and Apollo Client

14 min read

DoltHub is a Next.js application written in Typescript and backed by a GraphQL server. We use Apollo's built-in integration with React to manage our GraphQL data within our React components. If you want to know more about DoltHub's architecture, start with these blogs:

A few months ago I wrote a blog about how we use Apollo Client to manage GraphQL data on DoltHub. It goes through setting up Apollo Client, using GraphQL Code Generator to convert our GraphQL schema to Typescript, and how we use Apollo's query hooks in our components. This is a follow up blog that covers how we manage state within our components using a combination of Apollo Client and React hooks and contexts. I'd also highly recommend looking into Apollo's state management series on this topic.

TL;DR

We use Apollo Client and hooks to query and mutate data throughout DoltHub. There are three different ways we update fetched data after a mutation: using the refetchQueries option in the useMutation hook, reusing fragments, and manually updating the cache.

We use a combination of custom hooks and contexts to share query logic among components. Custom hooks are best for reusing common queries and extracting complex component logic. Context is best for sharing information throughout different levels of the component tree.

Configuring the cache

To most effectively use Apollo Client within your application, you should use Apollo's highly configurable cache. We use the InMemoryCache, which maintains a flat lookup table of objects that can reference each other. Our configuration looks like this:

import { ApolloClient, HttpLink, InMemoryCache } from "@apollo/client";
import { NormalizedCacheObject } from "@apollo/client/cache";
import { IncomingMessage } from "http";
import fragmentTypes from "../gen/fragmentTypes.json";

export function createApolloClient(
  uri: string,
  initialState?: NormalizedCacheObject,
  req?: IncomingMessage
): ApolloClient<NormalizedCacheObject> {
  const cache = new InMemoryCache({
    possibleTypes: fragmentTypes.possibleTypes,
  }).restore(initialState || {});

  return new ApolloClient({
    cache,
    link: new HttpLink({
      fetch,
      credentials: "include",
      uri,
      headers,
    }),
  });
}

You can learn more about the possibleTypes option and fragment types here and here. We pass the result of our createApolloClient function to ApolloProvider, which wraps our custom App page component.

Handling queries

Now that our cache is configured, we can use Apollo's useQuery hook to fetch data within our components, as well as manage loading and error states. Every query we define has a unique corresponding useQuery hook (generated via GraphQL Code Generator). I'm going to use our Dolt database star button component (DatabaseStarButton) to demonstrate how we manage state for one of our simpler components. The component looks like this on our website:

Database star button

You can see the button accomplishes two things: show the total database star count and show whether the calling user has starred the database or not. So in order to render the button, we need to fetch this data. This is the query definition for a database, which includes the relevant fields starCount and starredByCaller:

export const DATABASE_STARS_QUERY = gql`
  fragment DatabaseStarsDatabase on Database {
    _id
    dbName
    ownerName
    starredByCaller
    starCount
  }
  query DatabaseForStars($ownerName: String!, $dbName: String!) {
    database(ownerName: $ownerName, dbName: $dbName) {
      ...DatabaseStarsDatabase
    }
  }
`;

And when we run our GraphQL Code Generator, we end up with a custom useQuery hook.

/**
 * __useDatabaseForStarsQuery__
 *
 * To run a query within a React component, call `useDatabaseForStarsQuery` and pass it any options that fit your needs.
 * When your component renders, `useDatabaseForStarsQuery` returns an object from Apollo Client that contains loading, error, and data properties
 * you can use to render your UI.
 *
 * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
 *
 * @example
 * const { data, loading, error } = useDatabaseForStarsQuery({
 *   variables: {
 *      ownerName: // value for 'ownerName'
 *      dbName: // value for 'dbName'
 *   },
 * });
 */
export function useDatabaseForStarsQuery(
  baseOptions: Apollo.QueryHookOptions<
    DatabaseForStarsQuery,
    DatabaseForStarsQueryVariables
  >
) {
  const options = { ...defaultOptions, ...baseOptions };
  return Apollo.useQuery<DatabaseForStarsQuery, DatabaseForStarsQueryVariables>(
    DatabaseForStarsDocument,
    options
  );
}

For components with queries, we use an "inner"/"outer" component pattern to handle loading and error states. We can use this useDatabaseForStarsQuery hook in our DatabaseStarButton component.

type Props = {
  params: {
    ownerName: string;
    dbName: string;
  };
};

export default function DatabaseStarButton({ params }: Props) {
  const { data, loading, error } = useDatabaseForStarsQuery({
    variables: params,
  });

  if (loading) return <Loader loaded={false} />;

  if (error) return <ErrorMsg err={error} />;

  if (!data) return <ErrorMsg errString="No database star data found" />;

  return <Inner database={data.database} />;
}

We often check for loading and error states this way before rendering our "inner" component with the resulting data, so we refactored this logic to a component called QueryHandler.

export default function DatabaseStarButton({ params }: Props) {
  const res = useDatabaseForStarsQuery({ variables: params });

  return (
    <QueryHandler
      result={res}
      render={(data) => <Inner database={data.database} />}
    />
  );
}

Now that our loading and error states are handled, the Inner component renders the data returned by the query.

type Props = {
  database: DatabaseStarsDatabaseFragment;
};

function Inner({ database }: Props) {
  return (
    <button type="button">
      <span>
        {database.starredByCaller ? <AiTwotoneStar /> : <AiOutlineStar />}
      </span>
      <span>Star</span>
      <span>{getFormattedCount(database.starCount)}</span>
    </button>
  );
}

Our button is successfully rendering with the correct information!

Mutations and updating data

Now we want our button to actually do something when we click on it. We will use Apollo's useMutation hook to add or remove a star when the button is clicked. Similarly to how we defined our query to fetch database data, we will define our two star mutations.

export const CREATE_STAR = gql`
  mutation CreateStar(
    $ownerName: String!
    $dbName: String!
    $username: String!
  ) {
    createStar(ownerName: $ownerName, dbName: $dbName, username: $username)
  }
`;

export const DELETE_STAR = gql`
  mutation DeleteStar(
    $ownerName: String!
    $dbName: String!
    $username: String!
  ) {
    deleteStar(ownerName: $ownerName, dbName: $dbName, username: $username)
  }
`;

We'll again generate types for our two custom mutations, so that we'll end up with useCreateStarMutation and useDeleteStarMutation. We can use these in our Inner component, making sure to handle their resulting loading and error states.

function Inner({ database, username }: Props) {
  const [createStar, createStarRes] = useCreateRepoStarMutation();
  const [deleteStar, deleteStarRes] = useDeleteRepoStarMutation();

  const onClick = async () => {
    const variables = { ...database, username };
    if (database.starredByCaller) {
      await deleteStar({ variables });
    } else {
      await createStar({ variables });
    }
  };

  return (
    <div>
      <Loader loaded={!(createStarRes.loading && deleteStarRes.loading)} />
      <ErrorMsg err={createStarRes.error || deleteStarRes.error} />
      <button type="button" onClick={onClick}>
        <span>
          {database.starredByCaller ? <AiTwotoneStar /> : <AiOutlineStar />}
        </span>
        <span>Star</span>
        <span>{getFormattedCount(database.starCount)}</span>
      </button>
    </div>
  );
}

Now that our button can add and remove a star, we need to ensure database.starredByCaller and database.starCount are always showing up-to-date information. There are a few different ways we handle this throughout our application.

1. The refetchQueries option

We often utilize the refetchQueries array in the useMutation options. For the mutations above, including the DatabaseForStarsQuery document in the refetchQueries array will refetch the database star data once the mutation is called.

const refetchQueries = [{ query: DatabaseForStarsQueryDocument }];
const [createStar, createStarRes] = useCreateRepoStarMutation({
  refetchQueries,
});
const [deleteStar, deleteStarRes] = useDeleteRepoStarMutation({
  refetchQueries,
});

The refetched query is executed with its most recent provided set of variables, but if needed we could also add the variables to the corresponding object in the refetchQueries array.

Now when we click on our button, it will automatically update the button star count and the star icon so that the user sees the correct state of the button.

The downside of updating data this way is that as your application grows it can become unmanageable to track which queries need to be updated at different times.

2. Fragments

We can use fragments to automatically change which fields are included in operations that use the fragment. This strategy won't work for the above example in its current state, but we can make it work by changing the createStar and deleteStar mutations to return the same DatabaseStarsDatabase fragment that we used in the DatabaseForStars query (this would also require changing what these mutations return in our GraphQL server).

export const CREATE_STAR = gql`
  mutation CreateStar(
    $ownerName: String!
    $dbName: String!
    $username: String!
  ) {
    createStar(ownerName: $ownerName, dbName: $dbName, username: $username) {
      ...DatabaseStarsDatabase
    }
  }
  ${DatabaseStarsDatabaseFragmentDoc}
`;

export const DELETE_STAR = gql`
  mutation DeleteStar(
    $ownerName: String!
    $dbName: String!
    $username: String!
  ) {
    deleteStar(ownerName: $ownerName, dbName: $dbName, username: $username) {
      ...DatabaseStarsDatabase
    }
  }
  ${DatabaseStarsDatabaseFragmentDoc}
`;

Now when the star button is clicked, the DatabaseStarsDatabase fragment will update and keep the database query results consistent throughout.

3. Updating the cache directly

Providing an update function to useMutation will update all modified fields in your cache. We haven't utilized the cache this way in our application, mostly because we started using GraphQL before Apollo 3 came out and the first two options are less verbose and have been working well for us so far. However, using update is a highly customized way to update data using the cache and could work well for many applications.

Without using refetchQueries or returning the database fragment from the mutations, createStar and deleteStar would not update the button star count and icon. This is how we could use update to do so:

function updateCacheForStars(
  cache: ApolloCache<NormalizedCacheObject>,
  create: boolean
) {
  const existingDatabase = cache.readQuery<
    DatabaseForStarsQuery,
    DatabaseForStarsQueryVariables
  >({
    query: DatabaseForStarsDocument,
    variables: database,
  });

  if (existingDatabase) {
    cache.writeQuery({
      query: DatabaseForStarsDocument,
      data: {
        database: {
          ...existingDatabase.database,
          starredByCaller: create,
          starCount: create
            ? existingDatabase.database.starCount + 1
            : existingDatabase.database.starCount - 1,
        },
      },
    });
  }
}

const [createStar, createStarRes] = useCreateStarMutation({
  update(cache) {
    updateCacheForStars(cache, true);
  },
});
const [deleteStar, deleteStarRes] = useDeleteStarMutation({
  update(cache) {
    updateCacheForStars(cache, false);
  },
});

Testing queries and mutations

We use React unit tests to ensure our queries and mutations are behaving as expected. I wrote a blog about mocking Apollo queries and mutations and testing expected behavior using this same DatabaseStarButton (formerly RepoStarButton) component. It includes solutions to errors I was getting as I was writing the mocks and tests that could be helpful.

Using custom hooks and contexts

We use both custom hooks and contexts to abstract query hook logic and make data accessible at different points within the component tree.

Hooks

Hooks are functions that let you “hook into” React state and lifecycle features from function components.

We often move our query logic within a component to a custom hook. Custom hooks start with use and call other hooks. This helps with organization, separation of concerns, and reusability.

Our database star button is one of our simpler components, but moving the mutation logic to a hook makes both the component and the hook easier to read. I created a custom hook called useDatabaseStar to handle our mutations.

type ReturnType = {
  onClick: () => void;
  loading: boolean;
  error: ApolloError | undefined;
};

export default function useDatabaseStar({
  database,
  username,
}: Props): ReturnType {
  const variables = { ...params, username };
  const refetchQueries = [{ query: DatabaseForStarsQueryDocument, variables }];
  const [createStar, createStarRes] = useCreateRepoStarMutation({
    refetchQueries,
  });
  const [deleteStar, deleteStarRes] = useDeleteRepoStarMutation({
    refetchQueries,
  });

  const onClick = async () => {
    if (repo.starredByCaller) {
      await deleteStar({ variables });
    } else {
      await createStar({ variables });
    }
  };

  return {
    onClick,
    loading: createStarRes.loading || deleteStarRes.loading,
    error: createStarRes.error || deleteStarRes.error,
  };
}

And Inner would be simplified to:

function Inner(props: Props) {
  const { onClick, loading, error } = useDatabaseStar(props);

  return (
    <div>
      <Loader loaded={!loading} />
      <ErrorMsg err={error} />
      <button type="button" className={css.button} onClick={onClick}>
        [...]
      </button>
    </div>
  );
}

Hooks are also useful for creating reusable query logic. This includes commonly used queries, like getting current user information (useCurrentUser) or separating commonly used hook logic (custom useMutation hook that allows you to reset errors). However, if you want the same information to be accessible throughout more components without prop drilling, it's better to use a context.

Contexts

Context provides a way to pass data through the component tree without having to pass props down manually at every level.

We try to avoid using contexts when possible, but sometimes it's an optimal solution for providing certain information at different levels in the component tree. Our database star button's query logic is contained to that component, and therefore is not a good candidate for a context.

However, our notifications system is a great example for when to use context. We display notifications when the current user has a database collaborator or organization member invitation. These invitation notifications are displayed both in our top navbar (visible on all pages) and on our settings page.

Invitation notifications

When the invitation is accepted in the settings page, we display a green banner to notify the user their invitation was successfully accepted.

Accepted invitation banner

The components affected (NavbarMenu, SettingsLayout, DatabaseInvitationList, OrganizationInvitationList) exist at various levels within the component tree, so a custom hook will not work without some serious prop drilling. Context is the ideal way to share data and hook logic throughout these components.

A simplified version of the invitation context would look like this:

// contexts/invitations.tsx

import React from "react";
import { useInvitationsForCurrentUserQuery } from "../../gen/graphql-types";
import { InvitationsContextType } from "./types";

export const InvitationsContext = React.createContext<InvitationsContextType>(
  {} as any
);

export function InvitationsProvider(props: { children: React.ReactNode }) {
  const { data, loading } = useInvitationsForCurrentUserQuery();
  const [numOrgInvitations, setNumOrgInvitations] = React.useState(
    data?.orgInvitations.length ?? 0
  );
  const [numDatabaseInvitations, setNumDatabaseInvitations] = React.useState(
    data?.databaseInvitations.length ?? 0
  );
  const [successMsg, setSuccessMsg] = React.useState("");

  React.useEffect(() => {
    if (!data) return;
    setNumOrgInvitations(data.orgInvitations.length);
    setNumDatabaseInvitations(data.databaseInvitations.length);
  }, [data]);

  return (
    <InvitationsContext.Provider
      value={{
        numOrgInvitations,
        numDatabaseInvitations,
        successMsg,
        setSuccessMsg,
        loading,
      }}
    >
      {props.children}
    </InvitationsContext.Provider>
  );
}

export function useInvitationsContext(): InvitationsContextType {
  return React.useContext(InvitationsContext);
}

The navbar menu is available on every authenticated page, so we need to wrap all these pages in InvitationsProvider to make the useInvitationsContext hook available to the components that need it. AppLayout is the most specific parent component we can use, since all components that need to access our context will be children of this component.

// components/layouts/AppLayout.tsx

export default function AppLayout(props: Props) {
  return (
    <InvitesProvider>
      <div>
        <Navbar />
        {props.children}
      </div>
    </InvitesProvider>
  );
}

Now we can use the useInvitationsContext hook to obtain the values from our context within all our components.

// components/NavbarMenu.tsx

export default function NavbarMenu({ user }: NavbarMenuProps) {
  const { numRepoInvites, numOrgInvites } = useInvitesContext();
  const totalNumInvites = numRepoInvites + numOrgInvites;
  return (
    <div>
      <Popup trigger={<ProfilePicWithNotifications user={user} />}>
        <Links user={user} totalNumInvites={totalNumInvites} />
      </Popup>
    </div>
  );
}
// components/OrgInvitationsList/ListItem.tsx

export default function OrgInvitationsListItem({ invite, username }: Props) {
  const { setSuccessMsg, success } = useInvitesContext();
  const [acceptInvitation] = useAcceptOrgInviteMutation();

  const onAccept = async () => {
    await acceptInvitation({ variables: invite });
    setSuccessMsg(`Success! You are now part of ${invite.orgName}`);
  };

  return (
    <tr>
      <td>
        <img src={invite.org?.profPicUrl} alt="" />
      </td>
      <td>
        <OrgLink params={invite}>{invite.orgName}</OrgLink>
      </td>
      <td>{invite.role} (pending)</td>
      <td>
        <Button.Underlined onClick={onAccept}>Accept</Button.Underlined>
      </td>
    </tr>
  );
}
// components/layouts/SettingsLayout/RightPanel.tsx

export default function RightPanel(props: Props) {
  const { successMsg } = useInvitesContext();
  return (
    <div>
      {successMsg && <SuccessMsg>{successMsg}</SuccessMsg>}
      <main>{props.children}</main>
    </div>
  );
}

Conclusion

Have any questions, comments, or feedback? Come chat with me in our Discord server in the #dolthub channel.

Interested in working on DoltHub? We're currently looking for full stack software engineers! Check out the job posting here.

SHARE

JOIN THE DATA EVOLUTION

Get started with Dolt