Local-only fields in Apollo Client
Fetch local and remote data with one GraphQL query
Your Apollo Client queries can include local-only fields that aren't defined in your GraphQL server's schema:
1query ProductDetails($productId: ID!) {
2 product(id: $productId) {
3 name
4 price
5 isInCart @client # This is a local-only field
6 }
7}
The values for these fields are calculated locally using any logic you want, such as reading data from localStorage
.
As shown, a query can include both local-only fields and fields that are fetched from your GraphQL server.
Defining
Let's say we're building an e-commerce application. Most of a product's details are stored on our back-end server, but we want to define a Product.isInCart
boolean field that's local to the client. First, we create a field policy for isInCart
.
A field policy specifies custom logic for how a single GraphQL field is fetched from and written to your Apollo Client cache. You can define field policies for both local-only fields and remotely fetched fields.
Field policies are primarily documented in Customizing the behavior of cached fields. This article specifically describes how to use them with local-only fields.
You define your application's field policies in a map that you provide to the constructor of Apollo Client's InMemoryCache
. Each field policy is a child of a particular type policy (much like the corresponding field is a child of a particular type).
Here's a sample InMemoryCache
constructor that defines a field policy for Product.isInCart
:
1const cache = new InMemoryCache({
2 typePolicies: { // Type policy map
3 Product: {
4 fields: { // Field policy map for the Product type
5 isInCart: { // Field policy for the isInCart field
6 read(_, { variables }) { // The read function for the isInCart field
7 return localStorage.getItem('CART').includes(
8 variables.productId
9 );
10 }
11 }
12 }
13 }
14 }
15});
The field policy above defines a read
function for the isInCart
field. Whenever you query a field that has a read
function, the cache calls that function to calculate the field's value. In this case, the read
function returns whether the queried product's ID is in the CART
array in localStorage
.
You can use read
functions to perform any sort of logic you want, including:
Manually executing other cache operations
Calling helper utilities or libraries to prepare, validate, or sanitize data
Fetching data from a separate store
Logging usage metrics
If you query a local-only field that doesn't define a
read
function, Apollo Client performs a default cache lookup for the field. See Storing local state in the cache for details.
Reads are synchronous by design
Many UI frameworks like React (when not using Suspense
) have synchronous rendering pipelines, therefore it's important for UI components to have immediate access to any existing data. This is why all read
functions are synchronous, as are the cache's readQuery
and readFragment
methods. It is possible, however, to leverage reactive variables and options.storage
to compose a read
function that behaves in a manner that resembles an asynchronous action:
See example
1new InMemoryCache({
2 typePolicies: {
3 Person: {
4 fields: {
5 isInCart: {
6 read(_, { variables, storage }) {
7 if (!storage.var) {
8 storage.var = makeVar(false);
9 setTimeout(() => {
10 storage.var(
11 localStorage.getItem('CART').includes(
12 variables.productId
13 )
14 );
15 }, 100);
16 }
17 return storage.var();
18 }
19 }
20 }
21 }
22 }
23})
Querying
Now that we've defined a field policy for isInCart
, we can include the field in a query that also queries our back-end server, like so:
1const GET_PRODUCT_DETAILS = gql`
2 query ProductDetails($productId: ID!) {
3 product(id: $productId) {
4 name
5 price
6 isInCart @client
7 }
8 }
9`;
That's it! The @client
directive tells Apollo Client that isInCart
is a local-only field. Because isInCart
is local-only, Apollo Client omits it from the query it sends to our server to fetch name
and price
. The final query result is returned only after all local and remote fields are populated.
Note: If you apply the
@client
directive to a field with subfields, the directive is automatically applied to all subfields.
See exampleJavaScript1const GET_PRODUCT_DETAILS = gql` 2 query ProductDetails($productId: ID!) { 3 product(id: $productId) { 4 name 5 price 6 purchaseStatus @client { 7 isInCart 8 isOnWishlist 9 } 10 } 11 } 12`;
Storing and mutations
You can use Apollo Client to query and modify local state, regardless of how you store that state. Apollo Client provides a couple of optional but helpful mechanisms for representing local state:
It's tempting to think of local-only mutations as being expressed similarly to other local-only fields. When using previous versions of Apollo Client, developers would define local-only mutations using @client
. They would then use local resolvers to handle client.mutate
/ useMutation
calls. This is no longer the case, however. For Apollo Client version >= 3 users we recommend using writeQuery
, writeFragment
, or reactive variables to manage local state.
Storing and updating local state with reactive variables
Apollo Client reactive variables are great for representing local state:
You can read and modify reactive variables from anywhere in your application, without needing to use a GraphQL operation to do so.
Unlike the Apollo Client cache, reactive variables don't enforce data normalization, which means you can store data in any format you want.
If a field's value depends on the value of a reactive variable, and that variable's value changes, every active query that includes the field automatically refreshes.
Example
Returning to our e-commerce application, let's say we want to fetch a list of the item IDs in a user's cart, and this list is stored locally. The query to do that looks like this:
1export const GET_CART_ITEMS = gql`
2 query GetCartItems {
3 cartItems @client
4 }
5`;
Let's use the makeVar
function to initialize a reactive variable that stores our local list of cart items:
1import { makeVar } from '@apollo/client';
2
3export const cartItemsVar = makeVar([]);
This initializes a reactive variable with an empty array (you can pass any initial value to makeVar
). Note that the return value of makeVar
isn't the variable itself, but rather a function. We get the variable's current value by calling cartItemsVar()
, and we set a new value by calling cartItemsVar(newValue)
.
Next, let's define the field policy for cartItems
. As always, we pass this to the constructor of InMemoryCache
:
1export const cache = new InMemoryCache({
2 typePolicies: {
3 Query: {
4 fields: {
5 cartItems: {
6 read() {
7 return cartItemsVar();
8 }
9 }
10 }
11 }
12 }
13});
This read
function returns the value of our reactive variable whenever cartItems
is queried.
Now, let's create a button component that enables the user to add a product to their cart:
1import { cartItemsVar } from './cache';
2// ... other imports
3
4export function AddToCartButton({ productId }) {
5 return (
6 <div class="add-to-cart-button">
7 <Button onClick={() => cartItemsVar([...cartItemsVar(), productId])}>
8 Add to Cart
9 </Button>
10 </div>
11 );
12}
On click, this button updates the value of cartItemsVar
to append the button's associated productId
. When this happens, Apollo Client notifies every active query that includes the cartItems
field.
Here's a Cart
component that uses the GET_CART_ITEMS
query and therefore refreshes automatically whenever the value of cartItemsVar
changes:
1export const GET_CART_ITEMS = gql`
2 query GetCartItems {
3 cartItems @client
4 }
5`;
6
7export function Cart() {
8 const { data, loading, error } = useQuery(GET_CART_ITEMS);
9
10 if (loading) return <Loading />;
11 if (error) return <p>ERROR: {error.message}</p>;
12
13 return (
14 <div class="cart">
15 <Header>My Cart</Header>
16 {data && data.cartItems.length === 0 ? (
17 <p>No items in your cart</p>
18 ) : (
19 <Fragment>
20 {data && data.cartItems.map(productId => (
21 <CartItem key={productId} />
22 ))}
23 </Fragment>
24 )}
25 </div>
26 );
27}
Instead of querying for cartItems
, the Cart
component can read and react to a reactive variable directly with the useReactiveVar
hook:
1import { useReactiveVar } from '@apollo/client';
2
3export function Cart() {
4 const cartItems = useReactiveVar(cartItemsVar);
5
6 return (
7 <div class="cart">
8 <Header>My Cart</Header>
9 {cartItems.length === 0 ? (
10 <p>No items in your cart</p>
11 ) : (
12 <Fragment>
13 {cartItems.map(productId => (
14 <CartItem key={productId} />
15 ))}
16 </Fragment>
17 )}
18 </div>
19 );
20}
As with the preceding useQuery
example, whenever the cartItemsVar
variable is updated, the Cart
component rerenders.
Important: If you call
cartItemsVar()
instead ofuseReactiveVar(cartItemsVar)
in the example above, future variable updates do not cause theCart
component to rerender.
Storing and modifying local state in the cache
Storing local state directly in the Apollo Client cache provides some advantages, but usually requires more code than using reactive variables:
You don't have to define a field policy for local-only fields that are present in the cache. If you query a field that doesn't define a
read
function, by default Apollo Client attempts to fetch that field's value directly from the cache.When you modify a cached field with
writeQuery
orwriteFragment
, every active query that includes the field automatically refreshes.
Example
Let's say our application defines the following query:
1const IS_LOGGED_IN = gql`
2 query IsUserLoggedIn {
3 isLoggedIn @client
4 }
5`;
The isLoggedIn
field of this query is a local-only field. We can use the writeQuery
method to write a value for this field directly to the Apollo Client cache, like so:
1cache.writeQuery({
2 query: IS_LOGGED_IN,
3 data: {
4 isLoggedIn: !!localStorage.getItem("token"),
5 },
6});
This writes a boolean value according to whether localStorage
includes a token
item, indicating an active session.
Now our application's components can render according to the value of the isLoggedIn
field, without our needing to define a read
function for it:
1function App() {
2 const { data } = useQuery(IS_LOGGED_IN);
3 return data.isLoggedIn ? <Pages /> : <Login />;
4}
Here's a full example that incorporates the code blocks above:
Expand example
1import React from 'react';
2import ReactDOM from 'react-dom';
3import {
4 ApolloClient,
5 InMemoryCache,
6 ApolloProvider,
7 useQuery,
8 gql
9} from '@apollo/client';
10
11import Pages from './pages';
12import Login from './pages/login';
13
14const cache = new InMemoryCache();
15
16const client = new ApolloClient({
17 uri: 'http://localhost:4000/graphql',
18 cache
19});
20
21const IS_LOGGED_IN = gql`
22 query IsUserLoggedIn {
23 isLoggedIn @client
24 }
25`;
26
27cache.writeQuery({
28 query: IS_LOGGED_IN,
29 data: {
30 isLoggedIn: !!localStorage.getItem("token"),
31 },
32});
33
34function App() {
35 const { data } = useQuery(IS_LOGGED_IN);
36 return data.isLoggedIn ? <Pages /> : <Login />;
37}
38
39ReactDOM.render(
40 <ApolloProvider client={client}>
41 <App />
42 </ApolloProvider>,
43 document.getElementById("root"),
44);
Note that even if you do store local data as fields in the Apollo Client cache, you can (and probably should!) still define read
functions for those fields. A read
function can execute helpful custom logic, such as returning a default value if a field isn't present in the cache.
Persisting local state across sessions
By default, neither a reactive variable nor the InMemoryCache
persists its state across sessions (for example, if a user refreshes their browser). To persist this state, you need to add logic to do so.
The apollo3-cache-persist
library helps you persist and rehydrate the Apollo Client cache between sessions. For details, see Persisting the cache.
There is currently no built-in API for persisting reactive variables, but you can write variable values to localStorage
(or another store) whenever they're modified, and initialize those variables with their stored value (if any) on app load.
Modifying
The way you modify the value of a local-only field depends on how you store that field:
If you're using a reactive variable, all you do is set the reactive variable's new value. Apollo Client automatically detects this change and triggers a refresh of every active operation that includes an affected field.
If you're using the cache directly, call one of
writeQuery
,writeFragment
, orcache.modify
(all documented here) to modify cached fields. Like reactive variables, all of these methods trigger a refresh of every affected active operation.If you're using another storage method, such as
localStorage
, set the field's new value in whatever method you're using. Then, you can force a refresh of every affected operation by callingcache.evict
. In your call, provide both theid
of your field's containing object and the name of the local-only field.
Using local-only fields as GraphQL variables
If your GraphQL query uses variables, the local-only fields of that query can provide the values of those variables.
To do so, you apply the @export(as: "variableName")
directive, like so:
1const GET_CURRENT_AUTHOR_POST_COUNT = gql`
2 query CurrentAuthorPostCount($authorId: Int!) {
3 currentAuthorId @client @export(as: "authorId")
4 postCount(authorId: $authorId)
5 }
6`;
In the query above, the result of the local-only field currentAuthorId
is used as the value of the $authorId
variable that's passed to postCount
.
You can do this even if postCount
is also a local-only field (i.e., if it's also marked as @client
).
Considerations for using @export
To use the
@export
directive, a field must also use the@client
directive. In other words, only local-only fields can be used as variable values.A field that
@export
s a variable value must appear before any fields that use that variable.If multiple fields in an operation use the
@export
directive to assign their value to the same variable, the field listed last takes precedence. When this happens in development mode, Apollo Client logs a warning message.At first glance, the
@export
directive appears to violate the GraphQL specification's requirement that the execution order of an operation must not affect its result:…the resolution of fields other than top‐level mutation fields must always be side effect‐free and idempotent, the execution order must not affect the result, and hence the server has the freedom to execute the field entries in whatever order it deems optimal.
However, all
@export
ed variable values are populated before an operation is sent to a remote server. Only local-only fields can use the@export
directive, and those fields are stripped from operations before they're transmitted.