Existing member? Log in and continue learning
See if you like it, start the course for free!
Unlock full course by purchasing a membership
Building Your Own State Utility
Building Your Own State Utility
Our goal is to make our lives a bit easier by using some kind of utility to help manage our state management process. We are going to start by investigating creating our own little utility to help with this.
As an example, let’s consider the reducers for the ChecklistService from
the Quicklists application we built earlier:
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 ), })) ); }There is clearly a lot of repetition here:
- pipe
- takeUntilDestroyed
- subscribe
- update
We repeat this process for just about every source we have. Let’s create a little utility to remove at least some of that repeated effort.
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';import { Observable } from 'rxjs';
export function reducer<T>( source$: Observable<T>, next: (value: T) => void, error?: (err: any) => void) { source$.pipe(takeUntilDestroyed()).subscribe({ next, error: error ? error : (err) => console.error(err), });}The idea here is that we have created a function called reducer that will take
in our source and it will handle subscribing to that source for us and piping
on the takeUntilDestroyed operator. It also allows us to supply functions that
we want to be triggered whenever there is a next value on the source or
optionally an error. It just calls our next function when the next on the
observable is triggered, and likewise for our error function if we supply it.
We are using another TypeScript concept called generics here as well. This
is a complex TypeScript topic that we are not going to cover in detail, but the
basic idea is that we have created a generic type T. By typing our source$
as Observable<T> our T type will become whatever type that observable emits.
If we pass reducer an observable that emits strings, then T will be of the
type string. This allows us to supply that same T type to our next values.
If our observable emits strings, then our value will have the type string.
This allows us to use this function generically with all different types of observable streams, whilst still retaining accurate type information.
Now to use our utility, instead of doing this to set up a reducer:
this.checklistsLoaded$.pipe(takeUntilDestroyed()).subscribe({ next: (checklists) => this.state.update((state) => ({ ...state, checklists, loaded: true, })), error: (err) => this.state.update((state) => ({ ...state, error: err })), });We could do this:
reducer( this.checklistsLoaded$, (checklists) => this.state.update((state) => ({ ...state, checklists, loaded: true, })), (error) => this.state.update((state) => ({ ...state, error })) );Now all we have to do is pass reducer our source and the functions we want
to use to update the state.
This removes a little bit of the boilerplate for us, which is nice. We could
take this further — we are still making a this.state.update call in all of our
reducers. This would be a good candidate for refactoring out into our reusable
utility.
This might be a good exercise in building something for the sake of learning,
but it’s also a case of reinventing the wheel. Luckily for us, there is
a fantastic utility available in the ngxtension library for Angular called
connect.
In the following lesson, we are going to take a look at using this connect
utility to handle our reducers, which does basically everything we want. To give
you a sense of why I mentioned not reinventing the wheel, just take a look at
the source code for the connect utility we will be using:
import { DestroyRef, Injector, type WritableSignal } from '@angular/core';import { takeUntilDestroyed } from '@angular/core/rxjs-interop';import { assertInjector } from 'ngxtension/assert-injector';import { Subscription, isObservable, type Observable } from 'rxjs';
type PartialOrValue<TValue> = TValue extends object ? Partial<TValue> : TValue;type Reducer<TValue, TNext> = ( previous: TValue, next: TNext) => PartialOrValue<TValue>;
type ConnectedSignal<TSignalValue> = { with<TObservableValue extends PartialOrValue<TSignalValue>>( observable: Observable<TObservableValue> ): ConnectedSignal<TSignalValue>; with<TObservableValue>( observable: Observable<TObservableValue>, reducer: Reducer<TSignalValue, TObservableValue> ): ConnectedSignal<TSignalValue>; subscription: Subscription;};
export function connect<TSignalValue>( signal: WritableSignal<TSignalValue>, injectorOrDestroyRef?: Injector | DestroyRef): ConnectedSignal<TSignalValue>;export function connect< TSignalValue, TObservableValue extends PartialOrValue<TSignalValue>>( signal: WritableSignal<TSignalValue>, observable: Observable<TObservableValue>, injectorOrDestroyRef?: Injector | DestroyRef): Subscription;export function connect<TSignalValue, TObservableValue>( signal: WritableSignal<TSignalValue>, observable: Observable<TObservableValue>, reducer: Reducer<TSignalValue, TObservableValue>, injectorOrDestroyRef?: Injector | DestroyRef): Subscription;export function connect( signal: WritableSignal<unknown>, ...args: [ (Observable<unknown> | (Injector | DestroyRef))?, (Reducer<unknown, unknown> | (Injector | DestroyRef))?, (Injector | DestroyRef)? ]) { const [observable, reducer, injectorOrDestroyRef] = parseArgs(args);
if (observable) { let destroyRef = null;
if (injectorOrDestroyRef instanceof DestroyRef) { destroyRef = injectorOrDestroyRef; // if it's a DestroyRef, use it } else { const injector = assertInjector(connect, injectorOrDestroyRef); destroyRef = injector.get(DestroyRef); }
return observable.pipe(takeUntilDestroyed(destroyRef)).subscribe((x) => { signal.update((prev) => { if (typeof prev === 'object' && !Array.isArray(prev)) { return { ...prev, ...((reducer?.(prev, x) || x) as object) }; }
return reducer?.(prev, x) || x; }); }); }
return { with(this: ConnectedSignal<unknown>, ...args: unknown[]) { if (!this.subscription) { this.subscription = new Subscription(); } else if (this.subscription.closed) { console.info(`[ngxtension connect] ConnectedSignal has been closed.`); return this; } this.subscription.add( connect( signal, ...(args as any), injectorOrDestroyRef ) as unknown as Subscription ); return this; }, subscription: null!, } as ConnectedSignal<unknown>;}
function parseArgs( args: [ (Observable<unknown> | (Injector | DestroyRef))?, (Reducer<unknown, unknown> | (Injector | DestroyRef))?, (Injector | DestroyRef)? ]): [ Observable<unknown> | null, Reducer<unknown, unknown> | null, Injector | DestroyRef | null] { if (args.length > 2) { return [ args[0] as Observable<unknown>, args[1] as Reducer<unknown, unknown>, args[2] as Injector | DestroyRef, ]; }
if (args.length === 2) { const [arg, arg2] = args; const parsedArgs: [ Observable<unknown>, Reducer<unknown, unknown> | null, Injector | DestroyRef | null ] = [arg as Observable<unknown>, null, null]; if (typeof arg2 === 'function') { parsedArgs[1] = arg2; } else { parsedArgs[2] = arg2 as Injector | DestroyRef; }
return parsedArgs; }
const arg = args[0]; if (isObservable(arg)) { return [arg, null, null]; }
return [null, null, arg as Injector | DestroyRef];}Thanks for checking out the preview of this lesson!
You do not have the appropriate membership to view the full lesson. If you would like full access to this module you can view membership options (or log in if you are already have an appropriate membership).