Background
On the Internal Tools team at Circle, we recently modernized a legacy PHP app by introducing React components. Just a handful of months after this initiative began we have close to one-hundred React components in this app! 😲
We recently reached a point where we found ourselves reaching for a state management solution. Note that it took many months and dozens of components before we reached this point. State management is often a tool that teams reach for well before they need it. While integrating a state management solution into an application no doubt comes with many benefits it also introduces complexity so don’t reach for it until you truly need it.
Speaking of complexity, one complaint about the typical “go-to” state management solution, Redux, is that it requires too much boilerplate and can be difficult to hit-the-ground-running with. In this post, we will look at a more lightweight solution which comes with the added benefit of providing some basic GraphQL experience for those who choose to use it.
On the Circle 🛠 team, we know that our future stack includes GraphQL. In fact, in the ideal scenario, we would have a company-wide data graph at some point and access and mutate data consistently through GraphQL. However, in the short-term, we were simply looking for a low-friction way to introduce GraphQL to a piece of the stack and allow developers to wrap their heads around this technology in a low-stress way. GraphQL as a client-side state management solution using libraries such as apollo-client felt like the perfect way to get started. Let’s take a look at the high-level implementation of a proof-of-concept for this approach!
Configuring the client
First, there are a number of packages we’ll need to pull in:
yarn add @apollo/react-hooks apollo-cache-inmemory
apollo-client graphql graphql-tag react react-dom
Below you’ll find index.js
on the client in its entirety. We’ll walk through the client-side schema specific pieces next:
import React from "react";
import ReactDOM from "react-dom";
import gql from "graphql-tag";
import { ApolloClient } from "apollo-client";
import { ApolloProvider } from "@apollo/react-hooks";
import { InMemoryCache } from "apollo-cache-inmemory";
import App from "./App";
import userSettings from "./userSettings";
const typeDefs = gql`
type AppBarColorSetting {
id: Int!
name: String!
setting: String!
}
type Query {
appBarColorSetting: AppBarColorSetting!
}
type Mutation {
updateAppBarColorSetting(setting: String!): AppBarColorSetting!
}
`;
const resolvers = {
Query: {
appBarColorSetting: () => userSettings.appBarColorSetting
},
Mutation: {
updateAppBarColorSetting: (_, { setting }) => {
userSettings.appBarColorSetting.setting = setting;
return userSettings.appBarColorSetting;
}
}
};
const client = new ApolloClient({
cache: new InMemoryCache({
freezeResults: true
}),
typeDefs,
resolvers,
assumeImmutableResults: true
});
const TogglesApp = () => (
<ApolloProvider client={client}>
<App />
</ApolloProvider>
);
ReactDOM.render(<TogglesApp />, document.getElementById("root"));
First, we define typeDefs
and resolvers
.
The AppBarColorSetting
type will have required id
, name
, and setting
fields. This will allow us to fetch and mutate the app bar’s color through GraphQL queries and mutations!
type AppBarColorSetting {
id: Int!
name: String!
setting: String!
}
Next up, we define the Query
type so that we can fetch the appBarColorSetting
:
type Query {
appBarColorSetting: AppBarColorSetting!
}
Finally, you guessed it, we need to define the Mutation
type so that we can update appBarColorSetting
:
type Mutation {
updateAppBarColorSetting(setting: String!): AppBarColorSetting!
}
Finally, we set up our client. Often, you will find yourself instantiating ApolloClient
with a link
property. However, since we have added a cache
and resolvers
, we do not need to add a link
. We do, however, add a couple of properties that may look unfamiliar. As of apollo-client 2.6, you can set an assumeImmutableResults
property to true
to let apollo-client know that you are confident you are not modifying cache result objects. This can, potentially, unlock substantial performance improvements. To enforce immutability, you can also add the freezeResults
property to inMemoryCache
and set it to true
. Mutating frozen objects will now throw a helpful exception in strict mode in non-production environments. To learn more, read the “What’s new in Apollo Client 2.6” post from Ben Newman.
const client = new ApolloClient({
cache: new InMemoryCache({
freezeResults: true
}),
typeDefs,
resolvers,
assumeImmutableResults: true
});
That’s it! Now, simply pass this client
to ApolloProvider
and we’ll be ready to write our query and mutation! 🚀
const TogglesApp = () => (
<ApolloProvider client={client}>
<App />
</ApolloProvider>
);
Querying client-side data
We’re now going to query our client cache using GraphQL. Note that in this proof-of-concept, we simply define the initial state of our userSettings
in a JSON blob:
{
"appBarColorSetting": {
"id": 1,
"name": "App Bar Color",
"setting": "primary",
"__typename": "AppBarColorSetting"
}
}
Note the need to define the type with the __typename
property.
We then define our query in its own .js
file. You could choose to define this in the same file the query is called from or even in a .graphql
file though.
import gql from "graphql-tag";
const APP_BAR_COLOR_SETTING_QUERY = gql`
query appBarColorSetting {
appBarColorSetting @client {
id
name
setting
}
}
`;
export default APP_BAR_COLOR_SETTING_QUERY;
The most important thing to notice about this query is the use of the @client
directive. We simply need to add this to the appBarColorSetting
query as it is client-specific. Let’s take a look at how we call this query next:
import React from "react";
import { useQuery } from "@apollo/react-hooks";
import AppBar from "@material-ui/core/AppBar";
import Toolbar from "@material-ui/core/Toolbar";
import Typography from "@material-ui/core/Typography";
import IconButton from "@material-ui/core/IconButton";
import MenuIcon from "@material-ui/icons/Menu";
import SettingsComponent from "./components/SettingsComponent";
import APP_BAR_COLOR_SETTING_QUERY from "./graphql/APP_BAR_COLOR_SETTING_QUERY";
function App() {
const { loading, data } = useQuery(APP_BAR_COLOR_SETTING_QUERY);
if (loading) return <h2>Loading...</h2>;
return (
<div>
<AppBar position="static" color={data.appBarColorSetting.setting}>
<Toolbar>
<IconButton color="inherit" aria-label="Menu">
<MenuIcon />
</IconButton>
<Typography variant="h6" color="inherit">
State Management with Apollo
</Typography>
</Toolbar>
</AppBar>
<SettingsComponent
setting={
data.appBarColorSetting.setting === "primary"
? "secondary"
: "primary"
}
/>
</div>
);
}
export default App;
Note: we are using Material-UI in this app, but obviously the UI framework choice is up to you. 🤷♂️
const { loading, data } = useQuery(APP_BAR_COLOR_SETTING_QUERY);
We show a basic loading indicator and then render the app bar with data.appBarColorSetting.setting
passed into the color
attribute. If you are using the Apollo Client Developer Tools, you’ll be able to clearly see this data sitting in the cache.
Mutating client-side data and updating the cache
You may have noticed this block of code in our App
component. This simply alternates the value of setting
based on its current value and passes it to our SettingsComponent
. We will take a look at this component and how it triggers a GraphQL mutation next.
<SettingsComponent
setting={
data.appBarColorSetting.setting === "primary" ? "secondary" : "primary"
}
/>
First, let’s take a peek at our mutation:
import gql from "graphql-tag";
const UPDATE_APP_BAR_COLOR_SETTING_MUTATION = gql`
mutation updateAppBarColorSetting($setting: String!) {
updateAppBarColorSetting(setting: $setting) @client
}
`;
export default UPDATE_APP_BAR_COLOR_SETTING_MUTATION;
Again, notice the use of the @client
directive for our client-side updateAppBarColorSetting
mutation. This mutation is very simple: pass in a required setting string and update the setting.
Below you will find all the code within our SettingsComponent
which utilizes this mutation:
import React from "react";
import { useMutation } from "@apollo/react-hooks";
import Button from "@material-ui/core/Button";
import UPDATE_APP_BAR_COLOR_SETTING_MUTATION from "../graphql/UPDATE_APP_BAR_COLOR_SETTING_MUTATION";
import APP_BAR_COLOR_SETTING_QUERY from "../graphql/APP_BAR_COLOR_SETTING_QUERY";
function SettingsComponent({ setting }) {
const [updateUserSetting] = useMutation(
UPDATE_APP_BAR_COLOR_SETTING_MUTATION,
{
variables: { setting },
update: cache => {
const data = cache.readQuery({
query: APP_BAR_COLOR_SETTING_QUERY
});
const dataClone = {
...data,
appBarColorSetting: {
...data.appBarColorSetting,
setting
}
};
cache.writeQuery({
query: APP_BAR_COLOR_SETTING_QUERY,
data: dataClone
});
}
}
);
return (
<div style={{ marginTop: "50px" }}>
<Button variant="outlined" color="primary" onClick={updateUserSetting}>
Change color
</Button>
</div>
);
}
export default SettingsComponent;
The interesting piece of this code that we want to focus on is the following:
const [updateUserSetting] = useMutation(
UPDATE_APP_BAR_COLOR_SETTING_MUTATION,
{
variables: { setting },
update: cache => {
const data = cache.readQuery({
query: APP_BAR_COLOR_SETTING_QUERY
});
const dataClone = {
...data,
appBarColorSetting: {
...data.appBarColorSetting,
setting
}
};
cache.writeQuery({
query: APP_BAR_COLOR_SETTING_QUERY,
data: dataClone
});
}
}
);
Here, we make use of the apollo/react-hooks useMutation
hook, pass it our mutation and variables, then update the cache within the update method. We first read the current results for the APP_BAR_COLOR_SETTING_QUERY
from the cache then update appBarColorSetting.setting
to the setting passed to this component as a prop
, then write the updated appBarColorSetting
back to APP_BAR_COLOR_SETTING_QUERY
. Notice that we do not update the data
object directly, but instead make a clone of it and update setting
within the clone, then write the cloned data
object back to the cache. This triggers our app bar to update with the new color! We are now utilizing apollo-client as a client-side state management solution! 🚀
Takeaways
If you’d like to dig into the code further, the CodeSandbox can be found here. This is admittedly a very contrived example but it shows how easy it can be to leverage apollo-client as a state management solution. This can be an excellent way to introduce GraphQL and the Apollo suite of libraries and tools to a team who has little to no GraphQL experience. Expanding use of GraphQL is simple once this basic infrastructure is in place.
I would love to hear thoughts and feedback from everyone and I hope you learned something useful through this post!
Top comments (4)
This is a very interesting proposal for handling state management. Are there any expected benefits from this approach versus Redux? Besides allowing the teams to get comfortable with GraphQL.
Also, with hook and concurrent mode coming it seems like more optimized and performant solutions than Redux would emerge. Specifically I am thinking of having a state management solution for the global application state pieces, but then the state that is only shared inside of modules could be handled with useReducer and ContextAPI.
Thanks. Great questions which were addressed a bit in this thread: twitter.com/swyx/status/1166733744...
It sounds like Apollo is looking to improve upon this existing solution to make state management even easier and more robust. With that said, several folks in that thread make great points about why this may not be the best approach.
Still an interesting exercise and I'm glad it started a conversation.
I'm curious why you prefer writing to an in-memory object over the cache like the official docs recommend?
Great question. If this is in reference to the hard-coded JSON blob:
...that was a choice I made just for this proof-of-concept and not something I'd actually suggest in a real-world use case.