Local Apollo state management using reactive variables
GraphQL is a very popular tool to deliver application APIs while Apollo is defacto standard implementation of GraphQL server and client. An easy API access, which made these technologies so popular, still doesn't eliminate the need to manage application state. For example, consider a common use case: a user made a search and now wants to access additional information, clicking on the list items. An application may need to remember visited items and may need to dynamically accumulate some information about documents viewed (titles, view counts, keywords). This purely client-side information could be used later to generate suggestions, show history, add bookmarks and tags, etc. To implement such functionality, many applications would customary resort to Redux. But Apollo Client 3 now comes with reactive variables enabling pure GraphQL/Apollo state management, which is the subject of this blog post.
Our test case relies on swapi-graphql API, which returns information about Star War characters and starships. Instead of user searches, we develop a simple game which, upon user request, fetches information about two or more Star War characters/starships. A user/starship is declared a winner based on its score (height for people/hyperdriveRating for starships). To make this test case look more like real application, I use Styled Components/Typescript/Apollo Client/React Router/React Hooks stack, though with very minimalist UI design as the main focus of this blog post is state management.
Find example source code on Github.
Reactive variables and local state
As you probably know, reactive variables are just functions. Invoked without parameters, function returns a current variable value. By invoking function with a parameter, a variable value could be changed. When used in typePolicies to define @client GraphQL fields, reactive variable, upon changes, triggers query updates. Though reactive variables may look different from Redux store, conceptually their usage is quite similar: they define immutable application state, with React components subscribing to updates to reflect state changes in the UI. For example, in our game we need to store all the games played (ids of participants, their scores and a game winner) to be able to show history/last game outcome:
// from src/types.ts
export type PlayerType = "people" | "starships"
export interface Game {
id: string
mode: PlayerType
winner: string | undefined
playerIds: string[]
scores: Record<string, number>
}
To get games history using GraphQL, a games GraphQL @client field is defined in cache configuration:
// from src/types.ts
export const allGames = makeVar<GameInfo[]>([])
........................................
const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
...................................
games: {
read() {
return allGames()
}
}
}
}
}
})
For every game participant we show a card with relevant information. Data is fetched from the server using GraphQL queries. As you probably know, these requests are cached by Apollo Client, and could be batched using apollo-link-batch-http if further optimization is required.
Upon data retrieval the game is updated using addGameScore({ score, id: gameId, playerId: id }) function call:
export const STARSHIP = gql`
query Starship ($id: ID!){
starship(id: $id) {
name
score:hyperdriveRating
}
}
`
export const StarshipCard: React.FC<GamePlayer> = React.memo(({ id, gameId, winner }) => {
const { data, loading, error } = useQuery<{ starship: Starship }>(STARSHIP, { variables: { id: id } })
if (error) {
console.error(error)
return <Card>ERROR: {error.message}</Card>
}
if (loading || !data) return null
const { name, score } = data.starship
addGameScore({ score, id: gameId, playerId: id })
return (
<Card selected={winner} data-testid={id}>
<h4>{name}</h4>
<div>hyperdrive: {score}</div>
</Card>
)
})
Model functions like addGameScore commonly access reactive variables directly, without GraphQL. Inside this function, we find the game, check if the game info should be updated, and, if so, we update the player score and potentially the winner field. Finally, the reactive variable is set to the new array with the relevant games updated (there could be similar games with the same participants):
export const addGameScore = ({ id, playerId, score }: { id: string; playerId: string; score: number }) => {
const all = allGames()
const game = all.find(g => g.id === id)
if (!game) {
console.error(`no game found: ${id}`)
return
}
if (!game.winner) {
const updated = game.clone()
updated.addScore(playerId, score)
allGames(all.map(g => (g.id === id && updated) || g))
}
}
To better structure our code, a new class GameInfo was used to store/manipulate game information:
export class GameInfo implements Game {
readonly id: string
readonly mode: PlayerType
winner: string | undefined = undefined
readonly playerIds: string[]
scores: Record<string, number> = {}
constructor(type: PlayerType, ids: string[]) {
this.id = gameId(ids)
this.mode = type
this.playerIds = ids
}
clone() {
const obj = new GameInfo(this.mode, this.playerIds)
Object.assign(obj, this)
return obj
}
addScore(id: string, score: number) {
if (this.winner) {
return
}
this.scores = { ...this.scores, [id]: score || 0 }
const all = Object.entries(this.scores)
if (all.length === this.playerIds.length) {
const [argMax] = all.reduce((max, it) => {
return max[1] < it[1] ? it : max
}, all[0])
this.winner = argMax
}
}
}
This is arguably an overkill for this particular task, but it could be a good way to shape our code as more functionality is added in the future. Besides, this only requires a little bit of boilerplate, added just to make code more functional. We make our class immutable by adding a clone method which is used to a copy game instance on every modification. A common technique which reconciles object oriented and functional programming styles.
A few other reactive variables were added to manage our game state (request status, the number and type of players, last error). These variables help to decouple our components. For example, play button updates local state in accordance with API request status, so that other components may render game content using just corresponding state fields. As there is not much logic behind, very little code is required, which could be found in cache.ts file.
Unit testing Apollo reactive variables
As reactive variables are just functions, they and associated application model could be easily tested separately from React components and GraphQL. So, when testing reactive variables together with React components, you can just mock their values. There are two most common methods to mock GraphQL queries: one can test them using Jest mocks, or using Apollo MockedProvider. (There is actually a third method - automocking, which uses server side tooling to mock API based on GraphQL schema, which could be useful if more realistic mocking is desired.)
Despite limitations, Jest mocking could be actually an optimal choice when component uses @client fields only and thus we don't need to test errors and intermediate states. For example, consider GamePanel component. It is responsible to layout current game. Depending on the local game state this component shows list of the games played, or last game, or error/loading message, etc. As this component queries local state only, it could be tested using only Jest mocks:
it("game panel: last game", async () => {
const useGameURLMock = jest.spyOn(gameURL, 'useGameURL').mockImplementation((): any => {
return {
slug: null
}
})
const useQueryMock = jest.spyOn(apollo, 'useQuery').mockImplementation((): any => {
return {
data: {
mode: "game",
games: [{ id: "id1" }, { id: "id2" }]
}
}
})
const ListItem = jest.fn(()=><div>test-item</div>)
const { getByText } = render(
<GamePanel ListItem={ListItem}/>
)
expect(ListItem).toBeCalledTimes(1)
expect(getByText(/test-item/i)).toBeInTheDocument()
useGameURLMock.mockRestore()
useQueryMock.mockRestore()
})
React Router, application state, and child component are mocked here to check that component renders only the last game information.
When there are multiple GraphQL queries or intermediate/error states, MockedProvider from Apollo testing utilities is clearly a better fit. For example, PlayButton requires both local state fields and the list of ids of starships/people from the server. To test it, MockedProvider was used:
it.each([
["people" as PlayerType, 'p0;p3', ['p0', 'p3']],
["starships" as PlayerType, 's0;s3', ['s0', 's3']],
])("play: %s", async (type: PlayerType, id, playerIds) => {
await act(async () => {
allGames([])
const gameBoard: GameBoard = {
totalPlayers: 2,
mode: "loading",
playersType: type,
allPeople: {
people: mockPeople()
},
allStarships: {
starships: mockStarships()
},
games: []
}
const mocks = [
{
request: {
query: PLAY,
},
result: {
data: gameBoard
}
}
]
const { getByTestId } = render(
<MockedProvider mocks={mocks} addTypename={false} >
<PlayButton />
</MockedProvider>
)
const button = getByTestId("play")
expect(button).toBeInTheDocument()
await delay()
expect(gameState() === "playing").toBeTruthy()
expect(allGames().length).toBe(0)
const generateRandomInts = jest.spyOn(utils, 'generateRandomInts').mockImplementation((total: number, max: number): number[] => {
expect(total).toBe(2)
expect(max).toBe(4)
return [0, 3]
})
fireEvent.click(button)
expect(allGames().length).toBe(1)
expect(allGames()[0]).toEqual({
id,
mode: type,
winner: undefined,
playerIds,
scores: {}
})
generateRandomInts.mockRestore()
})
})
After successful remote query, play button updates local state and should add a new game upon the button click. This (as well as error/loading states) could be easily tested using MockedProvider similarly to the cases with remote only data.
Summary
Local fields combined with reactive variables could be used to solve common state management problems. Use them to add client only fields, or to manage global application state, or quickly mock up new API functionality.
In our example, global state is used to decouple different application components. Also, games field is a good example of a more complex usage, where reactive variables are used to manage business logic/global state of application. When updates come from both user actions and upon new data from the server.
In their concept, reactive variables are quite similar to Redux/Flux datastores, so code migration is pretty straightforward. Reactive variables evidently do not provide some advanced capabilities available in advanced state management frameworks like Redux Saga, but they integrate seamlessly with GraphQL, so developers may use descriptive GraphQL syntax to declaratively define data used by React components.
Many projects, using Apollo in combination with Redux, Context API, MobX and other state management libraries may consider migrating to this pure GraphQL state management solution from Apollo project.