Docs
Launch GraphOS Studio

Local-only fields in Apollo Client

Fetch local and remote data with one GraphQL query


Your queries can include local-only fields that aren't defined in your 's schema:

query ProductDetails($productId: ID!) {
product(id: $productId) {
name
price
isInCart @client # This is a local-only field
}
}

The values for these are calculated locally using any logic you want, such as reading data from localStorage.

As shown, a can include both local-only and that are fetched from your .

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 that's local to the client. First, we create a field policy for isInCart.

A policy specifies custom logic for how a single field is fetched from and written to your cache. You can define field policies for both local-only fields and remotely fetched fields.

policies are primarily in Customizing the behavior of cached fields. This article specifically describes how to use them with local-only .

You define your application's policies in a map that you provide to the constructor of 's InMemoryCache. Each policy is a child of a particular type policy (much like the corresponding is a child of a particular type).

Here's a sample InMemoryCache constructor that defines a policy for Product.isInCart:

const cache = new InMemoryCache({
typePolicies: { // Type policy map
Product: {
fields: { // Field policy map for the Product type
isInCart: { // Field policy for the isInCart field
read(_, { variables }) { // The read function for the isInCart field
return localStorage.getItem('CART').includes(
variables.productId
);
}
}
}
}
}
});

The policy above defines a read function for the isInCart . Whenever you a field that has a read function, the cache calls that function to calculate the '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 s
  • Calling helper utilities or libraries to prepare, validate, or sanitize data
  • Fetching data from a separate store
  • Logging usage metrics

If you a local-only that doesn't define a read function, performs a default cache lookup for the . 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 and options.storage to compose a read function that behaves in a manner that resembles an asynchronous action:

Querying

Now that we've defined a policy for isInCart, we can include the in a that also queries our back-end server, like so:

const GET_PRODUCT_DETAILS = gql`
query ProductDetails($productId: ID!) {
product(id: $productId) {
name
price
isInCart @client
}
}
`;

That's it! The @client tells that isInCart is a local-only . Because isInCart is local-only, omits it from the it sends to our server to fetch name and price. The final result is returned only after all local and remote are populated.

Note: If you apply the @client to a with subfields, the directive is automatically applied to all subfields.

Storing and mutations

You can use to and modify local state, regardless of how you store that state. provides a couple of optional but helpful mechanisms for representing local state:

It's tempting to think of local-only as being expressed similarly to other local-only . When using previous versions of , 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 version >= 3 users we recommend using writeQuery, writeFragment, or reactive variables to manage local state.

Storing and updating local state with reactive variables

reactive variables are great for representing local state:

  • You can read and modify reactive from anywhere in your application, without needing to use a to do so.
  • Unlike the cache, reactive don't enforce data normalization, which means you can store data in any format you want.
  • If a 's value depends on the value of a reactive , 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 to do that looks like this:

Cart.js
export const GET_CART_ITEMS = gql`
query GetCartItems {
cartItems @client
}
`;

Let's use the makeVar function to initialize a reactive that stores our local list of cart items:

cache.js
import { makeVar } from '@apollo/client';
export const cartItemsVar = makeVar([]);

This initializes a reactive with an empty array (you can pass any initial value to makeVar). Note that the return value of makeVar isn't the itself, but rather a function. We get the 's current value by calling cartItemsVar(), and we set a new value by calling cartItemsVar(newValue).

Next, let's define the policy for cartItems. As always, we pass this to the constructor of InMemoryCache:

cache.js
export const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
cartItems: {
read() {
return cartItemsVar();
}
}
}
}
}
});

This read function returns the value of our reactive whenever cartItems is queried.

Now, let's create a button component that enables the user to add a product to their cart:

AddToCartButton.js
import { cartItemsVar } from './cache';
// ... other imports
export function AddToCartButton({ productId }) {
return (
<div class="add-to-cart-button">
<Button onClick={() => cartItemsVar([...cartItemsVar(), productId])}>
Add to Cart
</Button>
</div>
);
}

On click, this button updates the value of cartItemsVar to append the button's associated productId. When this happens, notifies every active that includes the cartItems .

Here's a Cart component that uses the GET_CART_ITEMS and therefore refreshes automatically whenever the value of cartItemsVar changes:

Cart.js
export const GET_CART_ITEMS = gql`
query GetCartItems {
cartItems @client
}
`;
export function Cart() {
const { data, loading, error } = useQuery(GET_CART_ITEMS);
if (loading) return <Loading />;
if (error) return <p>ERROR: {error.message}</p>;
return (
<div class="cart">
<Header>My Cart</Header>
{data && data.cartItems.length === 0 ? (
<p>No items in your cart</p>
) : (
<Fragment>
{data && data.cartItems.map(productId => (
<CartItem key={productId} />
))}
</Fragment>
)}
</div>
);
}

Instead of for cartItems, the Cart component can read and react to a reactive directly with the useReactiveVar hook:

Cart.js
import { useReactiveVar } from '@apollo/client';
export function Cart() {
const cartItems = useReactiveVar(cartItemsVar);
return (
<div class="cart">
<Header>My Cart</Header>
{cartItems.length === 0 ? (
<p>No items in your cart</p>
) : (
<Fragment>
{cartItems.map(productId => (
<CartItem key={productId} />
))}
</Fragment>
)}
</div>
);
}

As with the preceding useQuery example, whenever the cartItemsVar is updated, the Cart component rerenders.

Important: If you call cartItemsVar() instead of useReactiveVar(cartItemsVar) in the example above, future updates do not cause the Cart component to rerender.

Storing and modifying local state in the cache

Storing local state directly in the 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 that are present in the cache. If you a field that doesn't define a read function, by default attempts to fetch that 's value directly from the cache.
  • When you modify a cached with writeQuery or writeFragment, every active query that includes the field automatically refreshes.

Example

Let's say our application defines the following :

const IS_LOGGED_IN = gql`
query IsUserLoggedIn {
isLoggedIn @client
}
`;

The isLoggedIn of this is a local-only field. We can use the writeQuery method to write a value for this directly to the cache, like so:

cache.writeQuery({
query: IS_LOGGED_IN,
data: {
isLoggedIn: !!localStorage.getItem("token"),
},
});

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 , without our needing to define a read function for it:

function App() {
const { data } = useQuery(IS_LOGGED_IN);
return data.isLoggedIn ? <Pages /> : <Login />;
}

Here's a full example that incorporates the code blocks above:

Note that even if you do store local data as in the cache, you can (and probably should!) still define read functions for those . A read function can execute helpful custom logic, such as returning a default value if a isn't present in the cache.

Persisting local state across sessions

By default, neither a reactive 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 cache between sessions. For details, see Persisting the cache.

There is currently no built-in API for persisting reactive , but you can write variable values to localStorage (or another store) whenever they're modified, and initialize those with their stored value (if any) on app load.

Modifying

The way you modify the value of a local-only depends on how you store that field:

  • If you're using a reactive variable, all you do is set the reactive 's new value. automatically detects this change and triggers a refresh of every active that includes an affected .

  • If you're using the cache directly, call one of writeQuery, writeFragment, or cache.modify (all documented here) to modify cached . Like reactive , all of these methods trigger a refresh of every affected active .

  • If you're using another storage method, such as localStorage, set the 's new value in whatever method you're using. Then, you can force a refresh of every affected by calling cache.evict. In your call, provide both the id of your 's containing object and the name of the local-only field.

Using local-only fields as GraphQL variables

If your uses , the local-only of that query can provide the values of those .

To do so, you apply the @export(as: "variableName") , like so:

const GET_CURRENT_AUTHOR_POST_COUNT = gql`
query CurrentAuthorPostCount($authorId: Int!) {
currentAuthorId @client @export(as: "authorId")
postCount(authorId: $authorId)
}
`;

In the above, the result of the local-only currentAuthorId is used as the value of the $authorId that's passed to postCount.

You can do this even if postCount is also a local-only (i.e., if it's also marked as @client).

Considerations for using @export

  • To use the @export , a must also use the @client . In other words, only local-only can be used as values.

  • A that @exports a value must appear before any that use that .

  • If multiple in an use the @export to assign their value to the same , the listed last takes precedence. When this happens in development mode, logs a warning message.

  • At first glance, the @export appears to violate the GraphQL specification's requirement that the execution order of an must not affect its result:

    …the resolution of other than top‐level 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 @exported values are populated before an is sent to a remote server. Only local-only can use the @export , and those are stripped from before they're transmitted.

Previous
Overview
Next
Reactive variables
Edit on GitHubEditForumsDiscord

© 2024 Apollo Graph Inc.

Privacy Policy

Company