,

Upleveling Our Developer Experience with GraphQL and Apollo Client

Chris Schmitz, Senior Software Engineer shares how to work with GraphQL and Apollo to improve your DevX team's efficiency and effectiveness.

Handshake is starting to roll out some features powered by a new GraphQL API. As a result, we’re exploring new tooling, patterns, and best practices that we hope will provide a better Developer Experience (DX) going forward. We decided not to use Apollo on iOS, but have been going all in on leveraging Apollo Client and its ecosystem of tools in our web UI.

As part of this transition we focused on improving the DX in 3 main areas:

  1. A declarative API for accessing data at any level of the component hierarchy. This is critical since so many components need to interact with data from our APIs.
  2. TypeScript interface generation which provides more accurate interfaces and eliminates the need to maintain them by hand. This gives developers a much faster feedback loop and more confidence in the code they are writing.
  3. A mock API for testing to make our tests more reliable and easier to write and maintain.

Missing Redux

We’re coming from using Redux and Normalizr for storing data from the server and Reselect to pull data out of the store anywhere in our component hierarchy. The ability to query data from the store wherever it’s needed is something we have enjoyed and wanted to preserve in our transition.

When we started adopting Apollo Client, the only obvious way to expose data from our top-level components making calls to useQuery was to pass data down via props wherever we needed it. This approach resulted in prop drilling that smelled bad to us, and we missed having the ability to reach into the Redux store and pull out data in deeper levels of the component tree. We quickly started looking for a way to provide a similar DX to what we had with Redux where each component could query the store to pull out the data it needed.

Fragment Composition

We wanted to allow each component to declare its own data needs, which helps developers get a better picture of a component’s dependencies at a glance. We realized that Relay provided the DX we were looking for with Fragment Composition, however, we were already pretty sold on Apollo, their community, and ecosystem of supporting tools. There has been some discussion about supporting fragment composition in Apollo Client and some promising projects by the community, but we didn’t see any attempts that appeared to be production-ready for an app like Handshake.

With Apollo Client we are able to colocate fragments with our components. This can be a good organizational pattern, but requires defining the fragment plus updating the main query to stitch things together. This pattern certainly has tradeoffs and we don’t feel strongly that it’s always the right fit, but is a nice organizational pattern to keep in mind.

Query Hooks and Caching

Colocating fragments allowed our components to declare their own data needs, but we still wanted to find a way to access data from the query without passing it down via props. After some experimentation, we realized that we could make calls for the same query multiple times in different components and rely on Apollo Client’s caching logic to avoid making duplicate requests. We ended up wrapping our calls to useQuery so we can minimize the amount of code required to reuse a query in each component. This gives us a logical place to put our gql queries and supporting TypeScript interfaces.

import { gql, useQuery } from "@apollo/client";
import { GetUser, GetUserVariables, } from "./types/GetUser";
export const GET_JOB_ROLE_GROUP_SHOW_QUERY = gql`
query GetUser($id: ID!) {
user(id: $id) {
id
first_name
last_name
}
}
`;
export const useGetUserQuery = () =>
useQuery<GetUser, GetUserVariables>(
GET_JOB_ROLE_GROUP_SHOW_QUERY,
{
variables: {
id: getResourceId(),
},
}
);
view raw 1801126254_1.js hosted with ❤ by GitHub

TypeScript Interface Generation

Since many components don’t have their own colocated fragment it can be hard to remember what data is available. This can be especially challenging in GraphQL since each query is specifying the exact data it needs and the shape of each response could be different. Having TypeScript interfaces for our queries is very helpful for this since it lets us easily explore the data we have access to in the response.

Screenshot showing how TypeScript interfaces help us to have a better explorability on our data structure
TypeScript interfaces are useful for our queries as it lets us easily explore the data we have access to in the response.

Maintaining these interfaces by hand can be very tedious. Luckily, the Apollo team has built apollo-tools, which provides code generation for TypeScript interfaces based on gql templates. We can set up apollo-tools to watch for file changes on our gql templates and automatically regenerate TS interfaces, which provides a nice feedback loop during development.

Gif with the changes on our gql templates and how the TypeScript interfaces regenerate
Apollo-tools allow us to watch for file changes on our gql templates and regenerate the TypeScript interfaces

There are some compelling alternatives to apollo-tools that are also worth checking out. Our team is currently investigating GraphQL Code Generator, which can generate more than just types based on queries. Stay tuned to our blog for more on this!

Testing with a Mock API

Now that we have much more accurate interfaces for the responses of our requests, we need to make sure our mocks in our tests match the interfaces. We can’t reuse generic factories like we did for requests to our REST API because those include a lot of attributes we aren’t using.

Because we have all the type definitions for our schema, we can actually automate generating mocks for each query. Following the example in this article we built our own MockedProvider which provides a mock API rather than mocking individual requests in each test. Here is what this looks like in an example spec:

describe("<EmployerCard />", () => {
const customResolvers = {
Query: () => ({
employers: () => ({
id: 1,
name: "Handshake",
}),
}),
};
const mountComponent = () =>
render(
<ApolloMockedProvider customResolvers={customResolvers}>
<EmployerCard id={1} />
</ApolloMockedProvider>,
);
it("renders the employer's name", async () => {
const { getByText } = mountComponent();
expect(getByText("Handshake")).toBeInTheDocument();
});
});
view raw 1801126254_2.js hosted with ❤ by GitHub

Note that we still have the ability to override specific attributes of the query so we can make assertions against data we declare in the setup for our tests. We ensure the employer returned from the mock API has the name “Handshake” which allows us to make an assertion on it later on.

If we were to use Apollo’s MockedProvider we would need to specify all fields in our query under results.data for our TypeScript interfaces to be accurate. It would be a lot of work to maintain these responses by hand and we didn’t want to bypass our interfaces in tests. Using the mock API gets us around this and saves a lot of effort maintaining mocks by hand. There are certainly tradeoffs to using the mock API, but we believe the benefits outweigh the costs for us.

Conclusion

Defining fragments alongside our components and using our query hooks wherever we need access to the data in our components saves us from prop drilling and provides a DX we are happy with. We’re also able to get more accurate TypeScript interfaces for responses than we had with our REST API and we don’t need to spend any time maintaining them thanks to apollo-tools’s codegen. Finally, we no longer need to implement mocks for every API request in our tests thanks to our mock API.

Photo by Surface on Unsplash