10. Managing local state
20m

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

client/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:

client/src/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 type that's defined in another location to add to that type.

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

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

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

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

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

Initialize reactive variables

Just like on the server, we can populate client-side schema with data from any source we want. 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 because they're faster to get started with.

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

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

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

client/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 , one for each of our client-side schema . The value we provide to each makeVar call sets the 's initial value.

The values of isLoggedInVar and cartItemsVar are functions:

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

Update login logic

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

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

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

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

client/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 . On the server side, next we would define to connect the two. On the client side, however, we define field policies instead.

Define field policies

A policy specifies how a single field in the 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 results directly to the cache and returns those results without any modifications.

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

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

client/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 policy here, specifically for the Query.launches when we added pagination support to our GET_LAUNCHES .

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

client/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 policies each include a single field: a read function. calls a 's read function whenever that is queried. The result uses the function's return value as the field's value, regardless of any value in the cache or on your .

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

Query local fields

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

Login status

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

client/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 below:

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

The IsLoggedIn component executes the IS_LOGGED_IN 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 's 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:

client/src/index.tsx
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 that a user wants to book.

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

client/src/pages/cart.tsx
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 a client-side and use that query's result to populate our UI. The @client is the only thing that differentiates this code from code that queries a remote .

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

Modify local fields

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

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:

client/src/containers/logout-button.tsx
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 (
<StyledButton
data-testid="logout-button"
onClick={() => {
// Evict and garbage-collect the cached user object
client.cache.evict({ fieldName: "me" });
client.cache.gc();
// Remove user details from localStorage
localStorage.removeItem("token");
localStorage.removeItem("userId");
// Set the logged-in status to false
isLoggedInVar(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:

  1. It uses the evict and gc methods to purge the Query.me 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 to false.

When the reactive 's value changes, that change is automatically broadcast to every that depends on the variable's value (specifically, the IS_LOGGED_IN 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:

client/src/containers/book-trips.tsx
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) {
success
message
launches {
id
isBooked
}
}
}
`;
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>
) : (
<Button
onClick={async () => {
await bookTrips();
cartItemsVar([]);
}}
data-testid="book-button"
>
Book All
</Button>
);
};
export default BookTrips;

This component executes the BOOK_TRIPS when the Book All button is clicked. The 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:

client/src/containers/action-button.tsx
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) {
success
message
launches {
id
isBooked
}
}
}
`;
interface ActionButtonProps
extends 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>
<Button
onClick={() => {
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 , which takes a launchId as a (indicating which previously booked trip to cancel).

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

Our update function obtains the canceled trip from the result, which is passed to the function. It then uses the modify method of InMemoryCache to filter that trip out of the trips 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 , because it can instead interact directly with the cartItemsVar reactive .

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.

Task!
Task!
Task!

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 and the Apollo tool suite.

Previous