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 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 . 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!]!
}
`;
client/src/index.jsx
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 , with one difference: we extend the Query type. You can extend a GraphQL type that's defined in another location to add s to that type.

In this case, we're adding two s 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 :

client/src/index.tsx
const client: ApolloClient<NormalizedCacheObject> = new ApolloClient({
cache,
uri: 'http://localhost:4000/graphql',
headers: {
authorization: localStorage.getItem('token') || '',
},
typeDefs,
});
client/src/index.jsx
const client = 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 s on the client.

Initialize reactive variables

Just like on the server, we can populate client-side s 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 s 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[]>([]);
client/src/cache.js
// Initializes to true if localStorage includes a 'token' key,
// false otherwise
export const isLoggedInVar = makeVar(!!localStorage.getItem('token'));
// Initializes to an empty array
export const cartItemsVar = makeVar([]);

Here we define two reactive s, one for each of our client-side s. 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 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 , 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 and our client-side s. On the server side, next we would define s to connect the two. On the client side, however, we define field policies instead.

Define field policies

A policy specifies how a single GraphQL in the Apollo Client cache is read and written. Most server-side s don't need a 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 s aren't stored in the cache! We need to define policies to tell Apollo Client how to query those s.

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...
},
},
},
},
});
client/src/cache.js
export const cache = 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 query.

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...
},
},
},
},
});
client/src/cache.js
export const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
isLoggedIn: {
read() {
return isLoggedInVar();
},
},
cartItems: {
read() {
return cartItemsVar();
},
},
launches: {
// ...field policy definitions...
},
},
},
},
});

Our two policies each include a single : a read function. Apollo Client calls a 's read function whenever that 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 s, the value of our corresponding reactive is returned. Let's write a query to try it!

Query local fields

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

Login status

Let's define a query 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 />;
}
client/src/index.jsx
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 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 s are local s, 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 launches 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;
client/src/pages/cart.jsx
import React, { Fragment } from 'react';
import { gql, useQuery } from '@apollo/client';
import { Header, Loading } from '../components';
import { CartItem, BookTrips } from '../containers';
export const GET_CART_ITEMS = gql`
query GetCartItems {
cartItems @client
}
`;
const Cart = () => {
const { data, loading, error } = useQuery(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) => (
<CartItem key={launchId} launchId={launchId} />
))}
<BookTrips cartItems={data?.cartItems || []} />
</Fragment>
)}
</Fragment>
);
};
export default Cart;

Once again, we query 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 query client-side s, a single query can query both client-side and server-side s.

Modify local fields

When we want to modify a server-side , we execute a that's handled by our server's s. Modifying a local is more straightforward, because we can directly access the '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,
},
]);
client/src/containers/logout-button.jsx
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 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 's value changes, that change is automatically broadcast to every query that depends on the '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:

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;
client/src/containers/book-trips.jsx
import React from 'react';
import { gql, useMutation } from '@apollo/client';
import Button from '../components/button';
import { cartItemsVar } from '../cache';
export const BOOK_TRIPS = gql`
mutation BookTrips($launchIds: [ID]!) {
bookTrips(launchIds: $launchIds) {
success
message
launches {
id
isBooked
}
}
}
`;
const BookTrips = ({ cartItems }) => {
const [bookTrips, { data }] = useMutation(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;
client/src/containers/action-button.jsx
import React from 'react';
import { gql, useMutation, useReactiveVar } from '@apollo/client';
import { GET_LAUNCH_DETAILS } from '../pages/launch';
import Button from '../components/button';
import { cartItemsVar } from '../cache';
export { GET_LAUNCH_DETAILS };
export const CANCEL_TRIP = gql`
mutation cancel($launchId: ID!) {
cancelTrip(launchId: $launchId) {
success
message
launches {
id
isBooked
}
}
}
`;
const CancelTripButton = ({ 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, { 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 = ({ 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 = ({ 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 GraphQL s, 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 GraphQL and the Apollo tool suite.

Previous