dojo dragon main logo

Common state management patterns

Initial state

When a store is first created, it will be empty. A process can then be used to populate the store with initial application state.

main.ts

const store = new Store<State>();
const { path } = store;

const createCommand = createCommandFactory<State>();

const initialStateCommand = createCommand(({ path }) => {
    return [add(path('auth'), { token: undefined }), add(path('users'), { list: [] })];
});

const initialStateProcess = createProcess('initial', [initialStateCommand]);

initialStateProcess(store)({});

Undo

Dojo Stores track changes to the underlying store using patch operations. This makes it easy for Dojo to automatically create a set of operations to undo a set of operations and reinstate any data that has changed as part of a set of commands. The undoOperations are available in the after middleware as part of the ProcessResult.

Undo operations are useful when a process involves several commands that alter the state of the store and one of the commands fails, necessitating a rollback.

undo middleware

const undoOnFailure = () => {
    return {
        after: (error, result) => {
            if (error) {
                result.store.apply(result.undoOperations);
            }
        }
    };
};

const process = createProcess('do-something', [command1, command2, command3], [undoOnFailure]);

If any of the commands fail during their execution the undoOnFailure middleware will have an opportunity to apply undoOperations.

It is important to note that undoOperations only apply to the commands fully executed during the process. It will not contain any operations to rollback state that changed as a result of other processes that may get executed asynchronously or state changes performed in middleware or directly on the store itself. These use cases are outside the scope of the undo system.

Optimistic updates

Optimistic updating can be used to build a responsive UI despite interactions that might take some time to respond, for example saving to a remote resource.

For example, in the case of adding a todo item, with optimistic updating a todo item can be immediately added to a store before a request is made to persist the object on the server, avoiding an unnatural waiting period or loading indicator. When the server responds, the todo item in the store can then get reconciled based on whether the outcome of the server operation was successful or not.

In the success scenario, the added Todo item can be updated with an id provided in the server response, and the color of the Todo item can be changed to green to indicate it was successfully saved.

In the error scenario, a notification could be shown to say the request failed and the Todo item color can be changed to red, together with showing a "retry" button. It's even possible to revert/undo the adding of the Todo item or anything else that happened during the process.

const handleAddTodoErrorProcess = createProcess('error', [ () => [ add(path('failed'), true) ]; ]);

const addTodoErrorMiddleware = () => {
    return {
        after: () => (error, result) {
            if (error) {
                result.store.apply(result.undoOperations);
                result.executor(handleAddTodoErrorProcess);
            }
        }
    };
};

const addTodoProcess = createProcess('add-todo', [
        addTodoCommand,
        calculateCountsCommand,
        postTodoCommand,
        calculateCountsCommand
    ],
    [ addTodoCallback ]);
  • addTodoCommand - adds the new todo into the application state
  • calculateCountsCommand - recalculates the count of completed and active todo items
  • postTodoCommand - posts the todo item to a remote service and, using the process after middleware, further changes can be made if a failure occurs
    • on failure the changes get reverted and the failed state field gets set to true
    • on success update the todo item id field with the value received from the remote service
  • calculateCountsCommand - runs again after the success of postTodoCommand

Synchronized updates

In some cases it is better to wait for a back-end call to complete before continuing on with process execution. For example, when a process will remove an element from the screen or when the outlet changes to display a different view, restoring a state that triggered these actions can be surprising.

Because processes support asynchronous commands, simply return a Promise to wait for a result.

function byId(id: string) {
    return (item: any) => id === item.id;
}

async function deleteTodoCommand({ get, payload: { id } }: CommandRequest) {
    const { todo, index } = find(get('/todos'), byId(id));
    await fetch(`/todo/${todo.id}`, { method: 'DELETE' });
    return [remove(path('todos', index))];
}

const deleteTodoProcess = createProcess('delete', [deleteTodoCommand, calculateCountsCommand]);

Concurrent commands

A Process supports concurrent execution of multiple commands by specifying the commands in an array.

process.ts

createProcess('my-process', [commandLeft, [concurrentCommandOne, concurrentCommandTwo], commandRight]);

In this example, commandLeft gets executed, then both concurrentCommandOne and concurrentCommandTwo get executed concurrently. Once all of the concurrent commands are completed the results get applied in order. If an error occurs in either of the concurrent commands then none of the operations get applied. Finally, commandRight gets executed.

Alternative state implementations

When a store gets instantiated an implementation of the MutableState interface gets used by default. In most circumstances the default state interface is well optimized and sufficient to use for the general case. If a particular use case merits an alternative implementation, the implementation can be passed in during initialization.

const store = new Store({ state: myStateImpl });

MutableState API

Any State implementation must provide four methods to properly apply operations to the state.

  • get<S>(path: Path<M, S>): S takes a Path object and returns the value in the current state at the provided path
  • at<S extends Path<M, Array<any>>>(path: S, index: number): Path<M, S['value'][0]> returns a Path object that points to the provided index in the array at the provided path
  • path: StatePaths<M> is a type-safe way to generate a Path object for a given path in the state
  • apply(operations: PatchOperation<T>[]): PatchOperation<T>[] applies the provided operations to the current state

ImmutableState

Dojo Stores provide an implementation of the MutableState interface that leverages Immutable. This implementation may provide better performance if there are frequent, deep updates to the store's state. Performance should be tested and verified before fully committing to this implementation.

Using Immutable

import State from './interfaces';
import Store from '@dojo/framework/stores/Store';
import Registry from '@dojo/framework/widget-core/Registry';
import ImmutableState from '@dojo/framework/stores/state/ImmutableState';

const registry = new Registry();
const customStore = new ImmutableState<State>();
const store = new Store<State>({ store: customStore });

Local storage

Dojo Stores provides a collection of tools to leverage local storage.

The local storage middleware watches specified paths for changes and stores them locally on disk using the id provided to the collector and structure as defined by the path.

Using local storage middleware:

export const myProcess = createProcess(
    'my-process',
    [command],
    collector('my-process', (path) => {
        return [path('state', 'to', 'save'), path('other', 'state', 'to', 'save')];
    })
);

The load function hydrates a store from LocalStorage

To hydrate state:

import { load } from '@dojo/framework/stores/middleware/localStorage';
import { Store } from '@dojo/framework/stores/Store';

const store = new Store();
load('my-process', store);

Note that data is serialized for storage and the data gets overwritten after each process call. This implementation is not appropriate for non-serializable data (e.g. Date and ArrayBuffer).