YouTube video for this article
I implemented Todo MVC with StateAdapt and the code decreased by 31%.
State change logic
The main difference is how state change logic is defined in StateAdapt. Some may not like how compact it is. However, the compactness enables portability, so I like it. Here it is 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),
},
});
In StateAdapt, you can define state management patterns for each type/interface, and combine adapters to manage the combined type/interface. Like this:
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;
}),
})();
This takes the state changes and selectors for each child adapter and makes it available on the new parent adapter, by using TypeScript magic to prepend the names by its property name (filter
or todos
).
This can create some naming awkwardness for selectors. The todosAdapter
's selector completed
becomes available on the new adapter as adapter.todosCompleted
, whereas natural English would have called it adapter.completedTodos
. However, since this drills down from general to specific, there is something nice about it too. In any case, it's not a problem.
More naming awkwardness can come from state change names. For now. Because I am working on a potential solution for this. But the issue is with plural vs singular. Since state change names are verbs, I put the first word first, then the namespace the state change came from, then the rest of the state change name. So set
on a child adapter becomes setFilter
, for example. setToTrue
would become setCheckedToTrue
if the namespace was checked
. But in this example, we have create
, as in, create a single todo item. This becomes adapter.createTodos
, even though it only creates one. But in the future I plan on creating a list adapter, so this would become addOne
, similar to NgRx/Entity (which was a primary inspiration for StateAdapt in the first place).
Anyway, despite this awkwardness, it allows you to easily define compact, reusable state logic.
Here is the same code in RxAngular:
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')
);
// ...
RxAngular is awesome because it puts RxJS first, unlike most other state management libraries. The ability to have in your state/store's declaration that it will react to an observable can really make state management cleaner.
RxAngular is awesome.
Callbacks
Another big difference is that I eliminated callback functions. RxAngular's implementation has 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 });
}
Here's what I changed that to 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;
Although it could have been similar in the RxAngular implementation, I decided not to do it this way, because StateAdapt is extremely committed to completely reactive code organization. Traditionally, callback functions like these were seen as a good practice because they allow future flexibility. But they only allow for imperative flexibility. Observables are already flexible, because anything can refer to them and be updated, as long as there is a declarative API for it. Read more about this philosophy here.
This extreme commitment can make StateAdapt awkward to use when you're dealing with imperative APIs everywhere. But the solution is to create declarative wrapper for those APIs.
Conclusion
For a full comparison, see this PR comparison.
RxAngular is still currently my top pick for state management in Angular. StateAdapt is still a work in progress. I need to apply it to many more projects before I can have the confidence to release version 1.0. If you think it has potential, I'd appreciate a star, and I'd love for you to try it out and share your thoughts.
Thanks!
Top comments (2)
Great, Is this a alternative to Redux in Angular?
Yes. It accomplishes the same unidirecionality, but with much less boilerplate. medium.com/weekly-webtips/introduc...