🚀Introducing Apollo Orbit: An Apollo Client modular state management abstraction for React

Wassim Khalil
6 min readSep 19, 2024

--

What is Apollo Orbit for React?

Apollo Orbit extends Apollo Client’s capabilities to offer a modular state management system for React.

Apollo Client is a GraphQL client with advanced caching capabilities which can be used to fetch, cache, and modify application data, all while automatically updating your React UI.

Apollo Orbit aims to bridge the gap between Apollo Client and traditional state management libraries like Redux.

For complete documentation and step-by-step guide, please visit Apollo Orbit — React Docs

Features ✨

  • Comprehensive state management: Apollo Orbit combines the strengths of Apollo Client and traditional state management libraries, providing a unified solution for managing both local and remote data.
  • Decoupling: Separate state management code from component code using modular state definitions and action handlers.
  • Modular: Each state definition manages its own slice of the cache.
  • Separation of concerns (SoC): Different state slices can handle the same Mutation or Action independently.
  • Event-driven architecture: Apollo Orbit actions enable event-driven application design.
  • Testability: state logic can be tested in isolation, enhancing testability.

Getting Started

Setup Apollo Client

All you need to start using Apollo Orbit is a React project setup with Apollo Client.
Please follow the Apollo Client setup guide to proceed.

Setup GraphQL Codegen

You can follow GraphQL-Codegen React guide to setup codegen for your project.

đź’ˇ While Apollo Orbit can work fully without codegen, it is highly recommended to use codegen in order to improve developer experience and enable compile-time safety across your application, cache and state logic.

Providing States

It’s as simple as passing an array of states to ApolloOrbitProvider:

import { themeState } from './states/theme.state';

root.render(
<ApolloProvider client={client}>
<ApolloOrbitProvider states={[themeState]}>
<App />
</ApolloOrbitProvider>
</ApolloProvider>,
);

đź’ˇ ApolloOrbitProvider can be used multiple times and at any level in the application, including in lazy loaded components.

Please refer to the docs for the complete getting started guide.

State Management

Apollo Orbit leverages Apollo Client’s extensive caching capabilities to provide a comprehensive solution for managing both remote and local data with ease.

Apollo Client utilizes GraphQL’s rich type information system (schema) in order to maintain an in-memory normalized cache of data retrieved from the API with no additional effort from the developer.

For example, let’s take the following GraphQL:

query Books($name: String, $genre: String, $authorId: ID) {
books(name: $name, genre: $genre, authorId: $authorId) {
...BookFragment
}
}

mutation AddBook($book: BookInput!) {
addBook(book: $book) {
...BookFragment
}
}

mutation UpdateBook($id: ID!, $book: BookInput!) {
updateBook(id: $id, book: $book) {
...BookFragment
}
}

When UpdateBook mutation is executed, any active Books queries on the page will receive a new value with the updated book data without any additional effort.

Alas, the same isn’t true for mutations that add a value to an existing collection, like AddBook mutation. Those require a bit more work on the developer’s part, and it looks something like this:

import { AddBookDocument } from '../graphql/types';

export function Books() {
const [addBook] = useMutation(AddBookDocument);

const handleSubmit = () => {
addBook({
variables: { book: ... },
update(cache, result) {
const addBook = result.data?.addBook;
if (!addBook) return;

// Update full list of books
cache.updateQuery(
{ query: BooksDocument },
data => ({ books: [...data.books, addBook] })
);

// Update author's list of books
cache.updateFragment(
identifyFragment(AuthorFragmentDoc, book.authorId),
author => ({ ...author, books: [...author.books, addBook] })
);
}
});
};

return (
<form onSubmit={handleSubmit}>
...
</form>
);
}

There are a few issues with the code above:

  • Mixing of component and state logic: This can quickly clutter component code and increase complexity.
  • Coupling of component and state logic: The component needs to be aware of parts of the state that might be managed by other modules e.g. author module.
  • Inconsistent behaviour: Calling addBook mutation from a different component needs to ensure the same logic is executed, otherwise it may lead to inconsistent behaviour.
  • Test isolation: It’s difficult to test cache logic in isolation without testing the component logic.

State

Apollo Orbit elegantly solves the above issues by providing the ability to define modular state slices.

Let’s start by defining two slices: one for managing the book slice of the cache and another for the author slice.

book.state.ts

import { state } from '@apollo-orbit/react';
import { AddBookDocument, BooksDocument } from '../../graphql';

export const bookState = state(descriptor => descriptor
.mutationUpdate(AddBookDocument, (cache, info) => {
const addBook = info.data?.addBook;
if (!addBook) return;

// Update full list of books
cache.updateQuery(
{ query: BooksDocument },
data => ({ books: [...data.books, addBook] })
);
})
);

author.state.ts

import { identifyFragment, state } from '@apollo-orbit/react';
import { AddBookDocument, AuthorFragmentDoc } from '../../graphql';

export const authorState = state(descriptor => descriptor
.mutationUpdate(AddBookDocument, (cache, info) => {
const addBook = info.data?.addBook;
if (!addBook) return;

// Update author's list of books
const authorId = info.variables?.book.authorId as string;
cache.updateFragment(
identifyFragment(AuthorFragmentDoc, authorId),
author => ({ ...author, books: [...author.books, addBook] })
);
})
);

đź’ˇidentifyFragment is a helper function provided by Apollo Orbit for returning a fragment object that uniquely identifies a fragment in the cache.

Next, we add the states to ApolloOrbitProvider:

import { authorState } from './states/author.state';
import { bookState } from './states/book.state';

export function Library() {
return (
<ApolloOrbitProvider states={[authorState, bookState]}>
<Books></Books>
<Authors></Authors>
</ApolloOrbitProvider>
);
}

Now, when a component calls useMutation(AddBookDocument), the mutationUpdate functions in the states are automatically executed and the UI displaying books and author books is updated.

The example above demonstrates how the same mutation can be handled independently by different states, achieving separation of concerns (SoC) and complete decoupling between component and state logic.

More state functionality

state can do much more than just handle mutation updates, for example, we can extend book.state.ts to return an immediate optimisticResponse while waiting for the server’s response and display a success/error notification as an effect of the mutation:

import { state } from '@apollo-orbit/react';
import { gql } from '@apollo/client';
import shortid from 'shortid';
import { AddBookDocument, Book, BooksDocument } from '../../graphql';

export const bookState = state(descriptor => descriptor
.optimisticResponse(AddBookDocument, ({ book }) => ({
__typename: 'Mutation' as const,
addBook: {
__typename: 'Book' as const,
id: shortid.generate(),
genre: book.genre ?? null,
name: book.name,
authorId: book.authorId
}
}))
.effect(AddBookDocument, info => {
if (info.data?.addBook) {
toast(`New book '${info.data.addBook.name}' was added.`);
} else if (info.error) {
toast(`Failed to add book '${info.variables?.book.name}': ${info.error.message}`);
}
})
);

For more information, refer to Apollo Orbit’s state docs

Actions

Besides managing the state of data fetched from a remote API, it’s common to implement state that is managed entirely on the client.

This is where actions are useful in communicating commands or events that occur within a component to the various state slices.

As a demonstration of how actions work, let’s implement a theme toggling feature.

In the interest of brevity, I’ll demonstrate the code as a whole here, but feel free to visit the docs for a step-by-step guide.

Let’s start by defining our actions:

theme.actions.ts

import { ThemeName } from '../../graphql';

export interface ToggleThemeAction {
type: 'theme/toggle';
force?: ThemeName;
}

export interface ThemeToggledAction {
type: 'theme/toggled';
toggles: number;
}

Next, we define the state:

theme.state.ts

import { state } from '@apollo-orbit/react';
import { gql } from '@apollo/client';
import { ThemeDocument, ThemeName } from '../../graphql';
import { ThemeName } from '../../graphql';
import { ThemeToggledAction, ToggleThemeAction } from './theme.actions';

export const themeState = state(descriptor => descriptor

// Define local state schema
.typeDefs(gql`
type Theme {
name: ThemeName!
displayName: String!
toggles: Int!
}

enum ThemeName {
DARK_THEME
LIGHT_THEME
}

extend type Query {
theme: Theme!
}
`)

// Implement displayName field on Theme type
.typePolicies({
Theme: {
fields: {
displayName: (existing, { readField }) =>
readField<ThemeName>('name') === ThemeName.LightTheme
? 'Light'
: 'Dark'
}
}
})

// Set initial state
.onInit(cache => cache.writeQuery({
query: ThemeDocument,
data: {
theme: {
__typename: 'Theme',
name: ThemeName.LightTheme,
toggles: 0,
displayName: 'Light'
}
}
}))

// Handle ToggleThemeAction
.action<ToggleThemeAction>(
'theme/toggle',
(action, { cache, dispatch }) => {

const result = cache.updateQuery(
{ query: ThemeDocument },
data => ({
theme: {
...data.theme,
toggles: data.theme.toggles + 1,
name: action.force ?? data.theme.name === ThemeName.DarkTheme ? ThemeName.LightTheme : ThemeName.DarkTheme
}
}));

return dispatch<ThemeToggledAction>({
type: 'theme/toggled',
toggles: result?.theme.toggles as number
});
})

// Handle ThemeToggledAction
.action<ThemeToggledAction>(
'theme/toggled',
action => {
toast(`Theme was toggled ${action.toggles} time(s)`);
})
);

Now, that we’ve defined the local GraphQL schema using typeDefs we can write our GraphQL query

query Theme {
theme @client {
name
toggles
displayName
}
}

Next, we provide the state as shown previously and create a theme toggling component:

import { useDispatch } from '@apollo-orbit/react';
import { useQuery } from '@apollo/client';
import { ThemeDocument } from './graphql';
import { ToggleThemeAction } from './states/theme/theme.actions';

export function Theme() {
const dispatch = useDispatch();
const { data: themeData } = useQuery(ThemeDocument);

return (
<div>
<span>Current theme:</span>
<b>{themeData?.theme.displayName}</b>
<button onClick={() => dispatch<ToggleThemeAction>({ type: 'theme/toggle' })}>Toggle theme</button>
</div>
);
}

Summary

In this introduction, we’ve covered some of the basic features of Apollo Orbit — React.

Make sure to visit the docs for a more in-depth guide.

I hope Apollo Orbit proves as valuable for your React projects as it has for ours and I look forward to hearing your feedback.

--

--