How we migrated from accessing and filtering data using Redux selectors to Apollo type policies.
As part of our organization’s migration from our React-Redux code base to client state management using Apollo, we’ve been exploring how to effectively implement data access patterns, side-effects handling, and other aspects of client state management using GraphQL and Apollo client.
This first post examines how we accomplish effective data access, filtering and transformation on the client like we used to through Redux selectors. Our goal is not only to find an Apollo equivalent to what we were able to achieve with Redux selectors, but also make sure that it is a step forward in effectively managing our client side data.
While we’ll briefly touch on where we’re coming from with Redux, the solutions are all Apollo-based and applicable to any project using Apollo regardless of their familiarity with Redux or other state management solutions.
Our organization made heavy use of Redux to manage data access and over the years we’ve built up hundreds of selectors that we use in various combinations across our applications. For those who are unfamiliar with Redux selectors, you can read about them here.
Redux isn’t a very complicated library, it essentially boils down to a single object {}
that stores all of your application’s state and you can dispatch actions
to update that global state object however you want. You have complete control over the shape of that global state object. Selectors are just utility functions that read, aka select, data from that global state object.
For example, your Redux state might look like this:
{
employees: [
{ id: 1, name: 'Alice', role: 'Manager', team: 'Banking' },
{ id: 2, name: 'Bob', role: 'Senior Developer', team: 'Investments' },
]
}
in which case a Redux selector could perform a data access like this:
const getEmployees = (state) => state?.employees ?? [];
or filter the data down like this:
const getBankingEmployees = (state) => getEmployees(state).filter(employee => employee.team === 'Banking');
const getManagers = (state) => getEmployees(state).filter(employee => employee.role === 'Manager');
As we can see, selectors are composable, so our applications often make use of chaining them together:
import _ from 'lodash';
const getBankingManagers = (state) => _.intersection(getManagers(state), getBankingEmployees(state));
Selectors make it easy tools to access what we need to from our data in the shape we want. Now let’s compare the tools Apollo gives us to accomplish this goal.
Apollo models its client state using a normalized client-side cache. There’s a great post from their team on what this means and how it works, which I’d recommend folks check out before we get too deeply in the weeds here.
Apollo’s version of our cached employees data looks like this:
{
'Employee:1': {
__typename: 'Employee',
id: 1,
name: 'Alice',
role: 'Manager',
team: 'Banking',
},
'Employee:2': {
__typename: 'Employee',
id: 1,
name: 'Bob',
role: 'Senior Developer',
team: 'Investments',
},
ROOT_QUERY: {
employees: {
__typename: 'EmployeesResponse',
data: [
{ __ref: 'Employee:1' },
{ __ref: 'Employee:2' },
]
}
}
}
This is more complicated than how things looked in Redux, so what’s going on here?
Since Apollo is a GraphQL client, data is written to the cache using queries and mutations. Our employees data has been written under an employees
field,
which could have been generated by a query like this:
query GetEmployees {
employees {
id
name
role
team
}
}
Since Apollo stores data entities using a normalization approach, the data in the cached queries are by reference, not value. We can see the values of these normalized entities adjacent to the ROOT_QUERY
, which stores all of the cached responses.
If we wanted to filter down our data to get the managers like we did before, we could write another query like this:
query GetManagers {
managers {
id
name
role
team
}
}
After this query is executed, the cache would be updated to look like this:
{
'Employee:1': {
__typename: 'Employee',
id: 1,
name: 'Alice',
role: 'Manager',
team: 'Banking',
},
'Employee:2': {
__typename: 'Employee',
id: 1,
name: 'Bob',
role: 'Senior Developer',
team: 'Investments',
},
ROOT_QUERY: {
employees: {
__typename: 'EmployeesResponse',
data: [
{ __ref: 'Employee:1' },
{ __ref: 'Employee:2' },
]
},
managers: {
__typename: 'EmployeesResponse',
data: [
{ __ref: 'Employee:1' },
]
}
}
}
Let’s break down the steps it took to do this:
GetManagers
on the clientmanagers
field and returned a filtered list of employeesEmployee:1
entityThe downside to this approach is that we still hit the network to get our managers when we would have been fine using the existing cached data we had on all of our employees to get a list of them. Making distinct queries over the network for data we already have available in the cache will slow down the experience for our users and introduce unnecessary network burden.
Instead of making a query over the network to our GraphQL server, we can use Apollo 3 field policies to access data from the cache.
A field policy, as described by Apollo’s documentation, allows developers to:
customize how a particular field in your Apollo Client cache is read and written.
We could customize how a field of our Employee
type is read like this:
const cache = new InMemoryCache({
typePolicies: {
Employee: {
fields: {
name: {
read(name, options) {
return name.toLowerCase();
}
}
},
},
},
});
The first argument is the existing value in the cache, which we can transform as desired. We can even define new field policies that aren’t in the schema:
const cache = new InMemoryCache({
typePolicies: {
Employee: {
fields: {
isWorkFromHome: {
read(isWorkFromHome, options) {
return isPandemicOn;
}
}
},
},
},
});
We can then query for this custom field in our queries using the client
directive:
query GetEmployees {
employees {
id
isWorkFromHome @client
}
}
When Apollo encounters a field marked with the client
directive, it knows not to send that field over the network and instead resolve it using our field policies against its cached data.
So far we’ve used field policies to to select data off of particular employee entities, but we still haven’t seen how we can get all of the managers from the cache like we did with Redux.
In addition to defining new fields on types like Employee
, we can also create custom client-side fields by defining fields on the Query
type:
const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
readManagers: {
read(managers, options) {
// TODO
}
}
},
},
},
});
Our client-side readManagers
field will read data from the Apollo cache and transform it into whatever shape we need to represent, achieving the same result as with using selectors. I’ve prefixed the field with read
as a convention to make it clear that we’re reading from the client cache rather than resolving a field from our remote GraphQL schema.
So how do we actually read the data we want for our query? We need to use the options.readField
API that Apollo provides in the field policy function. The full breakdown of the field policy options are available here in the Apollo docs. The readField
API takes 2 parameters, first a fieldName
to read from the cache and then the StoreObject
or Reference
to read it from.
One handy property of the readField
API that we can take advantage of is that if you don’t pass a 2nd parameter, it defaults to reading the field from the ROOT_QUERY
. This allows us to read existing fields from our new one:
const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
readManagers: {
read(managers, { readField }) {
const employees = readField('employees');
// Output:
// {
// __typename: 'EmployeesResponse',
// data: [
// { __ref: 'Employee:1' },
// { __ref: 'Employee:2' },
// ]
// }
return (employees?.data ?? []).filter(employeeRef => {
const employeeRole = readField('role', employeeRef);
return employeeRole === 'Manager';
});
}
}
},
},
},
});
Let’s break down the pattern for writing our client side query into steps:
readManagers
on the Query
type.employees
field from the cache using the readField
field policy API.readField
policy again to read their role
from the employee reference.Putting it all together, we’re now able to query for our managers anywhere we want in our application using a query like this:
query GetManagers {
readManagers @client {
id
name
}
}
In our example, we filtered down our list of managers from the cached employees
field. This is what I call a canonical field, a field that represents the entire collection of a particular type on the client from which custom filters and views of that data are derived.
Without a canonical field, it is much more difficult to support client-side data filtering and transformations. Consider a scenario where instead of an employees
field, our remote schema had separate bankingEmployees
, investmentEmployees
, managers
and engineers
fields that we’ve queried for and written to the cache.
This modeling makes it difficult to handle scenarios like add or deleting an employee from the system. The employee might be a manager on the banking team, requiring us to update both the cached bankingEmployees
and managers
queries.
It also makes it difficult to build client-side fields that are supersets of these fields, like if a part of our application wants to list out all employees. We would need to write a new field policy that reads all four of these fields and de-duplicates employees that exist across them.
By using a canonical field like employees
that is kept up to date with all instances of a particular entity, we can more easily manage derived fields that represent subsets and transformations on the collection.
Client-side field policies can easily be composed together similarly to how we demonstrated with Redux. To get all banking managers like we did in the Redux example, we can write a set of field policies like this:
const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
readEmployees: {
read(employees, { readField }) {
return readField('employees')?.data ?? [];
},
},
readManagers: {
read(managers, { readField }) {
return readField('readEmployees').filter(employeeRef => {
const employeeRole = readField('role', employeeRef);
return employeeRole === 'Manager';
});
}
},
readBankingTeam: {
read(managers, { readField }) {
return readField('readEmployees').filter(employeeRef => {
const employeeTeam = readField('team', employeeRef);
return employeeTeam === 'Banking';
});
}
},
readBankingManagers: {
read(bankingManagers, { readField }) {
return _.intersection(
readField('readManagers'),
readField('readBankingTeam')
);
}
}
},
},
},
});
As we can see in the example, our new field policies can reference eachother, making it easy to compose them together to create other field policies as needed. I created a readEmployees
field policy that parses out the data the raw employees
field from the GraphQL server so that we don’t need to keep doing that in every other field policy.
Instead of having to write a different field policy for each team of employees, it would be handy if we could provide the team to filter by to a single field policy. Field policies support variables similarly to any normal query, which we can provide as shown below:
const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
readEmployees: {
read(employees, { readField }) {
return readField('employees')?.data ?? [];
},
},
readTeamByName: {
read(managers, { readField, variables }) {
const { teamName } = variables;
return readField('readEmployees').filter(employeeRef => {
const employeeTeam = readField('team', employeeRef);
return employeeTeam === teamName;
});
}
},
readBankingTeam: {
read(bankingTeam, { readField }) {
return readField({ fieldName: 'readTeamByName', variables: { teamName: 'Banking' } });
}
}
},
},
},
});
As shown in the readBankingTeam
field policy, we can use an alternative object parameter to the readField
API in order to pass any variables we need when reading that field. A query on this field would then look like this:
query GetTeamByName($teamName: String!) {
readTeamByName(teamName: $teamName) @client {
id
name
}
}
For complex applications with a large number of field policies, it is important to have type safety guarantees for the fields we are reading and the shape of the policy return values. It is especially easy to make a typo on the name of the field you’re reading and cause hard to find errors down the road.
We added type safety to our field policies using the GraphQL code generator library, which will generate TypeScript types for each type in your GraphQL schema and each of your queries.
Assuming our Employee
type looked like this in our GraphQL schema:
enum EmployeeRole {
Manager
Developer
SeniorDeveloper
}
enum EmployeeTeam {
Banking
Investments
}
type Employee {
id: ID!
name: String!
team: EmployeeTeam!
role: EmployeeRole!
}
We would get a generated type looking like this:
export type Employee = {
id: Scalars['ID'],
name: Scalars['String'],
team: EmployeeTeam,
role: EmployeeRole,
}
It will define separate types for all enums and referenced GraphQL schema types and additionally if we are using the Apollo helpers plugin, we’ll also get types for our field policies that look like this:
export type TypedTypePolicies = TypePolicies & {
Query?: {
keyFields?: false | QueryKeySpecifier | (() => undefined | QueryKeySpecifier),
queryType?: true,
mutationType?: true,
subscriptionType?: true,
fields?: QueryFieldPolicy,
},
};
export type QueryFieldPolicy = {
readManagers?: FieldPolicy<any> | FieldReadFunction<any>,
...
}
At this time, the plugin isn’t perfect, as we can see that it uses the any
type for the field policies and doesn’t know things like what variables they might take, but they come in handy nevertheless as we’ll see later on.
Now that we have types for our entities, we need to add these constraints to the readField
API. Since Apollo does not know that we have generated types for our queries, or even that we are using TypeScript in general, the readField
API does not know that when we call readField('readEmployees')
, we will be getting back a list of employees, so we need to type that ourselves:
import { Employee, EmployeeRole } from './generated-types';
const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
readManagers: {
read(employees, { readField }) {
const employees = readField<Employee[]>('readEmployees');
return employees.filter(employeeRef => {
const employeeRole = readField<EmployeeRole>('role', employeeRef);
return employeeRole === 'Manager';
});
},
},
}
}
}
});
But wait! This is not actually the correct typing for readField('readEmployees')
because as we know, cached queries contain references. So a correct typing would be readField<Reference[]>('readEmployees')
with Reference
being imported from @apollo/client
.
While this typing is correct, it isn’t very helpful because we would need to know what the return value is for readEmployees
to know that it returns an array and even then, we’re still not getting type safety that the field readEmployees
actually exists.
In order to get better type safety, we need to add our custom fields to the GraphQL schema. We can do this by defining type policy configs which closely resemble our GraphQL server’s remote schema resolvers:
import { DocumentNode } from 'graphql';
import { gql } from '@apollo/client';
import { TypedTypePolicies } from './generated-types';
interface TypePoliciesSchema {
typeDefs?: DocumentNode;
typePolicies: TypedTypePolicies;
}
const schema: TypePoliciesSchema = {
typeDefs: gql`
extend type Query {
readManagers: [Employee!]!
}
`,
typePolicies: {
Query: {
fields: {
readManagers: {...},
readManagerss: {...} // Typo reports a TSC error
}
}
}
}
The TypedTypePolicies
type from earlier has come in handy here, as we now get type safety around the keys and options passed to our field policy. In order to make Apollo and the codegen aware of these schema definitions, we just pass them in as the typeDefs
option to Apollo client:
import EmployeeTypePoliciesSchema from './employee-type-policies';
const apolloClient = new ApolloClient({
...
typeDefs: [EmployeeTypePoliciesSchema.typeDefs],
});
But we’re still not quite there in terms of thorough type safety. Our next step involves defining a few custom utility types and helpers:
type DeepReference<X> = X extends Record<string, any>
? X extends { id: string }
? Reference
: {
[K in keyof X]: DeepReference<X[K]>;
}
: X extends Array<{ id: string }>
? Array<Reference>
: X;
export interface ReadFieldFunction {
<T, K extends keyof T = keyof T>(
context: FieldFunctionOptions,
options: ReadFieldOptions
): DeepReference<T[K]>;
<T, K extends keyof T = keyof T>(
context: FieldFunctionOptions,
fieldName: K,
from?: Reference | StoreObject | undefined
): DeepReference<T[K]>;
}
export const readField: ReadFieldFunction = (...args) => {
const [context, ...restArgs] = args;
return context.readField(...restArgs);
};
The main addition here is the custom readField
function. We will use this readField
that wraps the one from Apollo to add the rest of our type safety. Let’s see it in action:
import { DocumentNode } from 'graphql';
import { gql } from '@apollo/client';
import { readField } from './utils';
import { Query, TypedTypePolicies } from './generated-types';
interface TypePoliciesSchema {
typeDefs?: DocumentNode;
typePolicies: TypedTypePolicies;
}
const schema: TypePoliciesSchema = {
typeDefs: gql`
extend type Query {
readManagers: [Employee!]!
}
`,
typePolicies: {
Query: {
fields: {
readManagers: {
read(employees, context) {
const employees = readField<Query, 'readEmployees'>(context, 'readEmployees');
return employees.filter(employeeRef => {
const employeeRole = readField<Employee, 'role'>(context, 'role', employeeRef);
return employeeRole === 'Manager';
});
},
}
}
}
}
}
So what’s changed? Now our readField
function takes two generic type parameters:
Since we have defined our custom fields in the schema, the codegen library will include them in its Query
type along with the expected return values. In the case of the readEmployees
field used used above, it would look like this:
export type Query = {
readEmployees: Array<Employee>
}
The last change our readField
API makes is modifying the return type. While the Query
object is correct that readEmployees
would return an array of employees as part of a query, in the context of readField
we know that these will be references, so our DeepReference
recursively converts any types in the Array<Employee>
return type into references for us.
Note: The DeepReference utility determines whether an object should be a Reference by the presence of an ID field, which won’t always be correct if your type uses a different set of keys for its ID but this is correct most of the time depending on your app and 100% of our use cases so far.
This setup now gives us type safety that:
readField
exist on the type we’re reading fromreadField
call is correctly typed.These typings can help developers increase their confidence when writing field policies and hopefully lead to fewer bugs, especially in larger applications.
Since like many developers, we use Apollo in the context of a React application, we should also look at the effect our field policy data access approach will have on our application’s performance. First we’ll briefly cover the performance of a typical Redux setup so that we can compare.
Let’s revisit our composite getBankingManagers
selector from earlier on:
import _ from 'lodash';
export const getBankingManagers = (state) => _.intersection(getManagers(state), getBankingEmployees(state));
A typical usage of it in one of our React components could look like this:
import React from 'react';
import { connect } from 'react-redux';
import { getBankingManagers } from './selectors';
const BankingManagerList = ({ managers }) => {
return (
<ul>
{managers.forEach(manager => <li>{manager.name}</li>)}
</ul>
);
});
const mapStateToProps = (state) => ({
managers: getBankingManagers(state),
});
export default connect(mapStateToProps)(BankingManagerList);
But if re-render computation is a performance concern in your component, then there’s a problem here. The Redux docs call out this problem explicitly:
React Redux implements several optimizations to ensure your actual component only re-renders when actually necessary. One of those is a shallow equality check on the combined props object generated by the mapStateToProps and mapDispatchToProps arguments passed to connect. Unfortunately, shallow equality does not help in cases where new array or object instances are created each time mapStateToProps is called
Our getBankingManagers
selector returns a new array every time it is evaluated, regardless of whether the managers have changed. To solve this problem, we use the Reselect library in our application.
Reselect provides a createSelector
function for memoizing selectors based on their inputs. We can see what it looks like when applied to our getBankingManagers
below:
import _ from 'lodash';
import { createSelector } from 'reselect';
const getEmployees = (state) => state?.employees ?? [];
const getBankingEmployees = (state) => getEmployees(state).filter(employee => employee.team === 'Banking');
const getManagers = (state) => getEmployees(state).filter(employee => employee.role === 'Manager');
export const getBankingManagers = createSelector(
[getBankingEmployees, getManagers],
(bankingEmployees, managers) => _.intersection(bankingEmployees, managers;
)
Now when an action is fired and Redux reinvokes our getBankingManagers
, it will only recompute if its inputs change. In this case its inputs are the outputs of first running
getBankingEmployees
and getManagers
on the updated Redux state. But now we still have a problem, because those selectors return new arrays too!
We’ll need to add even more memoization to our other selectors like this:
import _ from 'lodash';
import { createSelector } from 'reselect';
const array = [];
const getEmployees = (state) => state?.employees ?? array;
const getBankingEmployees = createSelector(
[getEmployees],
employees => employees.filter(employee => employee.team === 'Banking')
);
const getManagers = createSelector(
[getEmployees],
employees => employee.role === 'Manager'
);
export const getBankingManagers = (state) => createSelector(
[getBankingEmployees, getManagers],
(bankingEmployees, managers) => _.intersection(bankingEmployees, managers),
)
Now all of our selectors in the chain from getBankingManagers
are memoized and the base selector, getEmployees
, will return a static array if it has no value. This whole memoization process does add extra overhead when writing selectors so it can be done as necessary. Our application, for example, has hundreds of selectors that we have tried to keep memoized because of performance concerns we’ve encountered in the past.
Given how React-Redux apps can optimize their data accesses in components, let’s compare this behaviour to how component re-renders work for Apollo queries.
TLDR: There’s lots of caching and memoization under the hood of the Apollo client that gives our field policies good performance out of the box.
Apollo queries are typically wired up in React components using the useQuery
React hook. A BankingManagersList
component would look something like this:
import React from 'react';
import { gql, useQuery } from '@apollo/client';
const bankingManagersQuery = gql`
query ReadBankingManagers {
readBankingManagers @client {
id
name
}
}
`;
const BankingManagersList = ({ managers }) => {
const { data, loading, error } = useQuery(bankingManagersQuery, { fetchPolicy: 'cache-only' });
const managers = data?.readBankingManagers ?? [];
return (
<ul>
{managers.forEach(manager => <li>{manager.name}</li>)}
</ul>
);
});
Assuming we already have the data in the cache for the employees
field that readBankingManagers
reads from, then when this query is executed our component will re-render with a list of managers. There are still a couple open questions it would be good to answer:
useQuery
hook re-read data from the cache every time the component re-renders from prop or state changes?To answer these questions, we need to explore the Apollo client implementation. Here’s a basic diagram of the modules of the Apollo client involved in getting a component it’s data:
Let’s breakdown each of these modules by their role in delivering data to our component.
Note: many of these modules do much more than what I’ve listed out here, but I’ve tried to streamline the details to the role they play in getting our component its data. I’m also not an Apollo engineer and I’m sure they could break this down more accurately.
useQuery
hook is just a wrapper around the useBaseQuery
hook.QueryData
instance and passes it a forceUpdate
function that can be used to trigger re-renders when the data the query cares about changes.forceUpdate
is called, it reads the latest data from QueryData
and re-renders.QueryData
, only re-rendering when forceUpdate
is called or hook options like variables
or fetchPolicy
changes.QueryManager
to set up a watchQuery
on the GraphQL document that the component cares about and subscribes to changeswatchQuery
.watchQuery
API.watchQuery
is called, it creates a QueryInfo
object, passing it the GraphQL document from QueryData
as well as an observable that QueryInfo
can notify
when data related to the document has changed in the cache. It returns this observable to QueryData
so that it can receive updates.QueryInfo
objects by query ID.cache.watch
, passing the cache its query document so that the cache can deliver it diffs of the data it cares about.QueryManager
.Now that we have identified the core modules involved in querying for data from our component, let’s see what happens when the query we set up in BankingManagerList
comes back from the network:
cache.write
API.write
API calls broadcastWatches()
to let subscribed watchers of the cache know that there has been a change.broadcastWatches
calls maybeBroadcastWatch
for each watcher, which will filter down the list of watchers to only the ones that might be dirty (more on how it determines this later).QueryInfo
watcher that might be dirty, the cache calls the QueryInfo
’s provided callback with a cache diff for the its query document.QueryInfo
instance notifies the observable set up by the QueryManager
.QueryData
subscribed to this observable receives the diff and performs a deep equality check against its current data to see if it should tell the component to forceUpdate
.forceUpdate
is called, the component re-renders and calls QueryData.execute
to read the new data waiting for it.Let’s revisit the three questions we wanted to explore:
useQuery
hook read data from the cache every time the component re-renders from prop or state changes?
forceUpdate
is called or the options to the hook change.If Apollo stopped optimizing accessing cached data as we understand the system so far, then whenever the cache was written to, it would calculate diffs for all of its watchers which includes a watcher for each component using useQuery
. This could have performance implications if we have a lot of components using useQuery
across our application.
To get around this problem, Apollo uses a dependency graph that tracks the dependencies of a field as they are read. Let’s look at our readBankingManagers
field policy again:
readBankingManagers: {
read(bankingManagers, { readField }) {
return _.intersection(
readField('readManagers'),
readField('readBankingTeam')
);
}
}
When Apollo reads the readBankingManagers
field, it records all field names that were resolved as part of evaluating that field as dependencies. In this case it would include the readManagers
and readBankingTeam
fields. These dependencies are tracked using the Optimism reactive caching developed by Apollo’s core developers.
This dependency graph is set up deeply for nested fields. So when we execute a query like this:
query GetBankingManagers {
readBankingManagers {
id
name
}
}
and have the following data in the cache:
{
'Employee:1': {
__typename: 'Employee',
id: 1,
name: 'Alice',
role: 'Manager',
team: 'Banking',
},
'Employee:2': {
__typename: 'Employee',
id: 1,
name: 'Bob',
role: 'Senior Developer',
team: 'Investments',
},
ROOT_QUERY: {
employees: {
__typename: 'EmployeesResponse',
data: [
{ __ref: 'Employee:1' },
{ __ref: 'Employee:2' },
]
},
managers: {
__typename: 'EmployeesResponse',
data: [
{ __ref: 'Employee:1' },
]
}
}
}
We will get a dependency graph for this query that looks like this:
Now let’s consider a couple scenarios:
Employee:1
is updatedWhen the cache calls broadcastWatches
after writing the new name, the dependency graph for GetBankingManagers
will be dirty, so it proceeds with diffing the query against the updated cache and delivering that data to the QueryData
instance. If that name is different from what is was previously, QueryData
will tell the component to forceUpdate
.
writeFragment
client.writeFragment({
data: {
__typename: 'Employee',
id: 3,
name: 'Charles',
role: 'Manager',
team: 'Banking',
},
id: 'Employee:3',
fragment: gql`
fragment NewEmployee on Employee {
id
name
role
team
}
`,
});
When a new employee is written to the cache using writeFragment
, none of the fields in our dependency graph have been affected since it is not included in any of our employees responses and only exists as a normalized entity. The subsequent call to broadcastWatches
would therefore skip over re-calcuating the diff for the GetBankingManagers
query.
employees
fieldclient.modify({
fields: {
employees: (currentEmployeesResponse) => {
return {
...currentEmployeesResponse,
data: [
...currentEmployeesResponse.data,
{ __ref: 'Employee:3' },
]
}
}
}
});
When the new manager is written into the employees
field, the dependency graph for the field is dirtied and now when broadcastWatches
is called, the cache will re-calculate the diff for the employees field and deliver it to QueryData
. QueryData
will do a deep equality check on the diff and see that there is a new employee, telling the component to update.
Reflecting on the breakdown of the Apollo caching mechanisms and these examples, we can see that Apollo offers powerful built-in performance optimizations that eliminate the need for the sort of client-side memoization akin to what we had to do with Redux selectors.
We’ve spent a good chunk of time writing field policies and now we need to see how to test them. Given the number of selectors in our applications and the number of field policies we’ll need, it’s critical that they be easily testable to support an effective migration and future development.
Let’s see how we can test our bankingManagers
policy. We’ll use jest
syntax here but it’s all pretty testing library agnostic:
import typePolicies from './type-policies';
import { InMemoryCache, gql } from "@apollo/client";
describe('employees field policies', () => {
let cache: InMemoryCache;
beforeEach(() => {
cache = new InMemoryCache({
typePolicies,
});
});
describe('bankingManagers', () => {
test('should return employees with role `manager` and team `banking`', () => {
cache.writeQuery({
query: gql`
query WriteEmployeees {
employees {
id
name
team
role
}
}
`,
data: {
employees: {
__typename: 'EmployeesResponse',
employees: [
{
__typename: 'Employee',
id: 1,
name: 'Alice',
role: 'Manager',
team: 'Banking',
},
{
__typename: 'Employee',
id: 2,
name: 'Bob',
role: 'Senior Developer',
team: 'Investments',
},
],
},
},
});
expect(
cache.readQuery({
query: gql`
query {
readBankingManagers @client {
id
}
}
`,
})
).toEqual({
readBankingManagers: [
{
__typename: 'Employee',
id: 1,
},
],
});
})
});
})
As we can see, testing field policies can be accomplished in three basic steps:
cache.writeQuery
.cache.readQuery
and assert that it returns the correct data.Queries and test data can be shared across tests, making the testing experience pretty simple and straightforward.
The goal of this post was to demonstrate how field policies can be used for data access by teams starting out with or migrating to Apollo from other libraries like Redux. We’ve dug into what problem field policies solve, how they can be composed together, how to safely type them, what their performance implications are, and their testability.
We will re-visit the topic again in the future as we expand our usage of field policies in our applications to provide further learnings. Hopefully this breakdown has empowered teams to feel confident using these policies to manage access of their client-side data and made the integration of Apollo to their client tech stack a little clearer.
That’s it for now!