Separating state from presentation using state providers
August 2023, by Maarten Nieber
Managing resource states

The ResourceState class

A resource state is a value that indicates whether the resource is loading or updating. Since tracking resource states is not trival, it's a good idea to only track the resources that require it. We'll return to this idea in the next chapter.

Internal and external resource states

We can store a resource state externally (in some lookup table) or internally in the resource itself. I prefer to store it internally for several reasons:

  • It reduces the amount of bookkeeping: if the resource is deleted then there is no need to remove its resource state from the external storage;
  • It removes the need for a globally unique id that we can use to store the resource state for a resource;
  • I find it less elegant to make React components dependent on a global storage of resource states.

To add a resource state, I'm using a initRS(resource) function. This function adds the internal resource state if the resource doesn't have one:

resourceStates/ResourceState.ts
export const symbolRS = Symbol('ResourceState');

export const initRS = (resource: any) => {
  if (resource && !resource[symbolRS]) {
    resource[symbolRS] = new ResourceState();
  }
  return resource;
};

Adding sources to a ResourceState

Instead of storing a value such loading or updating in the ResourceState class, I'm storing a list of sources from which the final state is derived. This approach makes it possible to construct a pipeline of resource states. For example, the state of a todo resource can be derived from the state of a todolist resource, that can be derived from a todolists resource. By using such a pipeline it's easier to ensure that the final resource state is correct, and not based on stale data.

Here is the source code for the ResourceState class:

resourceStates/ResourceState.ts
export type ConditionT = () => boolean | undefined;

export type SourceT = {
  state: string;
  condition: ConditionT;
  name: string;
};

export class ResourceState {
  @observable name?: string = undefined;
  @observable sources: SourceT[] = [];

  @action addSource(source: SourceT) { // Implementation omitted for brevity }
  @action removeSource(state: string, name: string) { // Implementation omitted for brevity }

  @computed get value() {
    return this.sources
      .filter((source: SourceT) => source.condition())
      .map((source: SourceT) => source.state);
  }

  constructor() { makeObservable(this); }
}

As we can see, the value of a ResourceState is an array that contains all resource states of all sources for which source.condition() returns true.

The updateSources function

The updateSources function is a helper function that is used as follows:

example.ts
const resources = updateSources(
  { resource: myTodolist },
  ['loading', () => isQueryLoading(getTodolists), 'getTodolists'],
)

This function does the following:

  • it calls initRS on the resource (in this case: myTodolist) to create a resource state;
  • it adds sources to the resource state. In this case, it "states" that the resource is loading if the getTodolists query is loading.

The null resource state

While a resource is loading, we don't have it. Therefore, we cannot access the resource state via the resource instance. Instead, I use null to represent a loading resource. When a React component receives a resource, it can be either null (which means that the resource is loading), or some object (that has a resource state), or undefined (which means that the resource is unavailable, but not in a loading state).

Here is the code for getting the resource state of a resource:

resourceStates/ResourceState.ts
import { symbolRS } from '/src/resourceStates/ResourceState';

export const loadingResource = null;

export const getState = <StateT = string>(
  resource: any
): StateT[] => {
  return resource === undefined
    ? []
    : resource === loadingResource
    ? ['loading']
    : resource[symbolRS].value;
};

export const isUpdating = (resource: any) => getState(resource).includes('updating');
export const isLoading = (resource: any) => getState(resource).includes('loading');

The trackPromise function

It often happens that the resource state is updating while some promise is being executed. For example, while we are running deleteTodolists, the resource state of the affected todolists resources should be updating. The trackPromise function helps to manage this. While the promise is running, it attaches a source to the resource state of the affected resources. This source effectively puts these todolists in the updating state. When the promise is resolved, the source is removed.

resourceStates/ResourceState.ts
const todolistsToBeDeleted = [todolist1, todolist2];

const promise = trackPromise({
  promise: deleteTodoLists.mutateAsync({
    todolistIds: getIds(todolistsToBeDeleted),
  }),
  // Add 'updating' to the resource state of the
  // todolists that are being deleted
  states: { updating: [todolistsToBeDeleted] },
}).result;

The graftResourceStatesFromMemo function

When you store resource states inside the resource objects themselves, there is a potential problem when fresh data is fetched from the server. If you replace the local resource with the newly fetched one then the local resource state is lost. This is a problem when you are locally updating a resource, for example: when the resource is being deleted. To avoid this, we should move the resource state from the old resource to the new resource. This is what the graftResourceStatesFromMemo function does. It's used as follows:

resourceStates/ResourceState.ts
// We're using React.useState to locally store the todolists.
// This is not what  I would use in a real application,
// but for illustration purposes it's good enough.
const [todolists, setTodolists] = React.useState<TodolistT[]>([]);
const graftResourceStatesFromMemo = useGraftResourceStatesFromMemo({});

React.useEffect(() => {
  getTodolists().then(todolists => {
    setTodolists(graftResourceStatesFromMemo({
      resources: todolists
    });
  });
})

When you pass an array of resources to the graftResourceStatesFromMemo function then it stores a memo of the resource states. The next time you call graftResourceStatesFromMemo it will copy the resource states from the memo to the new resources. This way, the resource states are preserved when the resources are replaced.

What's next

In the next chapter we'll look at how we can observe the state of queries and mutations. This is necessary since resource states usually depend on which queries and mutations are currently running.