DEV Community

Cover image for Angular State Library
Michael Muscat
Michael Muscat

Posted on • Edited on

Angular State Library

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++
   }
}
Enter fullscreen mode Exit fullscreen mode
Is this redux?

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)
   }
}
Enter fullscreen mode Exit fullscreen mode
Is this redux?

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.

  1. State is mutated when an action occurs.
  2. 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)
Enter fullscreen mode Exit fullscreen mode
Angular State Library is Redux reimagined.

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)
   }
}
Enter fullscreen mode Exit fullscreen mode
See it in action on StackBlitz

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:

  1. No compiler tricks
  2. No new reactive primitives (like ref)
  3. 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
   }
}
Enter fullscreen mode Exit fullscreen mode

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))
   }
}
Enter fullscreen mode Exit fullscreen mode

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
      })
   }
}
Enter fullscreen mode Exit fullscreen mode

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
      })
   }
}
Enter fullscreen mode Exit fullscreen mode
See it in action on StackBlitz

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
   );
}
Enter fullscreen mode Exit fullscreen mode
function toggleAll(todos: Todo[]): Observable<Todo[]> {
   useOperator(exhaustAll())
   return forkJoin(
      todos.map(todo => updateTodo({ 
         ...todo, 
         completed: !todo.completed
      }))
   );
}
Enter fullscreen mode Exit fullscreen mode

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
      })
   }
}
Enter fullscreen mode Exit fullscreen mode

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)