The creator of State Adapt, Mike Pearson, is one of the most passionate supporters of declarative code in Angular.
For those new to the concept, the most important concepts to understand are that, with declarative code, you can understand what something is and how it changes just by looking at its declaration - it is not imperatively changed after its declaration. If you aren’t too familiar, I have a video on declarative code which you can access here.
We generally have this architecture where we can visualise data flowing into the application from the top, and it follows this downward flow where everything automatically reacts to changes in the dependencies above it. This is where the “reactive” aspect comes into play, the reactions happen automatically we don’t need to manually update things like we would with imperative code, it all just happens, by definition, based on the things declaration.
This article is based on the following video:
Introduction to State Adapt
So, what exactly is State Adapt and why does it exist? In short, it’s Mike’s attempt at creating a state management library that is as declarative as possible.
A Real-World Example
Let’s dive straight into an example. I refactored the quicklists application to demonstrate how it works. For those who what to dive deeper into the Quicklist application I have linked the broader application here.
The focus here is mainly on one feature of this application - a service that manages checklist items, and a component that makes use of this service.
<app-checklist-item-list [checklistItem]="items()" (toggle)="cis.toggle$.next($event)" (delete)="cis.remove$.next($event)" (edit)="checklistItemBeingEdited.set($event)" />
Key Parts
The first key part is our data sources, which is how data flows into our application.
These data sources can be regular observable streams like the checklistItemsLoaded
stream that is responsible for loading the data. Or it could be one of the Source
streams.
export class ChecklistItemService {
private storageService = inject(StorageService);
private checklistItemsLoaded$ = this.storageService
.loadChecklistItems()
.pipe(toSource("[Storage] checklist items loaded"));
add$ = new Source<AddChecklistItem>("[Checklist Items] add");
remove$ = new Source<RemoveChecklistItem>("[Checklist Items] remove");
edit$ = new Source<EditChecklistItem>("[Checklist Items] edit");
checklistRemoved$ = new Source<RemoveChecklist>(
"[Checklist Items] checklistRemoved$"
);
toggle$ = new Source<RemoveChecklistItem>("[Checklist Items] toggle");
reset$ = new Source<RemoveChecklist>("[Checklist Items] reset");
A State Adapt “source” stream essentially behaves as a subject that we can next
. By supplying extra strings, we can integrate with Redux devtools, making it easier to track data sources and state changes during debugging. We also pipe our normal observable stream with toSource
to add this information as well.
The second part is our store. This gives us access to the current state. Here, we give the checklistItems
state its initial state, and then supply an adapter. This adapter takes the data emitted on our data sources and determines how that should change the state. The idea is quite similar to actions and reducers in the Redux pattern.
private store = adapt(["checklistItems", initialState, checklistItemsAdapter], {
loadChecklistItems: this.checklistItemsLoaded$,
add: this.add$,
remove: this.remove$,
edit: this.edit$,
toggle: this.toggle$,
reset: this.reset$,
clearChecklistItems: this.checklistRemoved$,
});
We hook up our data sources to the methods they should trigger in the adapter. When checklistItemsLoaded
emits a value it should trigger loadChecklistItems
, when the add
source emits a new value it should trigger add in the adapter, and so on.
Then, in the adapter, just like with a reducer, we take in the data from the data source, along with our current state, and determine how that event should change the state. We can also define selectors in our adapter for the streams of data we want to create from our state.
Utilizing State Adapt with Angular
Using the streams from those selectors, I convert them into signals that are publicly exposed on this service. I then use those signals in my components. At the end, all my component gets is a signal of the current state. The signals aspect here isn’t really a part of state adapt, it is just how I am using the state from state adapt in angular.
private store = adapt(["checklistItems", initialState, checklistItemsAdapter], {
loadChecklistItems: this.checklistItemsLoaded$,
add: this.add$,
remove: this.remove$,
edit: this.edit$,
toggle: this.toggle$,
reset: this.reset$,
clearChecklistItems: this.checklistRemoved$,
});
loaded = toSignal(this.store.loaded$, { requireSync: true });
checklistItems = toSignal(this.store.checklistItems$, { requireSync: true });
To trigger changes like adding a new checklist item, the component simply nexts
the appropriate data source from the service.
<app-checklist-item-list
[checklistItem]="items()"
(toggle)="cis.toggle$.next($event)"
(delete)="cis.remove$.next($event)"
(edit)="checklistItemBeingEdited.set($event)"
/>
A Comparison with Redux and NgRx Store
One might wonder how this is different from Redux and NgRx Store. We have what are basically actions being listened to by what are basically reducers to produce state that we can access with selector streams.
The key difference here centres around the importance of our data sources as the source of change and not utilising callback functions that can contain imperative code.
A core philosophy of State Adapt is to avoid, and I think Mike would advocate for entirely eliminating, callback functions. Notice in the component that there are no methods. If we look at the other smart component in this application we will see it is the same deal.
item = computed(() =>
this.cis
.checklistItem()
.filter((item) => item.checklistId === this.params()?.get("id"))
);
checklist = computed(() =>
this.cs
.checklist()
.find((checklist) => checklist.id === this.params()?.get("id"))
);
checklistItemForm = this.fb.nonNullable.group({
title: ["", Validators.required],
});
constructor() {
// TODO: Use [patchValue] directive to react to signal in template
effect(() => {
const checklist = this.checklistBeingEdited();
if (item) {
this.checklistItemForm.patchValue({
title: item.title,
});
}
});
}
}
Whenever the user interacts with something in the template, rather than using a callback functions we have that action directly next
a data source.
The only function left here is this one in the effect that patches the form value, and as we can see from the comment left by Mike this should ideally be eliminated too:
// TODO: Use [patchValue] directive to react to signal in template
I like how well this quote from one of Mike’s articles sums up the benefit of the anti-callback method philosophy:
“The curly braces of functions that don’t return anything are like open arms inviting imperative code.”
The Callback Method
Callback methods make it easy to drop in imperative code, directly nexting your data sources encourages you to be more declarative.
For example, I was very clearly violating the declarative approach by making this imperative call in a callback:
remove(id: string) {
this.checklistItemService.clearChecklistItems(id);
this.remove$.next(id);
}
By having the remove
method in the first place, it made it all too easy to sneak some imperative code in.
So, to make this more declarative, rather than having an imperative method to call clearChecklistItems
as a side effect, the checklist
being removed can just be a data source for both the checklist
and checklistItem
adapter. The checklist
adapter will get the event from the data source and remove the checklist
, and the checklistItem
adapter will also get the event from the same data source and remove all of the checklist
items associated with that checklist.
If we want to delete a checklist, now we no longer require the callback method. We just call remove$.next
with the event directly from the template.
<app-checklist-list [checklistItem]="checklists()" (delete)="cs.remove$.next($event)" (edit)="checklistBeingEdited.set($event)" />
Conclusion
I’m really enjoying using State Adapt so far and I think it’s likely that it will become the default state management library I reach for. The biggest problem it has right now is that it is not well known.
It’s a new library that was built and is currently being supported by Mike so of course it comes with that open source maintenance risk. But that’s the case with most new projects, and the way we get any of the projects we like off the ground is to support them however we can.
The 1.0 release of State Adapt has already been out for a while and at this point, Mike is mostly looking for help in the form of people using it and creating issues if they run into any problems.
Even if you don’t want to use State Adapt in production, trying it out is a fantastic exercise in learning to code more declaratively.