Dispatch This: Using Apollo Client 3 as a State Management Solution
Khalil Stemmler
A lot of developers understand how to use Redux or React Context for state management but are left confused about how to use Apollo Client as a replacement.
In this article, we’ll break down the responsibilities of a state management solution, discuss what’s involved in building a solution from scratch, and how Apollo Client 3 introduces new ways to manage local state and remote data in harmony.
You can also watch the talk presented at Apollo Space Camp 2020 by Khalil Stemmler here on YouTube.
The responsibilities of a state management solution
State management is one of the most challenging parts of every application, and historically, when starting on a new React project, we’ve had to design and implement the state management infrastructure from scratch in a bare-bones way.
No matter which approach we take, in every client-side application, the generic role of a state management solution is the same: to handle storage, update state, and enable Reactivity.
Storage
Most apps need to hold onto some data. That data may contain a little slice of local state that we’ve configured client-side, or it could be a subset of remote state from our backend services.
Often, we need to combine these two pieces of data, local and remote, and then call upon them in our app at the same time. This task alone has the potential to get pretty complicated, especially when we need to perform updates to the state of our app.
Update state
The Command-Query Segregation Principle states that there are two generic types of operations we can perform: commands
and queries
.
In GraphQL, we refer to these as queries
and mutations
.
In REST, we have several command
-like operations like delete
, update
, post
, etc and one query
-like operation called get
.
Most of the time, after invoking an operation in a client-side web app, we need to update the state stored locally as a side-effect.
Reactivity
When storage changes, we need an effective way to notify pieces of our UI that relied on that particular part of the store, and that they should present the new data.
Each state management approach has a slightly different approach
Just about every library available out there right now can adequately handle all three of these responsibilities! Here are some of the most popular approaches right now in the React realm.
Redux
- Storage: Plain JS object
- Updating state: actions + reducers
- Reactivity: Connect
React Context + Hooks
- Storage: Plain JS object
- Updating state: useReducer (or not)
- Reactivity: useContext
Apollo Client
- Storage: Normalized cache
- Updating state: Cache APIs
- Reactivity: (Auto) Broadcast change notifications to Queries
Choosing a state management approach
Comparing Redux or React Context for state management vs. Apollo Client is that with Redux and React Context, we still have to write design the infrastructure around handling fetch logic, async states, data marshaling, and more. In the end, we still have to make a lot of those design decisions on our own.
For smaller projects, I think this is an elegant approach, but having worked on massive Redux projects in the past, I’m acutely aware of how challenging it can get to test and maintain the additional code.
State management infrastructure can account for up to 50% of the work in building a client-side application
At 3:05 in the talk, we discuss a generic client-side architecture that breaks down the components involves in modern apps as:
- Presentational components with UI Logic
- Container components
- Interaction logic
- Infrastructure (fetch and state management logic)
- and a client-side store
Laying this all out visually, we can identify that the section of the architecture that handles storage, updating state, and reactivity – is actually about half of the work.
At Apollo, we call this half of the work the client-side data layer.
There’s more to this discussion. If you’d like to dive deeper, see examples and arguments for each concern in a modern client-side architecture, read the full Client-Side Architecture Basics essay.
Building data layer infrastructure is expensive
Having to spend time building out the entire data layer’s infrastructure is not a light task. Here are a number of concerns handled from within the data layer:
- Fetch logic
- Retry-logic
- Representing async logic (loading, failure, success, error states)
- Normalizing data
- Marshaling request and response data
- Facilitating optimistic UI updates
- … and more
We consider these tasks a part of the necessary infrastructure needed to interact with our data. They provide the baseline for which we can build our app on top of. With Redux or React Context, if we want this infrastructure, we have to build it, test, and maintain it ourselves using our own custom code.
At Apollo, we see these concerns as things that should be done for you so that you can move onto the application-specific tasks like:
- Declaratively asking for the data you want
- Building out presentational components
- Implementing UI & interaction logic
These are things that only you can do because they’re specific to your app, and it’s what makes your app special.
Using Apollo Client 3 as a state management solution, we start out with an answer to all of our data layer concerns. Out of the box, Apollo client comes with storage, updates, and reactivity set up.
When working with Apollo Client, most of the work we do can be thought of as falling within one of three categories:
- 1 – Cache configuration
- 2 – Fetching Data: Query for the data you need using the
useQuery
hook - 3 – Changing Data: Update data using the
useMutation
hook
Apollo Client gives us a set of APIs to configure, fetch, and update our client-side state
Tradeoffs of using Apollo Client 3 over other state management solutions
Choosing Apollo Client 3 over other state manage solutions like Redux or React with Context + useReducer is a tradeoff.
Going the Apollo Client route, we’re left to learn the cache and query APIs at the benefit of having the data layer infrastructure done for us. Choosing Redux or React with Context over Apollo Client means building the infrastructure to interact with the data layer and then testing, consuming, and maintaining them as well.
Using Apollo Client 3 for common state management use cases
Apollo Client 3 also just shipped with several new APIs that introduce much cleaner ways to work with the cache and handle common use cases.
You can learn how to use Apollo Client 3’s new APIs to build a Todo app using Local State (Reactive Variables), and the new Advanced Cache Modification APIs by checking out the Apollo Client 3 State Management Examples on Github.
Configuring the cache
A basic configuration involves creating a new InMemoryCache
with no options pointing the ApolloClient
instance to the remote GraphQL API.
import { ApolloClient, InMemoryCache } from '@apollo/client';
const cache = new InMemoryCache();
const client = new ApolloClient({
uri: '<https://api.todos.com/>',
cache
});
One improvement of Apollo Client 3 is that everything has been moved to live under @apollo/client
.
Check out the Getting Started docs for more detail.
Fetching data
The data fetching experience hasn’t changed much in the latest Apollo Client 3 release. To pull data from a remote API into the cache so that you can present it in a component, see the following code.
- Import the
useQuery
hook from @apollo/client - Ask for the data you want using a GraphQL query
- Pass the query into the hook
- (Optionally) Handle async states
- (Optionally) Pass the data to a presentational component
import React from 'react'
import { TodoList, Loader } from '../components';
import { filterTodos } from 'utils';
import { useQuery } from '@apollo/client';
import { gql } from '@apollo/client'
export const GET_ALL_TODOS = gql`
query GetAllTodos {
todos {
id
text
completed
}
}
`
export default function ActiveTodosList () {
const { loading, data, error } = useQuery(
GET_ALL_TODOS
);
if (loading) return <Loader/>
if (error) return <div>An error occurred</div>
if (!data) return <div>No data!</div>;
return <TodoList
todos={filterTodos(data.todos, ‘active')}
/>;
}
The useQuery
hook abstracts away all of the complex data fetching, state management, and marshaling logic that we used to have to do with bare bones approaches. A couple of the things it does behind the scenes are:
- Forming and sending the request
- Updating async states (loading, error, data)
- Normalizing and caching the response
- Saving bandwidth by delegating future requests for the same data and sourcing them from a local copy in the cache instead of going out onto the network
What does cached data look like?
A lot of developers are familiar with what the cache looks like in Redux or React with Context because the cache is a plain ol’ JavaScript object. Let’s see what the cache looks like in Apollo Client after having fetched some data.
Assume we have a basic GetAllTodos
query like the one shown in the previous section, and calling it returns a list of todos that we render to the UI like so.
If we were to take a look at the cache, it would look something like this:
{
"Todo:1": { __typename: "Todo", id: 1, text: "Getting started”…},
"Todo:2": { __typename: "Todo", id: 2, text: "Second todo”…},
"Todo:3": { __typename: "Todo", id: 3, text: "Third todo”…},
“ROOT_QUERY": { __typename: “Query", todos: {…} …},
...
}
Because the cache is normalized, it makes sure that there is only ever one reference to an item returned from a query.
Uniqueness is determined by constructing a cache key (which you can now configure using the key fields API). Read about this in Configuring the Cache > Customizing identifier generation by type in the docs.
Updating data
In Apollo Client 3, there are a few new additions to the way we update the cache after our mutations
, namely the name advanced cache manipulation APIs.
Assuming we’re working with the same todo app and we want to perform an AddTodo
mutation
, the steps are as follows.
- Write a GraphQL mutation (including the new item in the mutation response to automatically normalize it in the cache)
- Import the
useMutation
hook - Pass the
mutation
into the hook - Handle async states
- Connect the
mutation
to a component and invoke it on a user event.
import React from 'react';
import { Header, Loading } from '../components'
import { gql, useMutation } from '@apollo/client';
export const ADD_TODO = gql`
mutation AddTodo ($text: String!) {
addTodo (text: $text) {
success
todo {
id
text
completed
}
error {
message
}
}
}
`
export default function NewTodo () {
const [mutate, loading] = useMutation(
ADD_TODO,
)
if (loading) return <Loader/>
return <Header addTodo={(text) => mutate({
variables: { text }
})}/>;
}
This is usually enough to create a new item in the cache. Though sometimes, when we’re working with cached collections (arrays of data that we’ve previously fetched), in order to get lists to visually re-render in the UI, we need to use the update
function in the useMutation
options to specify the application-specific way that the cache should handle the new item.
In the case of adding a new todo, the behavior we would like to take is to add the new item to the end of the list of cached todos. Here’s how we may do that using the cache.readQuery
and cache.writeQuery
APIs in the update
function.
const [mutate, loading] = useMutation(
ADD_TODO,
{
update (cache, { data }) {
const newTodoFromResponse = data?.addTodo.todo;
const existingTodos = cache.readQuery({
query: GET_ALL_TODOS,
});
cache.writeQuery({
query: GET_ALL_TODOS,
data: {
todos: existingTodos?.todos.concat(newTodoFromResponse)
},
});
}
}
)
Apollo Client 3 introduced advanced cache manipulation APIs for power users (modify
, evict
, gc
). You can read more about them in this blog post and the docs.
Local State Management improvements with Cache Policies and Reactive Variables
My personal favorite new features about Apollo Client 3 are Cache Policies and Reactive Variables.
Cache Policies Cache Policies introduce a new way to modify what the cache returns before reads
and writes
to the cache. It introduces cleaner patterns for setting default values, local state management ⚡, pagination, pipes, filters, and other common client-side use cases.
You can learn more about Cache Policies via the docs.
Reactive Variables
Reactive variables are containers for variables that we would like to enable cache Reactivity for. The cache is notified when they change (this means that if we’re using React, it will also trigger a re-render).
// Create Reactive variable
export const todosVar = cache.makeVar<Todos>();
// Set the value
todosVar([]);
// Get the value
const currentTodosValue = todosVar();
The coolest part of this means that Reactive Variables can be changed from wherever you want, however you want – that’s a huge improvement over having to do all of your local state management in local resolvers (as we previously did in AC2.x).
For a walkthrough on how to use Cache Policies and Reactive Variables to perform local state management, check out the demonstration from the talk. Reactive Variables docs coming soon!
Conclusion
We learned about the three basic pillars of state management solutions:
- Storage
- Update state
- Reactivity
We also learned how much work it is to build out the data layer yourself and when you might want to consider using Apollo Client instead of a bare-bones state management solution.
Finally, we took a look at some of the new Apollo Client 3 APIs and how they can be used to implement common client-side state use cases.
Resources
- You can download the slides for this talk here.
- Apollo Client 3 State Management Examples
- Apollo Client 3 Roadmap