Storing local data in the Apollo cache
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!
Define a client-side schema
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.
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
:
export const typeDefs = gql`extend type Query {isLoggedIn: Boolean!cartItems: [ID!]!}`;
Also add gql
to the list of symbols imported from @apollo/client
:
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 sessioncartItems
, 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:
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:
import { InMemoryCache, Reference, makeVar } from "@apollo/client";
Then, add the following to the bottom of the file:
// Initializes to true if localStorage includes a 'token' key,// false otherwiseexport const isLoggedInVar = makeVar<boolean>(!!localStorage.getItem("token"));// Initializes to an empty arrayexport 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:
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
:
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
:
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
:
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
:
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 below:
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:
const rootElement = document.getElementById("root");if (!rootElement) throw new Error("Failed to find the root element");const root = ReactDOM.createRoot(rootElement);root.render(<ApolloProvider client={client}><IsLoggedIn /></ApolloProvider>);
Cart items
Next, let's implement a client-side cart for storing the launches that a user wants to book.
Open client/src/pages/cart.tsx
and replace its contents with the following:
import React, { Fragment } from "react";import { gql, useQuery } from "@apollo/client";import { Header, Loading } from "../components";import { CartItem, BookTrips } from "../containers";import { GetCartItems } from "./__generated__/GetCartItems";export const GET_CART_ITEMS = gql`query GetCartItems {cartItems @client}`;interface CartProps {}const Cart: React.FC<CartProps> = () => {const { data, loading, error } = useQuery<GetCartItems>(GET_CART_ITEMS);if (loading) return <Loading />;if (error) return <p>ERROR: {error.message}</p>;return (<Fragment><Header>My Cart</Header>{data?.cartItems.length === 0 ? (<p data-testid="empty-message">No items in your cart</p>) : (<Fragment>{data?.cartItems.map((launchId: any) => (<CartItem key={launchId} launchId={launchId} />))}<BookTrips cartItems={data?.cartItems || []} /></Fragment>)}</Fragment>);};export default Cart;
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 client/src/containers/logout-button.tsx
. Replace its contents with the following:
import React from "react";import styled from "@emotion/styled";import { useApolloClient } from "@apollo/client";import { menuItemClassName } from "../components/menu-item";import { isLoggedInVar } from "../cache";import { ReactComponent as ExitIcon } from "../assets/icons/exit.svg";const LogoutButton = () => {const client = useApolloClient();return (<StyledButtondata-testid="logout-button"onClick={() => {// Evict and garbage-collect the cached user objectclient.cache.evict({ fieldName: "me" });client.cache.gc();// Remove user details from localStoragelocalStorage.removeItem("token");localStorage.removeItem("userId");// Set the logged-in status to falseisLoggedInVar(false);}}><ExitIcon />Logout</StyledButton>);};export default LogoutButton;const StyledButton = styled("button")([menuItemClassName,{background: "none",border: "none",padding: 0,},]);
The important part of this code is the logout button's onClick
handler. It does the following:
- It uses the
evict
andgc
methods to purge theQuery.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. - It clears
localStorage
, where we persist the logged-in user's ID and session token between visits. - It sets the value of our
isLoggedInVar
reactive variable tofalse
.
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:
import React from "react";import { gql, useMutation } from "@apollo/client";import Button from "../components/button";import { cartItemsVar } from "../cache";import * as GetCartItemsTypes from "../pages/__generated__/GetCartItems";import * as BookTripsTypes from "./__generated__/BookTrips";export const BOOK_TRIPS = gql`mutation BookTrips($launchIds: [ID]!) {bookTrips(launchIds: $launchIds) {successmessagelaunches {idisBooked}}}`;interface BookTripsProps extends GetCartItemsTypes.GetCartItems {}const BookTrips: React.FC<BookTripsProps> = ({ cartItems }) => {const [bookTrips, { data }] = useMutation<BookTripsTypes.BookTrips,BookTripsTypes.BookTripsVariables>(BOOK_TRIPS, {variables: { launchIds: cartItems },});return data && data.bookTrips && !data.bookTrips.success ? (<p data-testid="message">{data.bookTrips.message}</p>) : (<ButtononClick={async () => {await bookTrips();cartItemsVar([]);}}data-testid="book-button">Book All</Button>);};export default BookTrips;
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:
import React from "react";import { gql, useMutation, useReactiveVar, Reference } from "@apollo/client";import { GET_LAUNCH_DETAILS } from "../pages/launch";import Button from "../components/button";import { cartItemsVar } from "../cache";import * as LaunchDetailTypes from "../pages/__generated__/LaunchDetails";export { GET_LAUNCH_DETAILS };export const CANCEL_TRIP = gql`mutation cancel($launchId: ID!) {cancelTrip(launchId: $launchId) {successmessagelaunches {idisBooked}}}`;interface ActionButtonPropsextends Partial<LaunchDetailTypes.LaunchDetails_launch> {}const CancelTripButton: React.FC<ActionButtonProps> = ({ id }) => {const [mutate, { loading, error }] = useMutation(CANCEL_TRIP, {variables: { launchId: id },update(cache, { data: { cancelTrip } }) {// Update the user's cached list of trips to remove the trip that// was just canceled.const launch = cancelTrip.launches[0];cache.modify({id: cache.identify({__typename: "User",id: localStorage.getItem("userId"),}),fields: {trips(existingTrips: Reference[], { readField }) {return existingTrips.filter((tripRef) => readField("id", tripRef) !== launch.id);},},});},});if (loading) return <p>Loading...</p>;if (error) return <p>An error occurred</p>;return (<div><Button onClick={() => mutate()} data-testid={"action-button"}>Cancel This Trip</Button></div>);};const ToggleTripButton: React.FC<ActionButtonProps> = ({ id }) => {const cartItems = useReactiveVar(cartItemsVar);const isInCart = id ? cartItems.includes(id) : false;return (<div><ButtononClick={() => {if (id) {cartItemsVar(isInCart? cartItems.filter((itemId) => itemId !== id): [...cartItems, id]);}}}data-testid={"action-button"}>{isInCart ? "Remove from Cart" : "Add to Cart"}</Button></div>);};const ActionButton: React.FC<ActionButtonProps> = ({ isBooked, id }) =>isBooked ? <CancelTripButton id={id} /> : <ToggleTripButton id={id} />;export default ActionButton;
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.
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 Quickstart course.
Want to share your newfound skills with the world? Apply your knowledge by taking our certification exam! Passing the exam will earn you the Apollo Graph Developer - Associate Certification, enabling you to show off your solid foundational understanding of GraphQL and the Apollo tool suite.