Computed local-only fields in Apollo Client
One valuable feature of Apollo Client is local-only
fields. These fields are redacted from an operation that is sent to the server by an application, and then computed and added to the server response to generate the final result. The Apollo docs clearly explain how to leverage this feature for local state management, but it's less clear on how to derive pure local-only fields solely from other fields on the operation result.
A (Contrived) Example
Suppose we have an operation that queries for the current user.
1const USER_QUERY = gql`2 query User {3 user {4 id5 firstName6 lastName7 department {8 id9 name10 }11 }12 }13`;
We use the result of this operation in some UserProfile
component to render a display name in the format of John Doe - Engineering team
.
1const UserProfile = () => {2 const { data } = useQuery(USER_QUERY);3 const displayName = `${data.user.firstName} ${data.user.lastName} - ${data.user.department.name} team`;4
5 return (6 <div>7 <ProfilePicture />8 <p>{displayName}</p>9 <ContactInfo />10 </div>11 ); 12}
As time goes on, we find ourselves using this same displayName
format in numerous places throughout our application, duplicating the same logic each time.
1const BlogPost = () => {2 const { data } = useQuery(USER_QUERY);3 const displayName = `${data.user.firstName} ${data.user.lastName} - ${data.user.department.name} team`;4
5 return (6 <div>7 <BlogTitle />8 <p>Written by {displayName}</p>9 <BlogContent />10 </div>11 );12}13
We consider how best to reuse this formatted name across our application. Our first thought might be a server-side resolver, but this isn't always feasible. We might want to make use of client-side data - local time, for example - or maybe our calculation will use fields from a variety of subgraphs that are difficult to federate between. Our next thought is a React component, but this won't work very well either. We want a consistent format for our displayName, but usage or styling might vary considerably depending on context. What about a hook, then? Maybe a useDisplayName
hook that encapsulates the query and display logic? Better, but inelegant: we'll probably find ourselves invoking both the useQuery
and useDisplayName
hook in the same components, over and over. What we'd really like is not logic derived from the query result, but rather logic incorporated in the query result.
A Solution
The first requirement for a local-only field is a corresponding field policy with a read
function in our cache. (Technically, a field policy could be omitted in favor of reading to and writing from the cache, but we'll save that for another post.)
1const cache = new InMemoryCache({2 typePolicies: {3 User: {4 fields: {5 displayName: {6 read(_) {7 return null; // We'll implement this soon8 }9 }10 }11 }12 }13});
The first argument of the read
function is the existing field value, which will be undefined for local-only fields since by definition they do not yet exist.
The other requirement for a local-only field is to add it to the operation with the @client
directive. This directs Apollo Client to strip the field from the server request and then restore it to the result, with the value computed by the read
function.
1const USER_QUERY = gql`2 query User {3 user {4 id5 firstName6 lastName7 displayName @client8 department {9 id10 name11 }12 }13 }14`;
This field will now be included in the data
field returned by our useQuery
hook, but of course, it always returns null right now. Our desired format requires three fields from the server response: the user firstName
and lastName
, and the department name
. The trick here is readField
, a helper provided by the second "options" argument of the read
function. This helper will provide the requested value (if it exists) from the parent object of the field by default, or from another object if it's included as the second argument. This helper will also resolve normalized references, allowing us to conveniently nest readField
invocations. Note that we can't really force the operation to include the fields on which the local-only field is dependent, and thus readField
always has the potential to return a value of undefined (even if it's a non-nullable field).
1const cache = new InMemoryCache({2 typePolicies: {3 User: {4 fields: {5 displayName: {6 read(_, { readField }) {7 // References the parent User object by default8 const firstName = readField("firstName");9 const lastName = readField("lastName");10 // References the Department object of the parent User object11 const departmentName = readField("name", readField("department"));12
13 // We can't guarantee these fields were included in the operation14 if (!firstName || !lastName || !departmentName) {15 return "A Valued Team Member";16 }17 return `${data.user.firstName} ${data.user.lastName} - ${data.user.department.name} team`;18 }19 }20 }21 }22 }23});
Now, it's trivial to use this formatted display name anywhere in our application - it's just another field on our query data!
1const BlogPost = () => {2 const { data } = useQuery(USER_QUERY);3
4 return (5 <div>6 <BlogTitle />7 <p>Written by {data.displayName}</p>8 <BlogContent />9 </div>10 );11}
Bonus: Local-only fields with GraphQL Code Generation
It's trivial to include local-only fields if you're using graphql-codegen
(and if you're not using it, it's pretty easy to get started, too.). All you need to do is extend the type to which you're adding the local-only field in your client-side schema file.
1const typeDefs = gql`2 extend type User {3 # Don't forget to return a default value4 # in the event a field dependency is undefined5 # if the local-only field is non-nullable6 displayName: String!7 }8`;