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 statecalculateCountsCommand
- recalculates the count of completed and active todo itemspostTodoCommand
- posts the todo item to a remote service and, using the processafter
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 ofpostTodoCommand
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
implemention must provide four methods to properly apply operations to the state.
get<S>(path: Path<M, S>): S
takes aPath
object and returns the value in the current state at the provided pathat<S extends Path<M, Array<any>>>(path: S, index: number): Path<M, S['value'][0]>
returns aPath
object that points to the providedindex
in the array at the provided pathpath: StatePaths<M>
is a type-safe way to generate aPath
object for a given path in the stateapply(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
).