Disclaimer: This project is an experiment, it is not production ready.
Introduction
Why: I love RxJS. The integration between Angular and RxJS leaves much to the imagination. Existing state libraries are complicated and verbose.
How: It uses TypeScript decorators to wrap class methods in an injection context. ES6 Proxies wrap this
to capture reactive dependencies. Reactive dependencies are dirty-checked during change detection. Reactive functions are invoked when their dependencies have changed.
What: Angular State Library borrows from Vue's reactivity system, combining it with RxJS observables and Redux, without boilerplate.
Redux: The Good Parts
Redux is a simple idea. State plus action equals new state. Also known as a reducer. For some this is means pure functions. But libraries like Immer and redux-thunk allow us to describe state changes in other, more ergonomic ways.
@Component()
export class UICounter {
count = 0
increment() {
this.count++
}
}
Another aspect of redux is observability. One part of the system can observe the outputs of another part of the system and perform more actions. Sometimes we call these sagas, or side effects. Action plus side effect equals more actions.
@Component()
export class UICounter {
count = 0
increment() {
this.count++
setTimeout(() => {
this.count *= 2
}, 2000)
}
}
There are other benefits to using redux, such as immutability and development tools. These are secondary concerns. What we have done is boil down redux to two simple principles.
- State is mutated when an action occurs.
- Side effects are triggered after an action occurs.
We can implement this however we want. For Angular, we can do a whole lot more.
Angular State Library
@Store()
@Component()
export class UICounter {
count = 0
@Action() increment() {
this.count++
return dispatch(timer(2000), () =>
this.count *= 2
})
}
}
const dispatch = createDispatch(UICounter)
The goal of this project is to eliminate the complexity of state management in Angular. There are no action factories here. No modules, no facades, no adapters. Just a simple Angular directive. Add some decorators and we're good to go.
Declarative State
State changes can be expressed declaratively. Functions decorated by @Invoke()
are called automatically during the first change detection run, and each time its dependencies change there after.
@Store()
@Component()
export class UICounter {
@Input() count = 0
increment() {
this.count++
}
@Invoke() logCount() {
console.log(this.count)
}
}
Here we see logCount
is a reactive function that logs the current value of count
whenever it changes. If you're familiar with Vue's reactivity system it's kind of like watchEffect
. But there are some big differences:
- No compiler tricks
- No new reactive primitives (like
ref
) - Shallow proxies by default
This works because we are able to replace this
with an ES6 Proxy object that marks property access on this
as a dependency. When Angular's change detection cycle runs it checks the dependencies and invokes the action each time they change. It doesn't work on arrow functions.
More importantly, Invoke
is just an alias for Action
. We can observe its calls through the event stream.
Selectors
Think computed properties. Angular State Library has two types of selectors:
Field selectors
@Store()
@Component()
export class UICounter {
@Input() count = 0
@Select() get double() {
return this.count * 2
}
}
Field selectors wrap the this
object in a shallow proxy that tracks dependencies, and only recalculates the value when those dependencies change. It's also lazy; nothing happens until we read the value.
Parameterized selectors
@Store()
@Component()
export class UICounter {
@Input() todos = []
@Select() getTodosWith(text) {
return this.todos.filter(todo => todo.title.match(text))
}
}
Parameterized selectors are the same as field selectors except the function arguments are also memoized. By default all arguments are memoized without limit, and reset when the object dependencies change. For example, if I query getTodosWith
with the values "Bob", "Jane" and "George", all three queries would be memoized until the todos
array changes, which clears the cache.
Side Effects
In Angular State Library, side effects are just plain RxJS observables. Effects are dispatched by actions, and each action can dispatch just one effect.
@Store()
@Component()
export class UICounter {
count = 0
@Action() increment() {
this.count++
return dispatch(timer(2000), () =>
this.count *= 2
})
}
}
dispatch
First let's look at dispatch
. It is used to dispatch effects and can only be used inside an action stack frame (ie. a method decorated with Action
or one of its derivatives).
The dispatcher accepts two arguments: an observable source and an (optional) observer that receives its events. The dispatcher returns an observable that mirrors the original observable for further chaining.
When an action returns an observable the subscription doesn't happen immediately. The store waits until all actions have executed before it subscribes to effects.
useOperator
By default, the previous effect is cancelled each time an action dispatches a new effect. This is consistent with other reactivity systems. But RxJS lets us do more.
useOperator
is used to configure the switching behaviour of effects. For example, if we want to merge effects instead of switching them:
@Store()
@Component()
export class UICounter {
count = 0
@Action() increment() {
this.count++
useOperator(mergeAll())
return dispatch(timer(2000), () => {
this.count *= 2
})
}
}
Try clicking the button many times and watch what happens. Now swap mergeAll()
with switchAll()
and click a few more times. See the difference? RxJS gives us amazing temporal powers.
Note: The flattening operator passed to
useOperator
cannot be changed once it has been set. It is locked in the first time it is called.
useOperator
can also be composed with effects.
function updateTodo(todo: Todo): Observable<Todo> {
useOperator(mergeAll())
return inject(HttpClient).put<Todo>(
`https://jsonplaceholder.typicode.com/todos/${todo.id}`,
todo
);
}
function toggleAll(todos: Todo[]): Observable<Todo[]> {
useOperator(exhaustAll())
return forkJoin(
todos.map(todo => updateTodo({
...todo,
completed: !todo.completed
}))
);
}
Sagas
For most cases the one-to-one relationship between actions and effects should suffice, especially for basic I/O operations like fetch. But when that's not enough, we can observe the full stream of events to create sagas.
@Store()
@Component()
export class UICounter {
count = 0
@Action() increment() {
this.count++
}
@Action() double() {
this.count *= 2
}
@Invoke() doubleAfterIncrement() {
const effect = fromStore(UICounter).pipe(
filter(event => event.name === "increment"),
filter(event => event.type === "dispatch"),
delay(2000)
)
return dispatch(effect, {
next: this.double
})
}
}
The fromStore
helper can be used to observe all events, including effects, with full type inference.
Putting it all Together
For a full example showcasing most of the features in Angular State Library (and some bonus features like lazy loading and transitions) view the example app on StackBlitz.
Help wanted
This project is just a proof of concept. It's no where near production ready. If you are interested in contributing or dogfooding feel free to open a line on Github discussions, or leave a comment with your thoughts below.
Thanks for reading!
Top comments (0)