🚀Introducing Apollo Orbit: A GraphQL Client for Angular with modular state management

Wassim Khalil
9 min readSep 19, 2024

--

What is Apollo Orbit for Angular?

Apollo Orbit is a fully-featured Apollo Client for Angular.

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 Angular UI.

Apollo Orbit brings the power of Apollo Client to Angular and combines it with Redux/NGXS concepts for state management.

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

Features ✨

  • A fully-featured implementation of Apollo Client for Angular with a focus on optimal developer experience.
  • 100% typesafe: @apollo-orbit/codegen package generates TypeScript code catching any breaking GraphQL schema changes at compile time across application, cache & state logic.
  • Comprehensive state management: Apollo Orbit combines the strengths of Apollo Client and traditional state management libraries, removing the need for an external state management library.
  • 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.

Getting Started

Setup environment, then provide Apollo Orbit to your ApplicationConfig or AppModule

import { provideHttpClient } from '@angular/common/http';
import { ApplicationConfig, inject } from '@angular/core';
import { InMemoryCache, provideApolloOrbit, withApolloOptions } from '@apollo-orbit/angular';
import { HttpLinkFactory, withHttpLink } from '@apollo-orbit/angular/http';

export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(),
provideApolloOrbit(
withHttpLink(),
withApolloOptions(() => ({
cache: new InMemoryCache(),
link: inject(HttpLinkFactory).create({ uri: 'http://localhost:4000/graphql' })
})
)
]
};

Queries

Let’s cover the basics by starting with a simple example of a component using Apollo Orbit to query data.

First, we write our GraphQL query:

fragment BookFragment on Book {
id
name
genre
authorId
}

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

Assuming you’ve followed the Getting Started guide, when saving this file, it will trigger GraphQL code generation using @apollo-orbit/codegen.

đź’ˇ @apollo-orbit/codegen is compliant with TypedDocumentNode standard which is supported by most GraphQL clients.
Visit the docs for more information about codegen.

Next, we write our component logic:

import { Apollo, mapQuery } from '@apollo-orbit/angular';
import { BooksQuery } from '../../graphql/types';

@Component({
selector: 'app-books',
template: `
<h3>Books <button (click)="refetch()">âźł</button></h3>

@if (books$ | async; as booksResult) {
@if (booksResult.loading) { Loading... }
@if (booksResult.error) { {{ booksResult.error.message }} }
@for (book of booksResult.data ?? booksResult.previousData; track book.id) {
<div>{{ book.name }}</div>
}
}
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class BooksComponent {
private readonly booksQuery = this.apollo.watchQuery(new BooksQuery());

protected readonly books$ = this.booksQuery.pipe(
mapQuery(data => data.books)
);

public constructor(
private readonly apollo: Apollo
) { }

protected refetch(): void {
this.booksQuery.refetch();
}
}

Let’s go through some of the features in the above code sample:

Seamless integration with RxJS
this.apollo.watchQuery(...) returns a QueryObservable<TData, TVariables> which extends RxJS's standard Observable<QueryResult<TData>>, this allows direct calls to Observable members like subscribe and pipe and also ObservableQuery members like refetch, fetchMore and subscribeToMore...

Auto-resubscription on query observable error
If an error occurs while fetching data using watchQuery, Apollo Orbit seamlessly manages the re-subscription to the underlying observable, ensuring that future operations, such as refetching or polling, proceed smoothly without any additional effort from the developer

previousData
Apollo Orbit
exposes a previousData property which stores the last non-nil value of the data property that can be used to provide a smoother user experience when refetching data.

mapQuery
Apollo Orbit
provides a helpermapQuery RxJS operator to map both data and previousData while preserving other properties of QueryResult<T>.

Mutations

Writing mutations using Apollo Orbit is as simple as working with queries.

Let’s start by writing our GraphQL mutation:

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

Next, we write our component logic:

import { Apollo, mapMutation } from '@apollo-orbit/angular';
import { AddBookMutation, BookInput } from '../../graphql/types';

@Component({
selector: 'new-book',
template: `
@if (error(); as error) {
<div>An error has occurred: {{ error.message }}</div>
}

<form [formGroup]="bookForm" (ngSubmit)="addBook(bookForm.value)">
...
</form>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class NewBookComponent {
protected readonly error = signal<Error | undefined>(undefined);

public constructor(
private readonly apollo: Apollo,
private readonly notificationService: NotificationService
) { }

protected addBook(book: BookInput): void {
this.error.set(undefined);
this.apollo.mutate(new AddBookMutation({ book })).pipe(
mapMutation(data => data.addBook)
).subscribe({
next: result => this.notificationService.success(`Book '${result.data.name}' was added successfully.`),
error: error => this.error.set(error)
});
}

...
}

Optional vs required query variables
The generated AddBookMutation class constructor requires a mandatory variables parameter, unlikeBooksQuery, which accepts an optional variables parameter.
This is automatically determined by @apollo-orbit/codegen when a GraphQL field has mandatory variables, ensuring that any breaking changes in the GraphQL schema, even in field variables, are caught at compile-time.

Please refer to the docs if you’d like to learn more Queries, Mutations & Subscriptions.

State Management

Apollo Orbit leverages Apollo Client’s extensive caching capabilities to provide a comprehensive solution for managing both remote and local data state 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 updateBook mutation:

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

When apollo.mutate(new UpdateBookMutation({ id, book }) is called from a component, any active apollo.watchQuery(new BooksQuery()) observables 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 in the previous code sample. Those require a bit more work on the developer’s part, and it looks something like this:

export class BooksComponent {
public constructor(
private readonly apollo: Apollo,
) { }

protected addBook(book: BookInput): void {
this.apollo.mutate({
...new AddBookMutation({ book }),
// Handle cache update
update(cache, result) {
const addBook = result.data?.addBook;
if (!addBook) return;

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

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

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/angular';
import { AddBookMutation, BooksQuery } from '../../graphql/types';

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

cache.updateQuery(
new BooksQuery(),
data => ({ books: [...data.books, addBook] })
);
})
);

author.state.ts

import { identifyFragment, state } from '@apollo-orbit/angular';
import { AddBookMutation, AuthorFragmentDoc } from '../../graphql/types';

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

const { authorId } = info.variables.book;
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 register the states with Angular’s DI system, by providing them in the providers array of an NgModule or a route:

import { provideStates } from '@apollo-orbit/angular';
import { authorState } from './states/author.state';
import { bookState } from './states/book.state';

...
providers: [
provideStates(authorState, bookState)
]
...

Now, when a component calls:
apollo.mutate(new AddBookMutation({ book })).subscribe()
the mutationUpdate functions defined in our 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/angular';
import { AddBookMutation, BooksQuery } from '../../graphql/types';
import shortid from 'shortid';

export const bookState: StateFactory = () => {
const notificationService = inject(NotificationService);

return state(descriptor => descriptor
...
.optimisticResponse(AddBookMutation, ({ book }) => ({
__typename: 'Mutation' as const,
addBook: {
__typename: 'Book' as const,
id: shortid.generate(),
genre: book.genre,
name: book.name,
authorId: book.authorId
}
}))
.effect(AddBookMutation, info => {
if (info.data?.addBook) {
notificationService.success(`New book '${info.data.addBook.name}' was added successfully.`);
} else if (info.error) {
notificationService.error(`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 class ToggleThemeAction {
public static readonly type = '[Theme] ToggleTheme';

public constructor(
public readonly force?: ThemeName
) { }
}

export class ThemeToggledAction {
public static readonly type = '[Theme] ThemeToggled';

public constructor(
public readonly toggles: number
) { }
}

Next, we define the state:

theme.state.ts

import { inject } from '@angular/core';
import { gql, StateFactory, state } from '@apollo-orbit/angular';
import { ThemeName, ThemeQuery } from '../../graphql/types';
import { NotificationService } from '../../services/notification.service';

export class ToggleThemeAction {
public static readonly type = '[Theme] ToggleTheme';

public constructor(
public readonly force?: ThemeName
) { }
}

export class ThemeToggledAction {
public static readonly type = '[Theme] ThemeToggled';

public constructor(
public readonly toggles: number
) { }
}

export const themeState: StateFactory = () => {
const notificationService = inject(NotificationService);

return 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({
...new ThemeQuery(),
data: {
theme: {
__typename: 'Theme',
name: ThemeName.LightTheme,
toggles: 0,
displayName: 'Light'
}
}
});
})
// Handle ToggleThemeAction
.action(ToggleThemeAction, (action, { cache, dispatch }) => {
const result = cache.updateQuery(
new ThemeQuery(),
data => ({
theme: {
...data.theme,
toggles: data.theme.toggles + 1,
name: action.force ?? (data.theme.name === ThemeName.DarkTheme ? ThemeName.LightTheme : ThemeName.DarkTheme)
}
})
);

return dispatch(new ThemeToggledAction(result?.theme.toggles as number));
})
// Handle ThemeToggledAction
.action(ThemeToggledAction, (action, context) => {
notificationService.success(`Theme was toggled ${action.toggles} time(s)`);
})
);
};

Now, that we’ve defined the local GraphQL schema usingtypeDefs we can write our GraphQL query and save it to trigger codegen.

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

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

import { AsyncPipe } from '@angular/common';
import { Component } from '@angular/core';
import { Apollo } from '@apollo-orbit/angular';
import { map } from 'rxjs';
import { ThemeQuery } from '../graphql/types';
import { ToggleThemeAction } from '../states/theme/theme.actions';

@Component({
selector: 'app-theme',
standalone: true,
imports: [AsyncPipe],
template: `
@if (theme$ | async; as theme) {
<div>
<span>Current theme:</span>
<b>{{ theme.displayName }}</b>
<button type="button" (click)="toggleTheme()">Toggle theme</button>
</div>
}
`
})
export class ThemeComponent {
protected readonly theme$ = this.apollo.cache.watchQuery(new ThemeQuery()).pipe(
map(({ data }) => data.theme)
);

public constructor(
private readonly apollo: Apollo
) { }

protected toggleTheme(): void {
this.apollo.dispatch(new ToggleThemeAction());
}
}

Summary

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

Make sure to visit the docs for many more features like:

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

--

--