Introduction
Dojo stores provide a predictable, consistent state container with built-in support for common state management patterns.
The Dojo stores package provides a centralized store designed to be the single source of truth for an application. Dojo applications operate using uni-directional data flow; as a result, all application data follows the same lifecycle, ensuring the application logic is predictable and easy to understand.
Feature | Description |
---|---|
Global data store | Application state gets stored globally in a single source of truth. |
Uni-directional data flow | Predictable and global application state management. |
Type-safe | Access and modification of state gets protected by interfaces. |
Operation-driven state changes | Encapsulated, well-defined state modifications that can be recorded, undone and replayed. |
Asynchronous support | Async commands supported out-of-the-box. |
Operational middleware | Before and after operations, error handling, and data transformation. |
Simple widget integration | Tools and patterns for easy integration with Dojo widgets. |
Basic usage
Dojo provides a reactive architecture concerned with constantly modifying and rendering the current state of an application. In simple systems this can happen at a local level and widgets can maintain their own state. However, as a system becomes more complex the need to better compartmentalize and encapsulate data and create a clean separation of concerns quickly grows.
Stores provide a clean interface for storing, modifying, and retrieving data from a global object through unidirectional data flow. Stores include support for common patterns such as asynchronous data retrieval, middleware, and undo. Stores and their patterns allow widgets to focus on their primary role of providing a visual representation of information and listening for user interactions.
The store
The store holds the global, atomic state for the entire application. The store should be created when the application gets created and defined in the Registry
with an injector.
main.ts
import { registerStoreInjector } from '@dojo/framework/stores/StoreInjector';
import Store from '@dojo/framework/stores/Store';
import { State } from './interfaces';
const store = new Store<State>();
const registry = registerStoreInjector(store);
State
defines the structure of the global store using an interface. Everything inside State
should be serializable, i.e. convertible to/from JSON, as this improves performance by making it easier for the Dojo virtual DOM system to determine when changes to the data occur.
interfaces.d.ts
interface User {
id: string;
name: string;
}
export interface State {
auth: {
token: string;
};
users: {
current: User;
list: User[];
};
}
The above is a simple example that defines the structure for the store used in the rest of the examples in this guide.
Updating stores
There are three core concepts when working with Dojo stores.
- Operations - instructions to manipulate the state held by the store
- Commands - simple functions that perform business logic and return operations
- Processes - execute a group of commands and represent application behavior
Commands and operations
To modify a store, when executing a process, a command function gets invoked. The command function returns a list of operations to apply to the store.. Each command is passed a CommandRequest
which provides path
and at
functions to generate Path
s in a type-safe way, a get
function for access to the store's state, a payload
object for the argument that the process executor was called with.
Command factory
Stores have a simple wrapper function that acts as a type-safe factory for creating new commands.
To create a store factory:
import { createCommandFactory } from '@dojo/framework/stores/process';
import { State } from './interfaces';
const createCommand = createCommandFactory<State>();
const myCommand = createCommand(({ at, get, path, payload, state }) => {
return [];
});
createCommand
ensures that the wrapped command has the correct typing and the passed CommandRequest
functions get typed to the State
interface provided to createCommandFactory
. While it is possible to manually type commands, the examples in this guide use createCommand
.
path
The path is a string
that describes the location where an operation gets applied. The path
function is part of the CommandRequest
and is accessible inside of a Command
.
In this example the path
describes a location in the store. The State
is the same as defined above in interface.d.ts
. The State
interface gets used by the Store
to understand the shape of the state data.
To define a path
for the current user name:
const store = new Store<State>();
const { path } = store;
path('users', 'current', 'name');
This path refers to the string
value located at /users/current/name
. path
gets used to transverse the hierarchy in a type-safe way, ensuring that only the property names defined in the State
interface get used.
at
The at
function gets used in conjunction with path
to identify a location in an array. This example leverages the at
function.
const store = new Store<State>();
const { at, path } = store;
at(path('users', 'list'), 1);
This path refers to the User
located at /users/list
at offset 1
.
add
operation
Adds a value to an object or inserts it into an array.
import { createCommandFactory } from '@dojo/framework/stores/process';
import { State } from './interfaces';
import { add } from '@dojo/framework/stores/state/operations';
const createCommand = createCommandFactory<State>();
const myCommand = createCommand(({ at, get, path, payload, state }) => {
const user = { id: '0', name: 'Paul' };
return [add(at(path('users', 'list'), 0), user)];
});
This adds user
to the beginning of the user list.
remove
operation
Removes a value from an object or an array.
import { createCommandFactory } from '@dojo/framework/stores/process';
import { State } from './interfaces';
import { add, remove } from '@dojo/framework/stores/state/operations';
const createCommand = createCommandFactory<State>();
const myCommand = createCommand(({ at, get, path, payload, state }) => {
const user = { id: '0', name: 'Paul' };
return [
add(path('users'), {
current: user,
list: [user]
}),
remove(at(path('users', 'list'), 0))
];
});
This example adds an initial state for users
and removes the first user
in the list.
replace
operation
Replaces a value. Equivalent to a remove
followed by an add
.
import { createCommandFactory } from '@dojo/framework/stores/process';
import { State } from './interfaces';
import { add, replace } from '@dojo/framework/stores/state/operations';
const createCommand = createCommandFactory<State>();
const myCommand = createCommand(({ at, get, path, payload, state }) => {
const users = [{ id: '0', name: 'Paul' }, { id: '1', name: 'Michael' }];
const newUser = { id: '2', name: 'Shannon' };
return [
add(path('users'), {
current: user[0],
list: users
}),
replace(at(path('users', 'list'), 1), newUser)
];
});
This example replaces the second user in the list
with newUser
.
get
The get
function returns a value from the store at a specified path or undefined
if a value does not exist at that location.
import { createCommandFactory } from '@dojo/framework/stores/process';
import { State } from './interfaces';
import { remove, replace } from '@dojo/framework/stores/state/operations';
const createCommand = createCommandFactory<State>();
const updateCurrentUser = createCommand(async ({ at, get, path }) => {
const token = get(path('auth', 'token'));
if (!token) {
return [remove(path('users', 'current'))];
} else {
const user = await fetchCurrentUser(token);
return [replace(path('users', 'current'), user)];
}
});
This example checks for the presence of an authentication token and works to update the current user information.
payload
The payload
is an object literal passed into a command when it is called from a process. The payload
's type may be defined when constructing the command.
import { createCommandFactory } from '@dojo/framework/stores/process';
import { State } from './interfaces';
import { remove, replace } from '@dojo/framework/stores/state/operations';
const createCommand = createCommandFactory<State>();
const addUser = createCommand<User>(({ at, path, payload }) => {
return [add(at(path('users', 'list'), 0), payload)];
});
This example adds the user provided in the payload
to the beginning of /users/list
.
Asynchronous commands
Commands can be synchronous or asynchronous. Asynchronous commands should return a Promise
to indicate when they finish. Operations are collected and applied atomically after each command completes successfully.
Processes
A Process
is a construct used to sequentially execute commands against a store
in order to makes changes to the application state. Processes are created using the createProcess
factory function that accepts a list of commands and optionally a list of middleware.
Creating a process
First, create a couple commands responsible for obtaining a user token and use that token to load a User
. Then create a process that uses those commands. Every process must be identified by a unique process ID. This ID is used internally in the store.
import { createCommandFactory, createProcess } from "@dojo/framework/stores/process";
import { State } from './interfaces';
import { add, replace } from "@dojo/framework/stores/state/operations";
const createCommand = createCommandFactory<State>();
const fetchUser = createCommand(async ({ at, get, payload: { username, password } }) => {
const token = await fetchToken(username, password);
return [
add(path('auth', 'token'), token);
];
}
const loadUserData = createCommand(async ({ path }) => {
const token = get(path('auth', 'token'));
const user = await fetchCurrentUser(token);
return [
replace(path('users', 'current'), user)
];
});
export const login = createProcess('login', [ fetchUser, loadUserData ]);
payload
type
The process executor's payload
is inferred from the payload
type of the commands. If the payloads differ then it is necessary to explicitly define the payload
type.
const createCommand = createCommandFactory<State>();
const commandOne = createCommand<{ one: string }>(({ payload }) => {
return [];
});
const commandTwo = createCommand<{ two: string }>(({ payload }) => {
return [];
});
const process = createProcess<State, { one: string; two: string }>('example', [commandOne, commandTwo]);
process(store)({ one: 'one', two: 'two' });
Connecting widgets and stores
There are two state containers available for widgets: StoreContainer
and StoreProvider
. These containers connect the application store with a widget. When using functional widgets, a typed store middleware can also be created.
Note that the documentation in this section is intended to show how widgets and state (provided by a store) are connected. For more information on widget state management in general, see the Creating Widgets reference guide.
Store middleware
When using function-based widgets, the createStoreMiddleware
helper can be used to create a typed store middleware that provides a widget access to the store.
middleware/store.ts
import createStoreMiddleware from '@dojo/framework/core/middleware/store';
import { State } from '../interfaces';
export default createStoreMiddleware<State>();
widgets/User.tsx
import { create } from '@dojo/framework/core/vdom';
import store from '../middleware/store';
import { State } from '../../interfaces';
const factory = create({ store }).properties();
export const User = factory(function User({ middleware: { store } }) {
const { get, path } = store;
const name = get(path('users', 'current', 'name'));
return <h1>{`Hello, ${name}`}</h1>;
});
This middleware contains an executor
method that can be used to run processes on the store.
import { create } from '@dojo/framework/core/vdom';
import store from '../middleware/store';
import logout from '../processes/logout';
import { State } from '../../interfaces';
const factory = create({ store }).properties();
export const User = factory(function User({ middleware: { store } }) {
const { get, path } = store;
const name = get(path('users', 'current', 'name'));
const onLogOut = () => {
store.executor(logout)({});
};
return (
<h1>
{`Hello, ${name}`}
<button onclick={onLogOut}>Log Out</button>
</h1>
);
});
StoreProvider
A StoreProvider
is a Dojo widget that has its own renderer
and connects to the store. It is always encapsulated in another widget because it does not define its own properties.
widget/User.ts
import { create } from '@dojo/framework/core/vdom';
import { State } from '../../interfaces';
const factory = create().properties();
export const User = factory(function User() {
return (
<StoreProvider
stateKey="state"
paths={(path) => [path('users', 'current')]}
renderer={(store) => {
const { get, path } = store;
const name = get(path('users', 'current', 'name'));
return <h1>{`Hello, ${name}`}</h1>;
}}
/>
);
});
The StoreProvider
occurs as part of User
's render and provides its own renderer
like any other Dojo widget.
Container
A Container
is a widget that fully encapsulates another widget. It connects the store to the widget with a getProperties
function.
widget/User.tsx
import { create, tsx } from '@dojo/framework/core/vdom';
interface UserProperties {
name?: string;
}
const factory = create().properties<UserProperties>();
export const User = factory(function User({ properties }) {
const { name = 'Stranger' } = properties();
return <h1>{`Hello, ${name}`}</h1>;
});
widget/User.container.ts
import { createStoreContainer } from '@dojo/framework/stores/StoreContainer';
import { State } from '../interfaces';
import User from './User';
const StoreContainer = createStoreContainer<State>();
const UserContainer = StoreContainer(User, 'state', {
getProperties({ get, path }) {
const name = get(path('user', 'current', 'name'));
return { name };
}
});
In this example UserContainer
wraps User
to display the current user's name. createStoreContainer
is a wrapper that gets used to ensure the proper typing of getProperties
. getProperties
is responsible for accessing data from the store and creating properties for the widget.
A StoreContainer
's properties are a 1:1 mapping to the widget it wraps. The widget's properties become the properties of the StoreContainer
, but they are all optional.
Executing a process
A process simply defines an execution flow for a set of data. To execute a process, the process needs access to the store to create an executor. Both the StoreContainer
and StoreProvider
widgets provide access to the store.
import { logout } from './processes/logout';
import StoreProvider from '@dojo/framework/stores/StoreProvider';
import { State } from '../../interfaces';
import User from './User';
import { create, tsx } from '@dojo/framework/core/vdom';
const factory = create().properties();
export const UserProvider = factory(function UserProvider() {
return (
<StoreProvider
stateKey="state"
paths={(path) => [path('users', 'current')]}
renderer={(store) => {
const { get, path } = store;
const name = get(path('users', 'current', 'name'));
const onLogOut = () => {
logout(store)({});
};
return <User name={name} onLogOut={onLogOut} />;
}}
/>
);
});