State management
Overview
Modern web applications are often required to manage complex state models which can involve fetching data from a remote service or multiple widgets requiring the same slices of state. While Dojo’s widgets can manage application state, encapsulation and a clean separation of concerns may be lost if widgets manage their own visual representations, listen for interactions from the user, manage their children, and keep track of state information. Additionally, using widgets to pass state through an application often forces the widgets to be aware of state information for the sole purpose of passing that data down to their children. To allow widgets to remain focused on their primary roles of providing a visual representation of themselves and listening for user interactions, Dojo provides a mechanism using the Registry
and Container
classes, that is designed to coordinate an application’s external state and connect and map this state to properties.
In this tutorial, we will start with an application that is managing its state in the widgets themselves. We will then extract all of the state-related code out of the widgets and inject external state as properties only into widgets as is needed.
You can open the tutorial on codesandbox.io or download the demo project and run npm install
to get started.
Prerequisites
This tutorial assumes that you have gone through the beginner tutorial series.
Creating an application context
Create a class to manage application state.
To begin our tutorial, let’s review the initial version of App
:
import { WidgetBase } from '@dojo/framework/widget-core/WidgetBase';
import { v, w } from '@dojo/framework/widget-core/d';
import Banner from './Banner';
import WorkerForm, { WorkerFormData } from './WorkerForm';
import { WorkerProperties } from './Worker';
import WorkerContainer from './WorkerContainer';
export default class App extends WidgetBase {
private _newWorker: Partial<WorkerFormData> = {};
private _workerData: WorkerProperties[] = [
{
firstName: 'Tim',
lastName: 'Jones',
email: '[email protected]',
tasks: [
'6267 - Untangle paperclips',
'4384 - Shred documents',
'9663 - Digitize 1985 archive'
]
},
{
firstName: 'Alicia',
lastName: 'Fitzgerald'
},
{
firstName: 'Hans',
lastName: 'Mueller'
}
];
private _addWorker() {
this._workerData = this._workerData.concat(this._newWorker);
this._newWorker = {};
this.invalidate();
}
private _onFormInput(data: Partial<WorkerFormData>) {
this._newWorker = {
...this._newWorker,
...data
};
this.invalidate();
}
protected render() {
return v('div', [
w(Banner, {}),
w(WorkerForm, {
formData: this._newWorker,
onFormInput: this._onFormInput,
onFormSave: this._addWorker
}),
w(WorkerContainer, {
workerData: this._workerData
})
]);
}
}
Most of this widget is dedicated to holding and managing the WorkerData
in the application. Notice, however, that it never actually uses that data itself. App
is only containing the state and passing it to the children as required via properties. Lifting up state to the highest common widget in the tree is a valid pattern, but as an application’s state grows in size and complexity, it is often desirable to decouple this from widgets. In larger applications, the App
class would become complicated and more difficult to maintain due to the additional state information that it would be required to track. Since the state information is not a primary concern of the App
class, let’s refactor it out of App
and into a new ApplicationContext
class that extends the base Injector
.
Add the following to the existing ApplicationContext.ts
file in the src
directory
import { deepAssign } from '@dojo/framework/core/util';
import { WorkerProperties } from './widgets/Worker';
import { WorkerFormData } from './widgets/WorkerForm';
export default class ApplicationContext {
private _workerData: WorkerProperties[];
private _formData: Partial<WorkerFormData> = {};
private _invalidator: () => void;
constructor(invalidator: () => void, workerData: WorkerProperties[] = []) {
this._workerData = workerData;
this._invalidator = invalidator;
}
get workerData(): WorkerProperties[] {
return this._workerData;
}
get formData(): Partial<WorkerFormData> {
return this._formData;
}
public formInput(input: Partial<WorkerFormData>): void {
this._formData = deepAssign({}, this._formData, input);
this._invalidator();
}
public submitForm(): void {
this._workerData = [ ...this._workerData, this._formData ];
this._formData = {};
this._invalidator();
}
}
Invalidations
Dojo Widgets can invoke invalidate()
directly, however, injector factories receive an invalidator
that can be called to ensure that all connected widgets are invalidated
The code begins by importing some modules, including the WorkerProperties
and WorkerFormData
interfaces defined in the Worker
and WorkerForm
modules. These two interfaces define the shape of state that the ApplicationContext
manages.
The ApplicationContext
contains the application state information. The constructor accepts two parameters, an invalidator
that is called when the internal state changes and the initial state.
ApplicationContext
also has two private fields, _workerData
and _formData
, which contain the state, and two accessor methods to retrieve these fields.
public formInput(input: Partial<WorkerFormData>): void {
this._formData = deepAssign({}, this._formData, input);
this._invalidator();
}
public submitForm(): void {
this._workerData = [ ...this._workerData, this._formData ];
this._formData = {};
this._invalidator();
The formInput
method provides the same functionality as the _onFormInput
method in the App
class and the submitForm
method is analogous to the _addWorker
method from the App
class. The implementations vary slightly as the ApplicationContext
has dedicated fields to store the state information. Also, since the ApplicationContext
is not a widget, it cannot call invalidate();
to schedule a re-render. Instead the instance needs to call the invalidator
function passed in and stored on construction.
Notice that the ApplicationContext
does not contain any code to load state information. Currently its only role is only to manage the application’s state provided on initialization via its constructor
. However as the requirements for the application become more advanced, the ApplicationContext
could make requests to fetch and modify data from a remote service or local storage mechanism.
Now that we have moved state management to a dedicated module, we need a way to register the state and connect it to sections of our application. We will do this by creating a registry and registering the ApplicationContext
injector.
Injectors
Register an injector factory that will allow state to be injected into widgets.
Currently, the application’s main
module is only responsible for creating the Projector
, which provides the bridge between the application code and the DOM.
import renderer from '@dojo/framework/widget-core/vdom';
import { w } from '@dojo/framework/widget-core/d';
import App from './widgets/App';
const r = renderer(() => w(App, {}));
r.mount({ domNode: document.querySelector('my-app') as HTMLElement });
Now, we need to:
- Create a
registry
and then define an injector factory that creates theApplicationContext
passing the invalidator and initial state. The injector factory returns a function that returns theApplicationContext
instance. - To make the
registry
available within the widget tree, we need to pass theregistry
as a property to theprojector
Import theApplicationContext
module and add this code to themain
module:
import ApplicationContext from './ApplicationContext';
Loading data
In a real-world application, this data would probably be loaded via a call to a web service or a local data store. To learn more, take a look at the stores tutorial.
The state stored in the ApplicationContext
is the same data that was used in the previous version of the App
module to initialize the WorkerProperties
, but it is now decoupled into an isolated module that helps to understand and maintain the application. In general, the main
module of an application should be concerned with initializing application-wide state. Also, as previously mentioned, the App
class only needed to manage the WorkerProperties
state so that it could coordinate change to its children.
Now we need to create the registry, create the injector factory that creates and returns the ApplicationContext
instance injector, and finally make the registry available to the widget tree.
Add the Registry
import to the main
module.
import { Registry } from '@dojo/framework/widget-core/Registry';
Now, create an injector factory that creates and returns the application context
const registry = new Registry();
registry.defineInjector('app-state', (invalidator) => {
const applicationContext = new ApplicationContext(invalidator, [
{
firstName: 'Tim',
lastName: 'Jones',
email: '[email protected]',
tasks: [
'6267 - Untangle paperclips',
'4384 - Shred documents',
'9663 - Digitize 1985 archive'
]
},
{
firstName: 'Alicia',
lastName: 'Fitzgerald'
},
{
firstName: 'Hans',
lastName: 'Mueller'
}
]);
return () => applicationContext;
});
Registry
The registry provides a way to register a widget via a label, making it accessible to other parts of the application. You can learn more in the registry tutorial.
The first statement creates a registry
where the application context can be registered. The second statement registers an injector factory that creates the ApplicationContext
instance passing in the invalidator
function passed to the factory. The factory creates an injector function that returns the created ApplicationContext
instance.
Pass the registry to the renderer when mounting
We need to pass the registry
to the renderer
as an option on the mount
function. This ensures that the registry
instance is available for all widget and container instances.
r.mount({ domNode: document.querySelector('my-app') as HTMLElement, registry });
Now that the ApplicationContext
injector factory is defined, and the registry
gets set on the projector
, it is time to create the components that will use it. In the next section, we will create a non-visual widget called a Container
that will allow injecting state into the WorkerForm
and WorkerContainer
widgets.
Creating state containers
Create Containers
that will allow state to be injected into widgets
On their own, the injector factories defined on the registry
are not able to help us very much because widgets expect state to be passed to them via properties. Therefore an injector
must be connected to interested widgets in order for their state to be mapped to properties
that widgets can consume by using a Container
. Containers
are designed to coordinate the injection - they connect injectors
to widgets and return properties
from the injector
‘s state which are passed to the connected widgets.
Normally, a separate Container
is created for each widget that needs to have properties
injected. In the demo application, we have two widgets that rely on application state - WorkerContainer
and WorkerForm
.
Let’s start with the WorkerContainer
. As a best practice, you should give your containers the same name as their respective widgets, with a Container
suffix.
E.g. Widget name: Foo
container name ‘FooContainer’. To keep things organized, they are also stored in a different directory - containers
.
Add the following imports to the WorkerContainerContainer
in the containers
directory
import { Container } from '@dojo/framework/widget-core/Container';
import ApplicationContext from './../ApplicationContext';
import WorkerContainer, { WorkerContainerProperties } from './../widgets/WorkerContainer';
- The first
import
gives the module access to theContainer
factory function which will be used to construct the container. - The second
import
allows the module to use theApplicationContext
to extract state - The third import enables the
WorkerContainerProperties
to receive properties from its parent, and wrap theWorkerContainer
class with thecontainer
.
Next, we need to address the fact that the container has two places to get properties from - its parent widget and theApplicationContext
. To tell the container how to manage this, we will create a function calledgetProperties
.
Add the getProperties
function to the WorkerContainerContainer
module.
function getProperties(inject: ApplicationContext, properties: any) {
return { workerData: inject.workerData };
}
The getProperties
function receives two parameters. The first is the payload
of the injector
instance returned by the injector
function returned by the registered factory. The second is the properties
that have been passed to the container via the normal mechanism, w(Container, properties)
. The properties will implement the properties interface defined by the wrapped widget (for example WorkerContainerProperties
). The getProperties
function must then return an object that holds the properties that will be passed to the widget itself. In this example, we are ignoring the properties provided by the parent and returning the workerData
stored by the ApplicationContext
. More advanced use cases where both sources are used to generate the properties are also possible.
Finish the WorkerContainerContainer
by adding the following code.
const WorkerContainerContainer = Container(WorkerContainer, 'app-state', { getProperties });
export default WorkerContainerContainer;
These final lines define the actual WorkerContainerContainer
class and exports it. The Container
function creates the class by accepting three parameters:
- The widget’s class definition (alternatively, a widget’s registry key can be used)
- The registry key for the
Injector
- An object literal that provides the mapping functions used to reconcile the two sets of properties and children that the container can receive (one from the
Injector
and one from the parent widget). The returned class is also a widget as it descends fromWidgetBase
and therefore may be used just like any other widget.
The other container that we need is theWorkerFormContainer
.
Add the following code to the WorkerFormContainer
module in the containers
sub-package.
import { Container } from '@dojo/framework/widget-core/Container';
import ApplicationContext from './../ApplicationContext';
import WorkerForm, { WorkerFormProperties } from './../widgets/WorkerForm';
function getProperties(inject: ApplicationContext, properties: any) {
const {
formData,
formInput: onFormInput,
submitForm: onFormSave
} = inject;
return {
formData,
onFormInput: onFormInput.bind(inject),
onFormSave: onFormSave.bind(inject)
};
}
const WorkerFormContainer = Container(WorkerForm, 'app-state', { getProperties });
export default WorkerFormContainer;
This module is almost identical to the WorkerContainerContainer
except for additional properties that are required by the WorkerForm
to allow it to respond to user interactions with the form. The ApplicationContext
contains two methods for managing these events - onFormInput
and onFormSave
. These methods need to be passed into the WorkerForm
to handle the events, but they need to execute in the context of the ApplicationContext
. To handle this, bind
is called on each of the methods to explicitly set their execution contexts.
At this point, we have created the ApplicationContext
to manage state, an ApplicationContext
injector factory to inject state into the application’s widgets, and Containers
to manage how properties and children from the injector and parent widgets are combined. In the next section, we will integrate these components into our application.
Using state containers
Integrate containers into an application.
As mentioned in the previous section, Container
is a higher order component that extends WidgetBase
and returns the wrapped widget and injected properties
from the render
. As such, it can be used just like any other widget. In our demo application, we can take advantage of its extension of WidgetBase
by simply replacing the WorkerForm
and WorkerContainer
with their container equivalents.
Replace the imports in the App
module with the following.
import { WidgetBase } from '@dojo/framework/widget-core/WidgetBase';
import { v, w } from '@dojo/framework/widget-core/d';
import Banner from './Banner';
import WorkerContainerContainer from './../containers/WorkerContainerContainer';
import WorkerFormContainer from './../containers/WorkerFormContainer';
There are two major changes to the App
module’s imports. First, the widgets (WorkerForm
and WorkerContainer
) have been replaced by their container equivalents (WorkerFormContainer
and WorkerContainerContainer
). Second, all of the interfaces, WorkerFormData
, and WorkerProperties
have been removed. These are no longer needed since the App
class no longer needs to manage state.
Also, the property and methods within App
that are setting and managing state can be removed.
Remove the following code from the App
class.
private _newWorker: Partial<WorkerFormData> = {};
private _workerData: WorkerProperties[] = [
{
firstName: 'Tim',
lastName: 'Jones',
email: '[email protected]',
tasks: [
'6267 - Untangle paperclips',
'4384 - Shred documents',
'9663 - Digitize 1985 archive'
]
},
{
firstName: 'Alicia',
lastName: 'Fitzgerald'
},
{
firstName: 'Hans',
lastName: 'Mueller'
}
];
private _addWorker() {
this._workerData = this._workerData.concat(this._newWorker);
this._newWorker = {};
this.invalidate();
}
private _onFormInput(data: Partial<WorkerFormData>) {
this._newWorker = {
...this._newWorker,
...data
};
this.invalidate();
}
The final change to App
is to update the render
method to use the containers. Since the containers already know how to manage their state and respond to events, no properties need to be passed directly to the Container
by the App
widget.
Replace the render
method with the following code.
protected render() {
return v('div', [
w(Banner, {}),
w(WorkerFormContainer, {}),
w(WorkerContainerContainer, {})
]);
}
With this last change, the App
class is now only nine lines of code. All of the state management logic is still part of the application, but it has been refactored out of the App
class to create a more efficient application architecture.
Notice that the WorkerForm
and WorkerContainer
widgets were not changed at all! This is an important thing to keep in mind when designing widgets - a widget should never be tightly coupled to the source of its properties. By keeping the containers and widgets separate, we have helped to ensure that each widget or container has a narrowly defined set of responsibilities, creating a cleaner separation of concerns within our widgets and containers.
At this point, you should reload your page and verify the application is working.
Summary
Since Dojo widgets are TypeScript classes, they are capable of filling a large number of roles, including state management. With complex widgets, however, combining the responsibilities to manage the widget’s visual representation as well as the state of its children can make them difficult to manage and test. Dojo defines the Registry
and Container
classes as a way to externalize state management from the app and centralize that management into mechanisms that are designed specifically to fill that role.
If you would like, you can open the completed demo application on codesandbox.io or alternatively download the project.