Development concept: State management
State management in complex frontend applications is a topic that has been heavily evolving over the past years. Redux and stores, one-way data flow are all the rage nowadays. OpenProject is an old application, so its frontend exists way before these concepts were introduced and became popular.
Key takeaways
State management in OpenProject frontend…
- is mainly controlled by
RxJs
and the reactivestates library State
andInputState
are mostly syntactic sugar over RxJSSubject
andBehaviorSubject
- States are used to hold and cache values with their values and non-values being observable
InputState
An InputState
object is a wrapper around RxJS BehaviorSubject
. It provides some syntactic sugar over it to inspect values and provide helpers to observe streams and fill in the underlying Subject
.
To create an InputState
, call new InputState<Type>(initialValue:Type|undefined)
or use the helper method input<Type>(initialValue?:Type)
which will fall back to the undefined value. You will then be able to inspect its value and contents.
// An initially empty state
const state = new InputState<string>();
console.log(state.isPristine()); // true
console.log(state.hasValue()); // false
console.log(state.value); // undefined
An InputState will hold exactly one value, that you can fill explicitly:
state.putValue('my value');
console.log(state.isPristine()); // false
console.log(state.hasValue()); // true
console.log(state.value); // 'my value'
You can find out if a value is older than a specific amount of ms:
state.isValueOlderThan(60000); // Value is older than 60 seconds?
The value can be explicitly cleared with state.clear()
.
You can also fill the InputState with the result of a promise request. With putFromPromiseIfPristine
, the promise will only be requested if the state is empty. This is useful for performing API requests that should not be re-executed while the value is cached.
state.putFromPromiseIfPristine(() => Promise.resolve('my new value'));
To find out if there is an active promise request, use state.hasActivePromiseRequest()
. You can also explicitly clear and put from promise in one step:
state.clearAndPutFromPromise(Promise.resolve('overridden value'));
You can get an RxJS Observable
to the value stream with state.values$()
:
state
.values$()
.subscribe(val => console.log("Observed value " + val));
You can also observe the changes
which includes undefined values
state
.changes$()
.subscribe(val => console.log("Observed " + (val ? "String value" : "Undefined"));
MultiInputState
The MultiInputState
is basically a map with a string key and an InputState
as its value. It is used for most of the cache stores in OpenProject.
To create a MultiInputState
, use the helper method multiInput<Type>()
. To get an InputState
member of this map, use the following:
export type FooType = { id:number };
const multi = multiInput<FooType>();
const state = multi.get('my identifier');
state.putValue({ id: 1234 });
// Later on
multi.get('my identifier').value // { id: 1324}
The MultiInputState can be observed as a whole:
multi
.observeChange()
.subscribe(([changedId, foo]) => {
console.log(`CHANGE for ${changedId}: ${foo?.id || 'cleared'});
}
multi.clear('my identifier');
// CHANGE for my identifier: cleared
multi
.get('my identifier')
.putFromPromiseIfPristine(() => Promise.resolve({ id: 'new' }));
// CHANGE for my identifier: new
StatesGroup
The StatesGroup
aggregates multiple States or MultiInputStates into one class. The only benefit to this is debugging capabilities of the reactivestates library. You can call the following method in development mode to see all changes to states in a StateGroup logged to console:
window.enableReactiveStatesLogging();
This might then look like the following, with green color for added objects, and red color for removed values:
[RS] Changesets.changesets[/api/v3/projects/1] {o=4} "[object Object]"
🔗 Code references
StatesService
GlobalStates
cache of MultiInputStatesIsolatedQuerySpace
Query spaceStatesGroup
. Is instantiated multiple times whenever a work package query is loaded. See the separate concept guide for more information.- ReactiveStates library we use for the StatesGroup. This was developed by Roman primarily for us during AngularJS times.
Discussions
- In contrast to a
Store
concept of redux, the States and state groups do not have any concept of data immutability. As a caller you will need to ensure that. In OpenProject, many of the states are in fact mutable due to historic reasons and the fact that complex class instances are passed around that cannot be easily shallow copied. This will need to be refactored in the future. - As the
reactivestates
library was primarily developed for us, we may need to take over its code or move to a different state management concept altogether. The recent developments inngxs
look very promising.