3d cartoon hands holding a phone

Unlock full course by purchasing a membership

Lesson 2

Building Your Own State Utility

EXTENDED

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];
}
EXTENDED
Key

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).