Refactoring a Complex State Service
I mentioned at the end of the last lesson that the first example we looked at
for implementing connect was a bit of an ideal case that ends up looking
super clean. It is still realistic, and often our state will end up looking
like this (in fact, the example we looked at was a snippet from the next
application we will be building).
However, things are complicated a little when we also need to access the previous state in order to set our new state. Our Quicklists application does that a lot, so to see how this works we will refactor the services in the Quicklists application.
Again, if you do not already have the completed Quicklists application on your computer you can find it here.
ng add ngxtensionRefactoring the Checklist Service
Let’s start by refactoring the ChecklistService. The only thing we typically
need to touch when doing these refactors to use connect is our reducers
— our sources and everything else can remain the same.
This is what the reducers look like for our ChecklistService without
connect:
constructor() { // reducers this.checklistsLoaded$.pipe(takeUntilDestroyed()).subscribe({ next: (checklists) => this.state.update((state) => ({ ...state, checklists, loaded: true, })), error: (err) => this.state.update((state) => ({ ...state, error: err })), });
this.add$.pipe(takeUntilDestroyed()).subscribe((checklist) => this.state.update((state) => ({ ...state, checklists: [...state.checklists, this.addIdToChecklist(checklist)], })) );
this.remove$.pipe(takeUntilDestroyed()).subscribe((id) => this.state.update((state) => ({ ...state, checklists: state.checklists.filter((checklist) => checklist.id !== id), })) );
this.edit$.pipe(takeUntilDestroyed()).subscribe((update) => this.state.update((state) => ({ ...state, checklists: state.checklists.map((checklist) => checklist.id === update.id ? { ...checklist, title: update.data.title } : checklist ), })) );
// effects effect(() => { if (this.loaded()) { this.storageService.saveChecklists(this.checklists()); } }); }We will be refactoring all of this, but the bit specifically that we are focusing on is stuff like this:
this.add$.pipe(takeUntilDestroyed()).subscribe((checklist) => this.state.update((state) => ({ ...state, checklists: [...state.checklists, this.addIdToChecklist(checklist)], })) );This source emits the new checklist to add. However, in order to set the new
checklists state we first need to access all of the existing checklists in the
previous state with ...state.checklists and then add our new checklist to
those.
This is the way we used connect in the last lesson:
constructor() { // reducers const nextState$ = merge( this.userAuthenticated$.pipe(map(() => ({ status: 'success' as const }))), this.login$.pipe(map(() => ({ status: 'authenticating' as const }))), this.error$.pipe(map(() => ({ status: 'error' as const }))) );
connect(this.state).with(nextState$); }There is no previous state here — we just map our sources to whatever we want the next state to be.
However, and again you may remember this from the lesson where we broke down
exactly how connect works, we can also optionally supply a reducer
function to connect.
We will update the entire ChecklistService in just a moment, but dealing with
the add$ source in isolation would look like this:
connect(this.state) .with(this.add$, (state, checklist) => ({ checklists: [...state.checklists, this.addIdToChecklist(checklist)], }))Instead of just supplying the source, we also supply a reducer function.
This reducer function will be given the previous state (state in this example)
and whatever our source emits (checklist) in this example. We then just have
our reducer return whatever it is we want to set in the state signal — in this
case, we want to overwrite the checklists property with our new checklists
array.
Dealing with errors
There is one other complicating factor here. For one of our sources in our
original implementation we are using the error callback of subscribe:
this.checklistsLoaded$.pipe(takeUntilDestroyed()).subscribe({ next: (checklists) => this.state.update((state) => ({ ...state, checklists, loaded: true, })), error: (err) => this.state.update((state) => ({ ...state, error: err })), });If we successfully get a value we call state.update with the checklists, but
if there is an error we update our state with the error.
To handle this with the new connect set up we will instead create a new source
for the error:
private error$ = new Subject<string>();And we will also modify our checklistsLoaded$ source so that the error is
caught with catchError:
private checklistsLoaded$ = this.storageService.loadChecklists().pipe( catchError((err) => { this.error$.next(err); return EMPTY; }) );Now rather than letting the checklistsLoaded$ source error, we catch it and
next our error$ source instead.
We then just handle that error$ source along with other state updates:
const nextState$ = merge( this.checklistsLoaded$.pipe( map((checklists) => ({ checklists, loaded: true })) ), this.error$.pipe(map((error) => ({ error }))) );
connect(this.state) .with(nextState$)There is no need to handle errors in the reducer stage now, as we will make sure
to catch those before then and handle them in the error$ source instead.
The Refactored Implementation
We have all the concepts we need, now it’s just a matter of putting them all together.
See if you can finish off the rest of the refactor for the ChecklistService
with what we have covered so far.
Click here to reveal solution
Solution
Here is the completed implementation:
constructor() { const nextState$ = merge( this.checklistsLoaded$.pipe( map((checklists) => ({ checklists, loaded: true })) ), this.error$.pipe(map((error) => ({ error }))) );
connect(this.state) .with(nextState$) .with(this.add$, (state, checklist) => ({ checklists: [...state.checklists, this.addIdToChecklist(checklist)], })) .with(this.remove$, (state, id) => ({ checklists: state.checklists.filter((checklist) => checklist.id !== id), })) .with(this.edit$, (state, update) => ({ checklists: state.checklists.map((checklist) => checklist.id === update.id ? { ...checklist, title: update.data.title } : checklist ), }));
// effects effect(() => { if (this.loaded()) { this.storageService.saveChecklists(this.checklists()); } }); }Obviously there is still some complexity here, but this is considerably shorter and cleaner than the original implementation.
Note that I am doing this:
const nextState$ = merge( this.checklistsLoaded$.pipe( map((checklists) => ({ checklists, loaded: true })) ), this.error$.pipe(map((error) => ({ error }))) );For any sources that do not need a reducer function, I like to combine them all
into a single nextState$ stream with merge and then supply that to the
connect function. Then I will chain on with for each source that needs
a reducer. If you don’t like this style, you can just skip creating this
nextState$ stream and instead just supply each source individually with
with.
If you had trouble implementing this yourself, study the code above for a little
bit because we have round two coming up now — we still need to refactor the
ChecklistItemService. There will be no new concepts here, it will be the exact
same style of refactor just with slightly different data.
Click here to reveal solution
Solution
Here is the completed refactor for the ChecklistItemService:
constructor() { const nextState$ = merge( this.checklistItemsLoaded$.pipe( map((checklistItems) => ({ checklistItems, loaded: true, })) ) );
connect(this.state) .with(nextState$) .with(this.add$, (state, checklistItem) => ({ checklistItems: [ ...state.checklistItems, { ...checklistItem.item, id: Date.now().toString(), checklistId: checklistItem.checklistId, checked: false, }, ], })) .with(this.edit$, (state, update) => ({ checklistItems: state.checklistItems.map((item) => item.id === update.id ? { ...item, title: update.data.title } : item ), })) .with(this.remove$, (state, id) => ({ checklistItems: state.checklistItems.filter((item) => item.id !== id), })) .with(this.toggle$, (state, checklistItemId) => ({ checklistItems: state.checklistItems.map((item) => item.id === checklistItemId ? { ...item, checked: !item.checked } : item ), })) .with(this.reset$, (state, checklistId) => ({ checklistItems: state.checklistItems.map((item) => item.checklistId === checklistId ? { ...item, checked: false } : item ), })) .with(this.checklistRemoved$, (state, checklistId) => ({ checklistItems: state.checklistItems.filter( (item) => item.checklistId !== checklistId ), }));
// effects effect(() => { if (this.loaded()) { this.storageService.saveChecklistItems(this.checklistItems()); } }); }The state for the two services we refactored are likely some of the more complex you are likely to come across. Quite often, our state management services will be considerably simpler than this.