Watch the accompanying video

I built the same Todo MVC app with StateAdapt and ended up with 31% less code.

Todo MVC UI

State transition logic

The most significant divergence is how StateAdapt handles state transition logic. Some developers may find the terseness off-putting, but that compactness brings portability, which I value. Here's how it looks in StateAdapt:

  todosAdapter = createAdapter<Todo[]>()({
    create: (todos, text: Todo['text']) => [
      ...todos,
      {
        id: Math.round(Math.random() * 100000),
        text,
        done: false,
      },
    ],
    remove: (todos, { id }: Todo) => todos.filter((todo) => todo.id !== id),
    update: (todos, { id, text, done }: Todo) =>
      todos.map((todo) => (todo.id !== id ? todo : { id, text, done })),
    toggleAll: (todos, done: Todo['done']) =>
      todos.map((todo) => ({ ...todo, done })),
    clearCompleted: (todos) => todos.filter(({ done }) => !done),
    selectors: {
      completed: (todos) => todos.filter(({ done }) => done),
      active: (todos) => todos.filter(({ done }) => !done),
    },
  });
Enter fullscreen mode Exit fullscreen mode

With StateAdapt, you can define state management patterns per type or interface, then combine adapters to manage composite types. Here's an example:

  adapter = joinAdapters<TodoState>()({
    filter: createAdapter<TodoFilter>()({ selectors: {} }),
    todos: this.todosAdapter,
  })({
    /**
     * Derived state
     */
    filteredTodos: (s) =>
      s.todos.filter(({ done }) => {
        if (s.filter === 'all') return true;
        if (s.filter === 'active') return !done;
        if (s.filter === 'completed') return done;
      }),
  })();
Enter fullscreen mode Exit fullscreen mode

This mechanism takes each child adapter's state changes and selectors and exposes them on the new parent adapter. TypeScript's type system handles the naming: it prefixes the original names with the property name, whether that's filter or todos.

This naming scheme can lead to some awkward selector names. For instance, the todosAdapter's completed selector surfaces as adapter.todosCompleted on the combined adapter, where plain English might suggest adapter.completedTodos. That said, going from general to specific has a certain appeal, so it's hardly a real issue.

State change names can also feel a bit off at times, though I'm actively exploring improvements. The challenge is plural versus singular. Because state change names are verbs, I keep the verb first, then add the namespace, then the rest of the name. So set on a child adapter becomes setFilter. If the namespace were checked, setToTrue would turn into setCheckedToTrue. In our case, create is used to create a single todo, but it becomes adapter.createTodos. In the future, I plan to build a dedicated list adapter where this would become addOne, following the pattern used by NgRx/Entity, which was a major influence on StateAdapt originally.

Despite these naming quirks, this approach makes it straightforward to define reusable, compact state logic.

For comparison, here's the RxAngular version:

interface Commands {
  create: Pick<Todo, 'text'>;
  remove: Pick<Todo, 'id'>;
  update: Pick<Todo, 'id' | 'text' | 'done'>;
  toggleAll: Pick<Todo, 'done'>;
  clearCompleted: Pick<Todo, 'done'>;
  setFilter: TodoFilter;
}

// ...
  /**
   * UI actions
   */
  private readonly commands = this.factory.create();

  /**
   * State
   */
  private readonly _filter$ = this.select('filter');
  private readonly _allTodos$ = this.select('todos');

  /**
   * Derived state
   */
  private readonly _filteredTodos$ = this.select(
    selectSlice(['filter', 'todos'])
  ).pipe(
    map(({ todos, filter }) =>
      todos.filter(({ done }) => {
        if (filter === 'all') return true;
        if (filter === 'active') return !done;
        if (filter === 'completed') return done;
      })
    )
  );
  private readonly _completedTodos$ = this._allTodos$.pipe(
    map((todos) => todos.filter((todo) => todo.done))
  );
  private readonly _activeTodos$ = this._allTodos$.pipe(
    map((todos) => todos.filter((todo) => !todo.done))
  );

// ...
    /**
     * State handlers
     */
    this.connect('filter', this.commands.setFilter$);
    this.connect('todos', this.commands.create$, ({ todos }, { text }) =>
      insert(todos, {
        id: Math.round(Math.random() * 100000),
        text,
        done: false,
      })
    );
    this.connect('todos', this.commands.remove$, ({ todos }, { id }) =>
      remove(todos, { id }, 'id')
    );
    this.connect(
      'todos',
      this.commands.update$,
      ({ todos }, { id, text, done }) => update(todos, { id, text, done }, 'id')
    );
    this.connect('todos', this.commands.toggleAll$, ({ todos }, { done }) =>
      update(todos, { done }, () => true)
    );
    this.connect(
      'todos',
      this.commands.clearCompleted$,
      ({ todos }, { done }) => remove(todos, { done }, 'done')
    );
// ...
Enter fullscreen mode Exit fullscreen mode

RxAngular stands out because it treats RxJS as the foundation, which most other state libraries don't. Declaring in the state store that it should react to an observable can make state management considerably cleaner.

RxAngular is genuinely impressive.

Callbacks

Another key difference: I removed callback functions entirely. The RxAngular version includes this:

  setFilter(filter: TodoFilter): void {
    this.commands.setFilter(filter);
  }

  create(todo: Pick<Todo, 'text'>): void {
    this.commands.create(todo);
  }

  remove(todo: Pick<Todo, 'id'>): void {
    this.commands.remove(todo);
  }

  update(todo: Todo): void {
    this.commands.update(todo);
  }

  toggleAll(todo: Pick<Todo, 'done'>): void {
    this.commands.toggleAll(todo);
  }

  clearCompleted(): void {
    this.commands.clearCompleted({ done: true });
  }
Enter fullscreen mode Exit fullscreen mode

Here's what I replaced it with in StateAdapt:

  setFilter = this.store.setFilter;
  create = this.store.createTodos;
  remove = this.store.removeTodos;
  update = this.store.updateTodos;
  toggleAll = this.store.toggleTodosAll;
  clearCompleted = this.store.clearTodosCompleted;
Enter fullscreen mode Exit fullscreen mode

I could have taken the same approach in the RxAngular version, but I deliberately chose not to. StateAdapt is firmly committed to fully reactive code organization. Callback functions were traditionally considered good practice for future flexibility, but that flexibility is mainly imperative. Observables already offer natural flexibility — anything can reference them and adapt, given a declarative API. You can read more about that philosophy here.

This strict commitment can make StateAdapt feel awkward when you're surrounded by imperative APIs. But the remedy is to build declarative wrappers around those APIs.

Summary

For a detailed side-by-side, check out this PR comparison.

RxAngular remains my top choice for Angular state management. StateAdapt is still under development; I want to test it on more projects before I'm confident enough to release version 1.0. If you see potential in it, a star would be much appreciated — and I'd love to hear your feedback if you try it out.

Thanks for reading!

Resources

Repo

StateAdapt

RxAngular

Twitter