July 17, 2020

Local State Management with Reactive Variables

Khalil Stemmler

Khalil Stemmler

In Apollo Client & Client-side Architecture Basics, we learned that most client-side apps work with a couple of different types of state.

When we talk about local state management, we’re referring to the pattern of attaching properties and objects that only exist on the client-side. That’s shared (global) state.

shared (global) state is state that we sometimes don’t want to lose when the routes change. It’s also state we wish to enable reactivity for.

We can combine shared (global) state with remote data from backend services, or we can use it to build a truly local state-only app.

The ultimate goal of shared (global) state is to enable view-layer components to subscribe to pieces of it, get notified when it changes, and re-render.

In Apollo Client 3, using cache policies and reactive variables, you can elegantly add pieces of local state to the cache. Apollo Client 2x used local resolvers to accomplish this, but we prefer the newer approach for its cleanliness and lack of verbosity.

In this article, we’ll learn how to get started with local state management using Cache Policies and Reactive Variables.

We’ve also documented use cases that you’ll likely encounter when working with a mixture of local state and remote data. To wrap up this guide, we’ll demonstrate how to use the new cache APIs for each use case.

Howdy! 🤠 Just looking for the code? Visit @apollographql/ac3-state-management-examples on GitHub to see best practice examples of local and remote state management using the new Apollo Client 3 cache APIs.

Cache policies

Cache policies are a way to customize reads and writes to the cache.

They give us the control to model types and fields that might not exist as a part of your data graph, but do exist on the client-side. That’s exactly what we take advantage of for local state management.

For starters, here’s how to initialize a default value for a name field on the Person type.

const cache = new InMemoryCache({
  typePolicies: {
    Person: { // Every person type
      fields: {
        name: { // A field on the person type
          read (value = "UNKNOWN NAME") { // when it's read
            return value;
          }
        },
      },
    },
  },
});

By providing a read function for any field you want to configure, we’re given the currently cached value of the field, and whatever we return is what’s going to be the new value.

Notice here that we set the default value by using TypeScript’s default value syntax?

It turns out that we can do a lot of things with this API. We can implement filters, handle pagination, and configure local state using Reactive Variables.

Read more about Cache Policies via the docs on “Customizing the behavior of cached fields“.

Cache policies are half of the local state management story in AC3. Reactive variables are the other part.

Reactive variables

Reactive variables are containers for variables that we would like to enable cache reactivity for. Using the small API, we can either:

  • set the value by passing in an argument — var(newValue)
  • get the value by invoking the reactive variable — const currentValue = var()

Here’s how it works in practice.

import { makeVar } from "@apollo/client";

// Create Reactive variable
export const todosVar = makeVar<Todos>();

// Set the value
todosVar([]);

// Get the value
const currentTodosValue = todosVar();

Cache reactivity means that when the value of a reactive variable changes, it notifies any queries in components that were subscribed to the value of the change.

Using Cache Policies and Reactive Variables, we can:

  • Query local state variables the same way we query for data that exists remotely
  • Trigger updates using simple functions

Getting started with local state management

Let’s demonstrate this in action. We’re going to be working with the todos app from @apollographql/ac3-state-management-examples.

Setting up a Reactive Variable

If you’re using TypeScript, it’s a good idea to define an interface or a type to represent the shape of the variable you want to create.

In this case, we’re modeling Todos as an array of Todo objects.

// models/todos.tsx

export interface Todo {
  text: string;
  completed: boolean;
  id: number
}



export type Todos = Todo[];

Next, where we’ve configured our cache, use the makeVar method to create a Reactive Variable, optionally passing in an initial value.

// cache.tsx

import { InMemoryCache, makeVar } from "@apollo/client";
import { Todos } from './models/todos'

export const cache: InMemoryCache = new InMemoryCache({});

// Create the initial value
const todosInitialValue: Todos = [
  {
    id: 0,
    completed: false,
    text: "Use Apollo Client 3"
  }
]

// Create the todos var and initialize it with the initial value
export const todosVar = makeVar<Todos>(
  todosInitialValue
);

Now it’s time to connect the Reactive Variable to a cache policy. In the config for the cache object, define a cache policy and connect the value of a todos field to the todosVar reactive variable we just created.

// cache.tsx
... 
export const cache: InMemoryCache = new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        todos: {
          read () {
            return todosVar();
          }
        }
      }
    }
  }
});

That’s it! We’ve just configured reactivity for this fully client-side local variable.

By defining todos as a field on the Query type, writing a query that asks for todos uses the current value of the reactive variable we just set up here.

Subscribing to the value of a Reactive Variable

Let’s demonstrate how to get the current value of a reactive variable. First, write a query to fetch the data we want.

// operations/queries/GetAllTodos.tsx

import { gql } from "@apollo/client";

export const GET_ALL_TODOS = gql`
  query GetAllTodos {
    todos @client { 
      id  
      text  
      completed
    }
  }
`

Apollo Client distinguishes between data that we want to fetch remotely and data we want to fetch from the client through the use of the @client directive. By placing it alongside the todos keyword, we’re telling Apollo Client to attempt to resolve everything nested within the todos type from the Apollo Client cache itself instead of trying to resolve it remotely.

To execute this query, use the useQuery hook like we would normally.

import React from 'react'
import MainSection from '../components/MainSection'
import { useQuery } from '@apollo/client'
import { GET_ALL_TODOS } from '../operations/queries/getAllTodos'

export default function Main () {
  const { data } = useQuery(GET_ALL_TODOS);
	const todos: Todos = data.todos;
	... 
  return (
    <MainSection
      todosCount={todos.length}
      completedCount={todos.filter(t => t.completed).length}
    />
  );
}

That’s all there is to it!

You may be asking yourself, “Why do I have to write a query to get the value? Since the Reactive Variable API returns the current value when we invoke it, couldn’t we do that instead”?

import React from 'react'
import MainSection from '../components/MainSection'
import { useQuery } from '@apollo/client'
import { todosVar } from '../cache'

export default function Main () { 
  // Not recommended
  // Using the reactive variable to get all values
  const todos = todosVar();
	... 
  return (
    <MainSection
      todosCount={todos.length}
      completedCount={todos.filter(t => t.completed).length}
    />
  );
};

You know what? You could totally do that— and that’s cool. But conventionally, we fetch data using GraphQL queries. To reduce potential confusion, it’s a good idea to enforce a consistent fetching approach.

Updating a Reactive Variable

Updates to reactive variables can be done by importing the variable and invoking it with a new value.

Here’s a trivial example of deleting a todo.

import React from 'react'
import MainSection from '../components/MainSection'
import { useQuery } from '@apollo/client'
import { todosVar } from '../cache'
import { Todo } from '../models'

export default function Main () { 
  const todos = todosVar();
	... 
  return (
    <MainSection
      todosCount={todos.length}
      completedCount={todos.filter(t => t.completed).length}
      actions={{
        // Delete todo
        deleteTodo: (id: number) => todosVar(
					todosVar().filter((todo: Todo)) => todo.id !== id
				)
      }}
    />
  );
};

This works, but remember that we should keep logic that determines how state changes outside of both presentation and container components?

How things change belongs to the model layer, more specifically, the interaction layer. This could be a simple function, or a custom React hook that contains all the operations and client-side only models needed for todos. Let’s move this delete logic there instead.

// hooks/useTodos.tsx

import { Todo, Todos } from "../../../models/Todos";
import { ReactiveVar } from "@apollo/client";

export function useTodos (todosVar: ReactiveVar<Todos>) {

  ... // Other todos operations

  const deleteTodo = (id: number) => {
    const allTodos = todosVar();
    const filteredTodos = allTodos.filter((todo: Todo) => todo.id !== id);
    todosVar(filteredTodos);
  }

  return {
    operations: { deleteTodo, ... }
  }
}

And then we can import the custom hook in our container component and pass it to the presentational components.

import React from 'react'
import MainSection from '../components/MainSection'
import { useQuery } from '@apollo/client'
import { todosVar } from '../cache'
import { Todo } from '../models'

export default function Main () { 
  const { deleteTodo } = useTodos(todosVar);
	... 
  return (
    <MainSection
      todosCount={todos.length}
      completedCount={todos.filter(t => t.completed).length}
      actions={{
        deleteTodo
      }}
    />
  );
};

Composable design like this keeps the behavior of the model testable. If we had really complex interaction layer behavior, we could unit test it by writing tests against our custom React hook.

import { useTodos } from "./useTodos";
import { todosVar, visibilityFilterVar } from "../cache";
import { VisibilityFilters } from "../models/VisibilityFilter";

const { operations } = useTodos(todosVar, visibilityFilterVar);

describe('useTodos', () => {
  beforeEach(() => {
    // Reset our reactive variables
    todosVar([]);
    visibilityFilterVar(VisibilityFilters.SHOW_ALL)
  });
  
  it('should add a todo', () => {
    operations.addTodo('First todo')
    expect(
      todosVar()
    ).toHaveLength(1)

    expect(
      todosVar()[0].id
    ).toEqual(0)

    expect(
      todosVar()[0].completed
    ).toEqual(false)

    expect(
      todosVar()[0].text
    ).toEqual('First todo')
  })

  // ...
})

Local state management use cases

We just covered the basics of local state management. It’s likely though that you’re going to encounter other use cases in your app. Here are a few of the most common local state use cases to solve.

Use case #1 — Local only data

This is what we walked through to demonstrate the core functionality. Key things to note:

  • The reactive variable is used as the source of truth.
  • Cache policies call the read function on every field and type before they read from the cache.
  • We can query for local state using the @client directive.
  • It’s recommended to organize logic that would normally be a mutation away from the presentation layer, preferably in a React hook or another form of organizing model logic.

Use case #2 — Separate local and remote data

This is the most common use case to encounter. It’s more often that we are working with remote data, and we just need to supplement it with a little bit of additional data that only exists on the client-side.

Let’s say you were still building the same todo app, but this time, all of the data was sourced from a remote GraphQL API. Our queries would look like this — no @client directive.

import { gql } from "@apollo/client";

export const GET_ALL_TODOS = gql`
  query GetAllTodos {
    todos { 
      id  
      text  
      completed
    }
  }
`

And our client cache policy for the todos type on the root query object would be removed. We wouldn’t need to add any additional config to get this to work.

export const cache: InMemoryCache = new InMemoryCache({ });

Now let’s say you wanted the ability to add dark mode to your site (because who doesn’t love dark mode)? We’d want to set up a reactive variable specifically for that.

We’d do the same thing, adding a cache policy for the darkMode field on the root Query type.

import { InMemoryCache, makeVar } from "@apollo/client";

export const darkModeVar = makeVar<boolean>(false);

export const cache: InMemoryCache = new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        darkMode: {
          read () {
            return darkModeVar();
          }
        }
      }
    }
  }
});

And if we wanted, we could query for both the current darkMode value and the todos at the same time from a container component, remembering to specify that darkMode is a client-only field.

import { gql } from "@apollo/client";

export const GET_TODOS_AND_DARK_MODE = gql`
  query GetTodosAndDarkMode {
    todos { 
      id  
      text  
      completed
    }
    darkMode @client
  }
`

Use case #3 — Locally decorated remote data

Sometimes when we’re working with remote data, it doesn’t contain all the data we need to do our work on the client-side. Sometimes we need to decorate remote data with additional client-side types and fields.

One of the best examples of this is dealing with an isSelected field on a list of todos retrieved from a GraphQL API. How would we go about hooking that up?

Using cache policies, we can define the isSelected field on the Todo type. Let’s set this to false for the time being.

import { InMemoryCache } from "@apollo/client";

export const cache: InMemoryCache = new InMemoryCache({
  typePolicies: {
    Todo: {
      fields: {
        isSelected: {
          read (value, opts) {
            return false;
          }
        }
      }
    }
  }
});

To query for this, we can do:

import { gql } from "@apollo/client";

export const GET_ALL_TODOS = gql`
  query GetAllTodos {
    todos { 
      id  
      text  
      completed
      isSelected @client
    }
  }
`

Now we have to figure out how to determine if a Todo is selected or not.

In the read function for a cache policy, the second argument gives us utility functions we can use to query into the type under question.

One of the most useful utility functions is readField.

Since the read function is called for every single Todo before it’s read from the cache, we can use the readField function to ask for the value of a particular field on the type that’s being referenced.

That’s exactly what we’ll do. First, let’s get the id field of the todo.

import { InMemoryCache } from "@apollo/client";

export const cache: InMemoryCache = new InMemoryCache({
  typePolicies: {
    Todo: {
      fields: {
        isSelected: {
          read (value, { readField }) {
            const todoId = readField('id');
              
            return false;
          }
        }
      }
    }
  }
});

Then let’s create a new reactive variable to hold onto the list of currently selected todo ids.

import { InMemoryCache, makeVar } from "@apollo/client";

export const currentSelectedTodoIds = makeVar<number[]>([]);

export const cache: InMemoryCache = new InMemoryCache({
  typePolicies: {
    Todo: {
      fields: {
        isSelected: {
          read (value, { readField }) {
            const todoId = readField('id');
              
            return isSelected;
          }
        }
      }
    }
  }
});

Finally, we can determine if the current todo is selected by seeing if it’s in the currentSelectedTodoIds reactive variable.

import { InMemoryCache, makeVar } from "@apollo/client";

export const currentSelectedTodoIds = makeVar<number[]>([]);

export const cache: InMemoryCache = new InMemoryCache({
  typePolicies: {
    Todo: {
      fields: {
        isSelected: {
          read (value, { readField }) {
            const todoId = readField('id');
            const isSelected = !!currentSelectedTodoIds()
              .find((id) => id === todoId)
              
            return isSelected;
          }
        }
      }
    }
  }
});

Boom. Now, to toggle a todo as selected or not, we can use the same pattern as before, by putting interaction logic for the operation inside of a React hook.

// hooks/useTodos.tsx

import { Todo, Todos } from "../../../models/Todos";
import { ReactiveVar } from "@apollo/client";

export function useTodos (
  todosVar: ReactiveVar<Todos>,
  selectedTodoIdsVar: ReactiveVar<number[]>
) {

  ... // Other todos operations

  const toggleTodoSelected = (todoId: number) => {
    const allSelectedTodos = selectedTodoIdsVar();
    const found = !!allSelectedTodos.find((t) => t === todoId);

    if (found) {
      selectedTodosVar(allSelectedTodos.filter((t) => t === todoId))
    } else {
      selectedTodosVar(allSelectedTodos.concat(todoId))
    }
  }

  return {
    operations: { toggleTodoSelected, ... }
  }
}

Conclusion

Local state management is an important consideration in building client-side web apps.

In Apollo Client 3, we can use cache policies and reactive variables to configure globally local state to:

  • Add pieces of it to the cache
  • Decorate remote data with local data
  • Build your entire app with local state

And the way we work with local state is an improvement over the local resolvers approach. In AC3, we can:

  • Query for it the same way we query for remote data
  • Change it by importing it anywhere, though we recommend doing this in your interaction layer functions, among your other custom react hooks.

Continue reading

Demystifying Cache Normalization

Written by

Khalil Stemmler

Khalil Stemmler

Read more by Khalil Stemmler