/
Launch Apollo Studio

Manage local state

Store and query local data in the Apollo cache


Time to accomplish: 20 Minutes

Like most web apps, our app relies on a combination of remotely fetched data and locally stored data. We can use Apollo Client to manage both types of data, making it a single source of truth for our application's state. We can even interact with both types of data in a single operation. Let's learn how!

The code blocks below use TypeScript by default. You can use the dropdown menu above each code block to switch to JavaScript.

If you're using JavaScript, use .js and .jsx file extensions wherever .ts and .tsx appear.

Define a client-side schema

First, let's define a client-side GraphQL schema that's specific to our application client. This isn't required for managing local state, but it enables useful developer tooling and helps us reason about our data.

Add the following definition to src/index.tsx, before the initialization of ApolloClient:

src/index.tsx
export const typeDefs = gql`
  extend type Query {
    isLoggedIn: Boolean!
    cartItems: [ID!]!
  }
`;

Also add gql to the list of symbols imported from @apollo/client:

index.tsx
import {
  ApolloClient,
  NormalizedCacheObject,
  ApolloProvider,
  gql,} from '@apollo/client';

As you might expect, this looks a lot like a definition from our server's schema, with one difference: we extend the Query type. You can extend a GraphQL type that's defined in another location to add fields to that type.

In this case, we're adding two fields to Query:

  • isLoggedIn, to track whether the user has an active session
  • cartItems, to track which launches the user has added to their cart

Finally, let's modify the constructor of ApolloClient to provide our client-side schema:

src/index.tsx
const client: ApolloClient<NormalizedCacheObject> = new ApolloClient({
  cache,
  uri: 'http://localhost:4000/graphql',
  headers: {
    authorization: localStorage.getItem('token') || '',
  },
  typeDefs,});

Next, we need to define how we store the values of these local fields on the client.

Initialize reactive variables

Just like on the server, we can populate client-side schema fields with data from any source we want. Apollo Client provides a couple of useful built-in options for this:

  • The same in-memory cache where the results from server-side queries are stored
  • Reactive variables, which can store arbitrary data outside the cache while still updating queries that depend on them

Both of these options work for most use cases. We'll use reactive variables because they're faster to get started with.

Open src/cache.ts. Update its import statement to include the makeVar function:

src/cache.ts
import { InMemoryCache, Reference, makeVar } from '@apollo/client';

Then, add the following to the bottom of the file:

src/cache.ts
// Initializes to true if localStorage includes a 'token' key,
// false otherwise
export const isLoggedInVar = makeVar<boolean>(!!localStorage.getItem('token'));

// Initializes to an empty array
export const cartItemsVar = makeVar<string[]>([]);

Here we define two reactive variables, one for each of our client-side schema fields. The value we provide to each makeVar call sets the variable's initial value.

The values of isLoggedInVar and cartItemsVar are functions:

  • If you call a reactive variable function with zero arguments (e.g., isLoggedInVar()), it returns the variable's current value.
  • If you call the function with one argument (e.g., isLoggedInVar(false)), it replaces the variable's current value with the provided value.

Update login logic

Now that we're representing login status with a reactive variable, we need to update that variable whenever the user logs in.

Let's return to login.tsx and import our new variable:

src/pages/login.tsx
import { isLoggedInVar } from '../cache';

Now, let's also update that variable whenever a user logs in. Modify the onCompleted callback for the LOGIN_USER mutation to set isLoggedInVar to true:

src/pages/login.tsx
onCompleted({ login }) {
  if (login) {
    localStorage.setItem('token', login.token as string);
    localStorage.setItem('userId', login.id as string);
    isLoggedInVar(true);  }
}

We now have our client-side schema and our client-side data sources. On the server side, next we would define resolvers to connect the two. On the client side, however, we define field policies instead.

Define field policies

A field policy specifies how a single GraphQL field in the Apollo Client cache is read and written. Most server-side schema fields don't need a field policy, because the default policy does the right thing: it writes query results directly to the cache and returns those results without any modifications.

However, our client-side fields aren't stored in the cache! We need to define field policies to tell Apollo Client how to query those fields.

In src/cache.ts, look at the constructor of InMemoryCache:

src/cache.ts
export const cache: InMemoryCache = new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        launches: {
          // ...field policy definitions...
        }
      }
    }
  }
});

You might remember that we've already defined a field policy here, specifically for the Query.launches field when we added pagination support to our GET_LAUNCHES query.

Let's add field policies for Query.isLoggedIn and Query.cartItems:

src/cache.ts
export const cache: InMemoryCache = new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        isLoggedIn: {          read() {            return isLoggedInVar();          }         },        cartItems: {          read() {            return cartItemsVar();          }         },        launches: {
          // ...field policy definitions...
        }
      }
    }
  }
});

Our two field policies each include a single field: a read function. Apollo Client calls a field's read function whenever that field is queried. The query result uses the function's return value as the field's value, regardless of any value in the cache or on your GraphQL server.

Now, whenever we query one of our client-side schema fields, the value of our corresponding reactive variable is returned. Let's write a query to try it!

Query local fields

You can include client-side fields in any GraphQL query you write. To do so, you add the @client directive to every client-side field in your query. This tells Apollo Client not to fetch that field's value from your server.

Login status

Let's define a query that includes our new isLoggedIn field. Add the following definitions to index.tsx:

src/index.tsx
const IS_LOGGED_IN = gql`
  query IsUserLoggedIn {
    isLoggedIn @client
  }
`;

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

Also add the missing imports highlighted below:

index.tsx
import {
  ApolloClient,
  NormalizedCacheObject,
  ApolloProvider,
  gql,
  useQuery} from '@apollo/client';
import Login from './pages/login';

The IsLoggedIn component executes the IS_LOGGED_IN query and renders different components depending on the result:

  • If the user isn't logged in, the component displays our application's login screen.
  • Otherwise, the component displays our application's home page.

Because all of this query's fields are local fields, we don't need to worry about displaying any loading state.

Finally, let's update the ReactDOM.render call to use our new IsLoggedIn component:

index.tsx
ReactDOM.render(
  <ApolloProvider client={client}>
    <IsLoggedIn />
  </ApolloProvider>,
  document.getElementById('root')
);

Cart items

Next, let's implement a client-side cart for storing the launches that a user wants to book.

Open src/pages/cart.tsx and replace its contents with the following:

Once again, we query a client-side field and use that query's result to populate our UI. The @client directive is the only thing that differentiates this code from code that queries a remote field.

Although both of the queries above only query client-side fields, a single query can query both client-side and server-side fields.

Modify local fields

When we want to modify a server-side schema field, we execute a mutation that's handled by our server's resolvers. Modifying a local field is more straightforward, because we can directly access the field's source data (in this case, a reactive variable).

Enable logout

A logged-in user needs to be able to log out of our client as well. Our example app can perform a logout entirely locally, because logged-in status is determined by the presence of a token key in localStorage.

Open src/containers/logout-button.tsx. Replace its contents with the following:

The important part of this code is the logout button's onClick handler. It does the following:

  1. It uses the evict and gc methods to purge the Query.me field from our in-memory cache. This field includes data that's specific to the logged-in user, all of which should be removed on logout.
  2. It clears localStorage, where we persist the logged-in user's ID and session token between visits.
  3. It sets the value of our isLoggedInVar reactive variable to false.

When the reactive variable's value changes, that change is automatically broadcast to every query that depends on the variable's value (specifically, the IS_LOGGED_IN query we defined earlier).

Because of this, when a user clicks the logout button, our isLoggedIn component updates to display the login screen.

Enable trip booking

Let's enable our users to book trips in the client. We've waited so long to implement this core feature because it requires interacting with both local data (the user's cart) and remote data. Now we know how to do both!

Open src/containers/book-trips.tsx. Replace its contents with the following:

This component executes the BOOK_TRIPS mutation when the Book All button is clicked. The mutation requires a list of launchIds, which it obtains from the user's locally stored cart (passed as a prop).

After the bookTrips function returns, we call cartItemsVar([]) to clear the user's cart because the trips in the cart have been booked.

A user can now book all the trips in their cart, but they can't yet add any trips to their cart! Let's apply that last touch.

Enable cart and booking modifications

Open src/containers/action-button.tsx. Replace its contents with the following:

This code defines two complex components:

  • A CancelTripButton, which is displayed only for trips that the user has already booked
  • A ToggleTripButton, which enables the user to add or remove a trip from their cart

Let's cover each separately.

Canceling a trip

The CancelTripButton component executes the CANCEL_TRIP mutation, which takes a launchId as a variable (indicating which previously booked trip to cancel).

In our call to useMutation, we include an update function. This function is called after the mutation completes, enabling us to update the cache to reflect the server-side cancellation.

Our update function obtains the canceled trip from the mutation result, which is passed to the function. It then uses the modify method of InMemoryCache to filter that trip out of the trips field of our cached User object.

The cache.modify method is a powerful and flexible tool for interacting with cached data. To learn more about it, see cache.modify.

Adding and removing cart items

The ToggleTripButton component doesn't execute any GraphQL operations, because it can instead interact directly with the cartItemsVar reactive variable.

On click, the button adds its associated trip to the cart if it's missing, or removes it if it's present.

Finish up

Our application is complete! If you haven't yet, start up your server and client and test out all of the functionality we just added, including:

  • Logging in and out
  • Adding and removing trips from the cart
  • Booking trips
  • Canceling a booked trip

You can also start up the version of the client in final/client to compare it to your version.


Congratulations! 🎉 You've completed the Apollo full-stack tutorial. You're ready to dive into each individual part of the Apollo platform. Return to the documentation homepage for quick links to each part's documentation, along with "recommended workouts" to get you going.

Edit on GitHub