Using Apollo's local cache for global state management with Typescript, codegen and remote resolvers
If your project is using the apollo-client
to communicate with your GraphQL backend, there is no need to include redux
or mobx
for the global state management; you can store this state in the same Apollo client you are already using. This will be a contrived example, but this post will cover:
- Setting up local queries and mutations to take advantage of the cache
- Integrating the
apollo-client
you are already using with the local graphql - Setting up a GraphQL schema locally to use for
typeDefs
andcodegen
- Using
@graphql-codegen
to create custom hooks for your queries and mutations
The main reason that I wanted to implement this in our project was to kill off redux
. Aside from generally disliking redux
, I wanted it out of our app to reduce the complexity around managing global state in our UI that was mainly driven by data retrieved from our backend. All of this data was already living in our apollo-client
's cache, and we were having to then get it into our redux
store. This required lots of files, lots of type and interface creation and was just generally a pain. By moving all of this into our apollo-client
we would be able to develop more quickly in a strongly typed spec that we already had set up for our remote GraphQL server.
We'll start by creating a schema.js
file. The reason that I chose to make this a javascript file in a completely typescript project was to make code generation easier (avoid the need to compile a .ts
file). The gql
tag created in this file will be added to our apollo-client
's typeDefs
and used to generate the local schema later on.
At this point it is also worth noting that our project is set up in a fairly opinionated way (I know, shocker), so you will need to tweak any scripts to match your structure. Our structure assumes that files are organized by domain, so for the rest of the post we'll mainly be operating in the Balloon
domain, or src/Balloon
.
So let's get started setting up what we need to retrieve balloons from our server and then store the selected one locally.
// src/Balloon/schema.js
const gql = require("graphql-tag");
module.exports = gql`
input BalloonInput {
color: String!
id: ID!
}
type Balloon {
color: String!
id: ID!
}
extend type Query {
selectedBalloon: Balloon!
}
extend type Mutation {
# this returns null, but graphql requires defining a type here
setSelectedBalloon(selectedBalloon: BalloonInput!): String
}
`;
Next we'll set up the queries.ts
and mutations.ts
files.
// src/Balloon/queries.ts
import gql from "graphql-tag";
//
// ─── GQL TAGS ───────────────────────────────────────────────────────────────────
//
export const GetSelectedBalloonLocal = gql`
query GetSelectedBalloonLocal {
selectedBalloon @client {
color
id
}
}
`;
export const GetBalloons = gql`
query GetBalloons {
balloons {
color
id
}
}
`;
The magic in the above file is that we have defined both a local and remote query. By decorating the GetSelectedBalloonLocal
query with @client
we are telling apollo-client
that it should only look in the local cache for this. We will use the GetBalloons
query to populate our cache with data from our server, but we will only keep the concept of a selected balloon client-side in our cache.
// src/Balloon/mutations.ts
import { Resolver } from "apollo-client";
import gql from "graphql-tag";
//
// ─── GQL TAGS ───────────────────────────────────────────────────────────────────
//
export const SetSelectedBalloonLocal = gql`
mutation SetSelectedBalloonLocal($selectedBalloon: BalloonInput!) {
setSelectedBalloon(selectedBalloon: $selectedBalloon) @client
}
`;
//
// ─── RESOLVERS ──────────────────────────────────────────────────────────────────
//
export const setSelectedBalloonMutationResolver: Resolver = (
_root,
{ selectedBalloon },
{ cache }
) => {
try {
cache.writeData({ data: { selectedBalloon } });
} catch (error) {
console.error("Error writing selectedBalloon to local cache: ", error);
}
return null;
};
The gql tags in the file above should be familiar, but we've also added something you may not have set up before with client-side GraphQL. The query for selectedBalloon
is simple enough that we don't need to create a resolver for it (though we could), but we do need to let the apollo-client
know what to do when the setSelectedBalloon
mutation is called, and that is done with the setSelectedBalloonMutationResolver
. If you have set up resolvers for graphql on the server before, this should look pretty similar.
Lastly we'll set up some defaults so the cache doesn't explode (i.e. an empty state).
// src/Balloon/defaults.ts
import { IBalloon } from "../types/graphql"; // <-- this file will be auto-generated very soon
const defaultSelectedBalloon: IBalloon = {
color: "",
id: "",
};
export default {
selectedBalloon: { ...defaultSelectedBalloon, __typename: "Balloon" },
};
Most of the heavy lifting is done elsewhere, so we only have a few modifications to the src/client.ts
file (I'm going to omit setting up links and any other things that you may want to do with your client).
// src/client.ts
import { InMemoryCache } from 'apollo-cache-inmemory';
import balloonDefaultCacheValues from './Balloon/defaults';
import { setSelectedBalloonMutationResolver } from './Balloon/mutations';
import balloonSchema from './Balloon
const cache = new InMemoryCache();
const client = new ApolloClient({
...
cache,
resolvers: {
Mutation: {
setSelectedBalloon: setSelectedBalloonMutationResolver
}
},
typeDefs: [balloonSchema]
});
// init cache
cache.writeData({
data: {
...balloonDefaultCacheValues
}
});
At this point you could use the local query and mutation for balloons in your app, but there would be a lot of typing involved. We want auto-generated query and mutation hooks and we want our data typed correctly without defining types all over the place, and we want it now.
Create two files at the root level of the project: localSchema.js
and codegen.yml
. The localSchema.js
will be used to get all of the schema.js
files you have throughout your app and combine them into a schema to then be combined with your remote schema.
// ./localSchema.js
const { loadFilesSync } = require("@graphql-tools/load-files");
const { mergeTypeDefs } = require("@graphql-tools/merge");
const loadedFiles = loadFilesSync(`${__dirname}/src/**/schema.js`);
module.exports.schema = mergeTypeDefs(loadedFiles);
// ./codegen.yml
overwrite: true
schema:
- localSchema.js
- ${SCHEMA_PATH}/graphql: # <-- this is for our remote schema, you'll probably have something similar
headers:
Authorization: ${AUTH0_ACCESS_TOKEN}
generates:
src/types/graphql.tsx:
documents: # <-- your app structure may be different, but this will pick up the queries.ts and mutations.ts files created earlier
- src/**/*queries.{js,ts}
- src/**/*mutations.{js,ts}
plugins:
- typescript
- typescript-operations
- typescript-react-apollo
config:
withHOC: false
withHooks: true # <-- say yes to the hooks
hooksImportFrom: "@apollo/react-hooks"
typesPrefix: I # <-- all of our types created will be prefixed with 'I'
skipTypename: true
namingConvention:
enumValues: upper-case#upperCase
scalars:
ID: string
hooks:
afterOneFileWrite:
- yarn lint:script --fix
- prettier --write src/**/*.{js,jsx,ts,tsx}
./schema.graphql:
plugins:
- schema-ast
Make sure you have the following dev dependencies installed:
@graphql-codegen/cli
@graphql-codegen/introspection
@graphql-codegen/schema-ast
@graphql-codegen/typescript
@graphql-codegen/typescript-operations
@graphql-codegen/typescript-react-apollo
@graphql-tools/load-files
@graphql-tools/merge
In your package.json
, add a script for code generation:
// ./package.json
{
...
"scripts": {
...
"generate:schema": "graphql-codegen -r dotenv/config"
}
}
Now, when you run $ yarn generate:schema
from the command line all of your local and remote queries and mutations will have hooks generated for them as well as data types and inputs. All of this will now be accessible from the ./types/graphql.tsx
file. So now go forth and use your new hooks:
// ./src/AnotherThing/index.tsx
import React from "react";
import { BalloonPickerThatHasAllOfTheNeededLogicInItButForSomeReasonRequiresMeToPassAMutation } from "../Balloon/Picker";
import {
useGetSelectedBalloonLocalQuery,
useGetBalloonsQuery,
useSetSelectedBalloonLocalMutation,
} from "../types/graphql";
const AnotherThing: React.FC = () => {
useGetBalloonsQuery();
const { data: localData } = useGetSelectedBalloonLocalQuery();
const [setSelectedBalloon] = useSetSelectedBalloonLocalMutation();
return (
<>
<span>
You've selected the {localData?.selectedBalloon?.color} balloon!
</span>
<BalloonPickerThatHasAllOfTheNeededLogicInItButForSomeReasonRequiresMeToPassAMutation
onClick={setSelectedBalloon}
/>
</>
);
};
export default AnotherThing;
So there you have it. I hope you're able to adapt this adapt this for your own project to reduce some of the complexity around global state management.
Article tags: react, typescript, graphql, apollo-client, codegen