DEV Community

Josh Console for EF Go Ahead Tours

Posted on

Computed local-only fields in Apollo Client

This article was also posted on eftech.com

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.

const USER_QUERY = gql`
  query User {
    user {
      id
      firstName
      lastName
      department {
        id
        name
      }
    }
  }
`;
Enter fullscreen mode Exit fullscreen mode

We use the result of this operation in some UserProfile component to render a display name in the format of John Doe - Engineering team.

const UserProfile = () => {
  const { data } = useQuery(USER_QUERY);
  const displayName = `${data.user.firstName} ${data.user.lastName} - ${data.user.department.name} team`;

  return (
    <div>
      <ProfilePicture />
      <p>{displayName}</p>
      <ContactInfo />
    </div>
  );    
}
Enter fullscreen mode Exit fullscreen mode

As time goes on, we find ourselves using this same displayName format in numerous places throughout our application, duplicating the same logic each time.

const BlogPost = () => {
  const { data } = useQuery(USER_QUERY);
  const displayName = `${data.user.firstName} ${data.user.lastName} - ${data.user.department.name} team`;

  return (
    <div>
      <BlogTitle />
      <p>Written by {displayName}</p>
      <BlogContent />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

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.)

const cache = new InMemoryCache({
  typePolicies: {
    User: {
      fields: {
        displayName: {
          read(_) {
            return null;  // We'll implement this soon
          }
        }
      }
    }
  }
});
Enter fullscreen mode Exit fullscreen mode

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.

const USER_QUERY = gql`
  query User {
    user {
      id
      firstName
      lastName
      displayName @client
      department {
        id
        name
      }
    }
  }
`;
Enter fullscreen mode Exit fullscreen mode

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).

const cache = new InMemoryCache({
  typePolicies: {
    User: {
      fields: {
        displayName: {
          read(_, { readField }) {
            // References the parent User object by default
            const firstName = readField("firstName");
            const lastName = readField("lastName");
             // References the Department object of the parent User object
            const departmentName = readField("name", readField("department"));

            // We can't guarantee these fields were included in the operation
            if (!firstName || !lastName || !departmentName) {
              return "A Valued Team Member";
            }
            return `${data.user.firstName} ${data.user.lastName} - ${data.user.department.name} team`;
          }
        }
      }
    }
  }
});
Enter fullscreen mode Exit fullscreen mode

Now, it's trivial to use this formatted display name anywhere in our application - it's just another field on our query data!

const BlogPost = () => {
  const { data } = useQuery(USER_QUERY);

  return (
    <div>
      <BlogTitle />
      <p>Written by {data.displayName}</p>
      <BlogContent />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

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.

const typeDefs = gql`
  extend type User {
    # Don't forget to return a default value
    # in the event a field dependency is undefined
    # if the local-only field is non-nullable
    displayName: String!
  }
`;
Enter fullscreen mode Exit fullscreen mode

Top comments (1)

Collapse
 
wongmrdev profile image
M Wong

How to access nested fields that are Arrays?
This method doesn't play well with interfaces
It seems like a lot of work and there is no type safety, building these client fields.

Whats the performance improvement/dev experience gains by using these client fields?