Installing Ngxtension
Alright, let’s calm things down a bit now. We are actually going to use the
connect function in this lesson, which is considerably easier than
understanding how to build the thing.
The connect function is just one of the utilities found in the
ngxtension
library. Although it might seem wasteful to install an entire library for just
one function, the library is structured to use secondary entry points such
that when your Angular application is built tree-shaking will take care of
removing code for all the rest of the functions that you aren’t using.
In this lesson, we will just be talking about the general usage of the connect
function and in the following lessons we will refactor some of our existing code
with it.
If you want, you can install this in the Quicklists application, or any other demo application, and have a play around with it:
ng add ngxtensionFirst, we are going to get the basic idea of how this works, and how it simplifies things for us. If you read through the entirety of the last lesson, then you might already have a decent idea of how this can be used.
Then in the coming lessons we will refactor the applications we have already built with this new approach — don’t worry, it’s actually quite easy because this utility is very much in line with the philosophy we have been using.
For our last application build, we will use this approach from the beginning. If you’re feeling comfortable with the general ideas we have been using for state management, then I would advise that you also just generally use this approach from now on as it removes a whole lot of boilerplate for us. If you don’t like it, feel free to continue using the approach we have been using.
Using the connect function
Let’s suppose we have a state signal that looks like this:
export type LoginStatus = 'pending' | 'authenticating' | 'success' | 'error';
interface LoginState { status: LoginStatus;} // state private state = signal<LoginState>({ status: 'pending', });Let’s also suppose we have some sources that looks like this:
// sources error$ = new Subject<any>(); login$ = new Subject<Credentials>();
userAuthenticated$ = this.login$.pipe( // ...snip );With our normal approach we might set up some reducers like this:
constructor() { // reducers this.userAuthenticated$ .pipe(takeUntilDestroyed()) .subscribe(() => this.state.update((state) => ({ ...state, status: 'success' })) );
this.login$ .pipe(takeUntilDestroyed()) .subscribe(() => this.state.update((state) => ({ ...state, status: 'authenticating' })) );
this.error$ .pipe(takeUntilDestroyed()) .subscribe(() => this.state.update((state) => ({ ...state, status: 'error' })) ); }You’ve seen this plenty of times by now. In this case, we are updating the
status state based on which source is emitting.
Let’s see what this looks like with connect:
import { connect } from 'ngxtension/connect'; 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$); }Pretty clean, right? What is happening here is that the connect function
allows us to connect any signal with any observable. Whatever that
observable emits will be set into the signal. The connect handles
subscribing and unsubscribing for us.
We have pretty much left our reducers exactly the same as they were, except
rather than calling this.state.update and returning the next state, we map
the observable to whatever we want the next state to be.
We could just do this:
constructor() { // reducers connect(this.state).with( this.userAuthenticated$.pipe(map(() => ({ status: 'success' as const }))) ); connect(this.state).with( this.login$.pipe(map(() => ({ status: 'authenticating' as const }))) ); connect(this.state).with( this.error$.pipe(map(() => ({ status: 'error' as const }))) ); }In this example, we just connect each observable stream with this.state
separately — this will function the same. Perhaps you like this syntax better
— you can use this style if you want.
But, personally, I prefer to just have all the values emit on a single
nextState$ stream:
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 }))));We use the RxJS merge operator to combine all of these streams into one
stream, and then we supply that in a single connect call:
connect(this.state).with(nextState$);Notice that we have had to add as const to our strings. This is just to deal
with TypeScript. It is expecting status to be of the type:
export type LoginStatus = 'pending' | 'authenticating' | 'success' | 'error';However, if we try to set the status to just 'success' it will not work.
Even though 'success' matches one of the values in our union type, TypeScript
considers the type of 'success' to just be a generic string, not literally
the specific string 'success'. By adding as const we are enforcing that the
value will never change from 'success' and this makes TypeScript comfortable
that it satisfies the LoginStatus type.
This is a particularly nice and clean example — of course, things are not always so clean and nice. We have reducers in other circumstances that deal with more complex situations, like ones that require access to the previous state.
In the next lesson, we will be refactoring the services in the Quicklists application to use this approach and this will touch on those more complex situations.