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.
NOTE: If you do not already have the Quicklists application on your computer you can find it here
Create a file at
src/app/shared/utils/reducer.ts
and add the following:
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];
}