Managing state

For simple applications where data is not required to flow between many components, state management can be very straightforward. Data can be encapsulated within individual widgets that need it as the most basic form of state management within a Dojo application.

As applications grow in complexity and start requiring data to be shared and transferred between multiple widgets, a more robust form of state management is required. Here, Dojo begins to prove its value as a reactive framework, allowing applications to define how data should flow between components, then letting the framework manage change detection and re-rendering. This is done by wiring widgets and properties together when declaring VDOM output in a widget’s render function.

For large applications, state management can be one of the most challenging aspects to deal with, requiring developers to balance between data consistency, availability and fault tolerance. While a lot of this complexity remains outside the scope of the web application layer, Dojo provides further solutions that help ensure data consistency. The Dojo Stores component provides a centralized state store with a consistent API for accessing and managing data from multiple locations within the application.

Basic: self-encapsulated widget state

Widgets can maintain their own internal state in a variety of ways. Function-based widgets can use the icache middleware to store widget-local state, and class-based widgets can use internal class fields.

Internal state data may directly affect the widget’s render output, or may be passed as properties to any child widgets where they in turn directly affect the children’s render output. Widgets may also allow their internal state to be changed, for example in response to a user interaction event.

The following example illustrates these patterns:

src/widgets/MyEncapsulatedStateWidget.tsx

Function-based variant:

  1. import { create, tsx } from '@dojo/framework/core/vdom';
  2. import icache from '@dojo/framework/core/middleware/icache';
  3. const factory = create({ icache });
  4. export default factory(function MyEncapsulatedStateWidget({ middleware: { icache } }) {
  5. return (
  6. <div>
  7. Current widget state: {icache.get<string>('myState') || 'Hello from a stateful widget!'}
  8. <br />
  9. <button
  10. onclick={() => {
  11. let counter = icache.get<number>('counter') || 0;
  12. let myState = 'State change iteration #' + ++counter;
  13. icache.set('myState', myState);
  14. icache.set('counter', counter);
  15. }}
  16. >
  17. Change State
  18. </button>
  19. </div>
  20. );
  21. });

Class-based variant:

  1. import WidgetBase from '@dojo/framework/core/WidgetBase';
  2. import { tsx } from '@dojo/framework/core/vdom';
  3. export default class MyEncapsulatedStateWidget extends WidgetBase {
  4. private myState = 'Hello from a stateful widget!';
  5. private counter = 0;
  6. protected render() {
  7. return (
  8. <div>
  9. Current widget state: {this.myState}
  10. <br />
  11. <button
  12. onclick={() => {
  13. this.myState = 'State change iteration #' + ++this.counter;
  14. }}
  15. >
  16. Change State
  17. </button>
  18. </div>
  19. );
  20. }
  21. }

Note that this example is not complete - clicking on the ‘Change State’ button in the running application will not have any effect on the widget’s render output. This is because the state is fully encapsulated within MyEncapsulatedStateWidget, and Dojo is not aware of any changes made to it. Only the widget’s initial render will be processed by the framework.

In order to notify Dojo that a re-render is needed, widgets that encapsulate render state need to invalidate themselves.

Invalidating a widget

Function-based widgets can use the icache middleware to deal with local state management that automatically invalidates the widget when state is updated. icache composes cache and invalidator middleware, with cache handling widget state management and invalidator handling widget invalidation on state change. Function-based widgets can also use invalidator directly, if desired.

For class-based widgets, there are two ways to invalidate:

  1. Explicitly calling this.invalidate() in an appropriate location where state is being changed.
    • In the MyEncapsulatedStateWidget example, this could be done in the ‘Change State’ button’s onclick handler.
  2. Annotating any relevant fields with the @watch() decorator (from the @dojo/framework/core/vdomecorators/watch module). When @watched fields are modified, this.invalidate() will implicitly be called - this can be useful for state fields that always need to trigger a re-render when updated.

Note: marking a widget as invalid won’t immediately re-render the widget - instead it acts as a notification to Dojo that the widget is in a dirty state and should be updated and re-rendered in the next render cycle. This means invalidating a widget multiple times within the same render frame won’t have a negative impact on application performance, although excessive invalidation should be avoided to ensure optimal performance.

The following is an updated MyEncapsulatedStateWidget example that will correctly update its output when its state is changed.

Function-based variant:

  1. import { create, tsx } from '@dojo/framework/core/vdom';
  2. import icache from '@dojo/framework/core/middleware/icache';
  3. const factory = create({ icache });
  4. export default factory(function MyEncapsulatedStateWidget({ middleware: { icache } }) {
  5. return (
  6. <div>
  7. Current widget state: {icache.getOrSet<string>('myState', 'Hello from a stateful widget!')}
  8. <br />
  9. <button
  10. onclick={() => {
  11. let counter = icache.get<number>('counter') || 0;
  12. let myState = 'State change iteration #' + ++counter;
  13. icache.set('myState', myState);
  14. icache.set('counter', counter);
  15. }}
  16. >
  17. Change State
  18. </button>
  19. </div>
  20. );
  21. });

Class-based variant:

Here, both myState and counter are updated as part of the same application logic operation, so @watch() could be added to either or both of the fields, with the same net effect and performance profile in all cases:

src/widgets/MyEncapsulatedStateWidget.tsx

  1. import WidgetBase from '@dojo/framework/core/WidgetBase';
  2. import watch from '@dojo/framework/core/decorators/watch';
  3. import { tsx } from '@dojo/framework/core/vdom';
  4. export default class MyEncapsulatedStateWidget extends WidgetBase {
  5. private myState: string = 'Hello from a stateful widget!';
  6. @watch() private counter: number = 0;
  7. protected render() {
  8. return (
  9. <div>
  10. Current widget state: {this.myState}
  11. <br />
  12. <button
  13. onclick={() => {
  14. this.myState = 'State change iteration #' + ++this.counter;
  15. }}
  16. >
  17. Change State
  18. </button>
  19. </div>
  20. );
  21. }
  22. }

Intermediate: passing widget properties

Passing state into a widget via virtual node properties is the most effective way of wiring up reactive data flows within a Dojo application.

Widgets specify their own properties interface which can include any fields the widget wants to publicly advertise to consumers, including configuration options, fields representing injectable state, as well as any event handler functions.

Function-based widgets pass their properties interface as a generic type argument to the create().properties<MyPropertiesInterface>() call. The factory returned from this call chain then makes property values available via a properties function argument in the render function definition.

Class-based widgets can define their properties interface as a generic type argument to WidgetBase in their class definition, and then access their properties through the this.properties object.

For example, a widget supporting state and event handler properties:

src/widgets/MyWidget.tsx

Function-based variant:

  1. import { create, tsx } from '@dojo/framework/core/vdom';
  2. import icache from '@dojo/framework/core/middleware/icache';
  3. const factory = create().properties<{
  4. name: string;
  5. onNameChange?(newName: string): void;
  6. }>();
  7. export default factory(function MyWidget({ middleware: { icache }, properties }) {
  8. const { name, onNameChange } = properties();
  9. let newName = icache.get<string>('new-name') || '';
  10. return (
  11. <div>
  12. <span>Hello, {name}! Not you? Set your name:</span>
  13. <input
  14. type="text"
  15. value={newName}
  16. oninput={(e: Event) => {
  17. icache.set('new-name', (e.target as HTMLInputElement).value);
  18. }}
  19. />
  20. <button
  21. onclick={() => {
  22. icache.set('new-name', undefined);
  23. onNameChange && onNameChange(newName);
  24. }}
  25. >
  26. Set new name
  27. </button>
  28. </div>
  29. );
  30. });

Class-based variant:

  1. import WidgetBase from '@dojo/framework/core/WidgetBase';
  2. import { tsx } from '@dojo/framework/core/vdom';
  3. export interface MyWidgetProperties {
  4. name: string;
  5. onNameChange?(newName: string): void;
  6. }
  7. export default class MyWidget extends WidgetBase<MyWidgetProperties> {
  8. private newName = '';
  9. protected render() {
  10. const { name, onNameChange } = this.properties;
  11. return (
  12. <div>
  13. <span>Hello, {name}! Not you? Set your name:</span>
  14. <input
  15. type="text"
  16. value={this.newName}
  17. oninput={(e: Event) => {
  18. this.newName = (e.target as HTMLInputElement).value;
  19. this.invalidate();
  20. }}
  21. />
  22. <button
  23. onclick={() => {
  24. this.newName = '';
  25. onNameChange && onNameChange(newName);
  26. }}
  27. >
  28. Set new name
  29. </button>
  30. </div>
  31. );
  32. }
  33. }

A consumer of this example widget can interact with it by passing in appropriate properties:

src/widgets/NameHandler.tsx

Function-based variant:

  1. import { create, tsx } from '@dojo/framework/core/vdom';
  2. import icache from '@dojo/framework/core/middleware/icache';
  3. import MyWidget from './MyWidget';
  4. const factory = create({ icache });
  5. export default factory(function NameHandler({ middleware: { icache } }) {
  6. let currentName = icache.get<string>('current-name') || 'Alice';
  7. return (
  8. <MyWidget
  9. name={currentName}
  10. onNameChange={(newName) => {
  11. icache.set('current-name', newName);
  12. }}
  13. />
  14. );
  15. });

Class-based variant:

  1. import WidgetBase from '@dojo/framework/core/WidgetBase';
  2. import { tsx } from '@dojo/framework/core/vdom';
  3. import watch from '@dojo/framework/core/decorators/watch';
  4. import MyWidget from './MyWidget';
  5. export default class NameHandler extends WidgetBase {
  6. @watch() private currentName: string = 'Alice';
  7. protected render() {
  8. return (
  9. <MyWidget
  10. name={this.currentName}
  11. onNameChange={(newName) => {
  12. this.currentName = newName;
  13. }}
  14. />
  15. );
  16. }
  17. }

Advanced: abstracting and injecting state

When implementing complex responsibilities, following a pattern of state encapsulation within widgets can result in bloated, unmanageable components. Another problem can arise in large applications with hundreds of widgets structured across tens of layers of structural hierarchy. State is usually required in the leaf widgets, but not in intermediate containers within the VDOM hierarchy. Passing state through all layers of such a complex widget hierarchy adds brittle, unnecessary code.

Dojo provides the Stores component to solve these issues by abstracting state management into its own dedicated context, then injecting relevant portions of the application’s state into specific widgets that require it.