YouTube video for this article
I implemented the Angular Ionic Movies app with StateAdapt and the state management code decreased by 62% from what it was using NGXS.
Actions
The first way StateAdapt is more minimal is in how it expresses event sources. This is an action in NGXS:
export class AddMovie {
static readonly type = '[Movies] Add movie';
constructor(public payload: Movie) {}
}
This is the same event source in StateAdapt:
addMovie$ = new Source<Movie>('addMovie$');
State changes
This particular NGXS project was using an NGXS Labs plugin that allows you to connect actions with action handlers defined outside the state file. That looks like this:
attachAction(MovieState, AddMovie, addMovie(moviesService));
That is pretty similar to StateAdapt's syntax:
addMovies: this.addMovieRequest.success$,
That extra observable came from a stream that transformed addMovie$
into some standard HTTP observables using StateAdapt's HTTP utilities:
addMovieRequest = getHttpSources(
'[Add Movie]',
this.addMovie$.pipe(
switchMap(({ payload }) => {
payload.poster = // COPIED—Don't mutate!
payload.poster === ''
? 'https://in.bmscdn.com/iedb/movies/images/website/poster/large/ela-cheppanu-et00016781-24-03-2017-18-31-40.jpg'
: payload.poster;
return this.moviesService.addMovie(payload);
})
),
(res) => [!!res, res, 'Error']
);
addMovies
is a pure function inside a state adapter, and it looks like this:
add: (state, movie: Movie) => [...state, movie],
Note: add
gets turned into addMovies
for a reason I'll explain in the next section.
StateAdapt keeps pure functions separate from side-effects and async code as much as possible, and you'll see how that enables some awesome reusability soon.
On the other hand, the function NGXS's action maps to includes multiple concerns and side-effects:
export const addMovie =
(moviesService: MoviesService) =>
({ setState }: StateContext<MoviesStateModel>, { payload }) => {
payload.poster =
payload.poster === ''
? 'https://in.bmscdn.com/iedb/movies/images/website/poster/large/ela-cheppanu-et00016781-24-03-2017-18-31-40.jpg'
: payload.poster;
return moviesService.addMovie(payload).pipe(
catchError((x, caught) => {
return throwError(() => new Error(x));
}),
tap({
next: (result) => {
setState(
patch({
movies: append([result])
})
);
}
})
);
};
This could have been written more succinctly, but the fact that it can be nested so much (as some devs prefer) comes from how many layers of functions need to be called.
State adapters
addMovies
is an awkward name, and I did not choose it. StateAdapt generated it by joining the moviesAdapter
onto a higher state shape, CatalogStateModel
:
export interface CatalogStateModel {
movies: Movie[];
movieForm: MovieForm;
filter: Filter;
favorites: Movie[];
}
export const catalogAdapter = joinAdapters<CatalogStateModel>()({
movies: moviesAdapter,
movieForm: movieFormAdapter,
filter: createAdapter<Filter[]>()({ selectors: {} }),
favorites: moviesAdapter
})();
Since add
is an available state change on moviesAdapter
, catalogAdapter
gets it as addMovies
. Every state change name is split up as <first word><Property name><Rest of words>
.
However, I am working on a createListAdapter
function that will generate state change names like addOne
and addMany
, and so on, which are not quite as awkward when converted into addMoviesOne
and addMoviesMany
. Still a little awkward, but it's worth it.
Why?
You may have noticed that I have two references to moviesAdapter
in the last code snippet. This is because while I was busy converting NGXS action handlers into RxJS operators and adapter methods I started typing something familiar inside the favoritesAdapter
:
add: (state, movie: Movie) => [...state, movie],
I realized I had already implemented that in the moviesAdapter
... In fact, both favorites
and movies
had type Movie[]
. Why shouldn't they have similar patterns of state changes and derived state?
State adapters should really be thought of as companions to data types. This is sort of like object-oriented programming, except with immutability, and messages are defined as independent, self-describing (declarative) entities instead of imperative, forward/downstream-looking commands. This makes a massive difference. This allows proper separation of concerns, whereas traditional OOP does not, for anything asynchronous.
More on this in a future article. But I'm really excited about the possibilities.
Anyway, so that's why 2 event sources and a single, simple state change in StateAdapt came from this in NGXS:
export const addMovie =
(moviesService: MoviesService) =>
({ setState }: StateContext<MoviesStateModel>, { payload }) => {
payload.poster =
payload.poster === ''
? 'https://in.bmscdn.com/iedb/movies/images/website/poster/large/ela-cheppanu-et00016781-24-03-2017-18-31-40.jpg'
: payload.poster;
return moviesService.addMovie(payload).pipe(
catchError((x, caught) => {
return throwError(() => new Error(x));
}),
tap({
next: (result) => {
setState(
patch({
movies: append([result])
})
);
}
})
);
};
export const favoriteMovie = (
{ setState }: StateContext<MoviesStateModel>,
{ payload }
) => {
setState(
patch({
favorites: append([payload])
})
);
};
(Favorites were stored in localStorage
, not the server.)
Could this have been reused? Yes. One thing I've noticed is that boilerplate, being repetitive itself, obscures repetitive business logic.
Other notes
Forms plugin
NGXS had a forms plugin it was using. StateAdapt had to code this from scratch, which added a few lines.
Local storage plugin
StateAdapt doesn't have a local storage plugin yet, so I had to define a custom state sanitizer like this:
import { actionSanitizer, stateSanitizer } from '@state-adapt/core';
import { provideStore } from '@state-adapt/angular';
const enableReduxDevTools = (window as any).__REDUX_DEVTOOLS_EXTENSION__?.({
actionSanitizer,
stateSanitizer: (state: any) => {
const newState = stateSanitizer(state);
localStorage.setItem('@@STATE', JSON.stringify(newState));
return newState;
}
});
export const storeProvider = provideStore(enableReduxDevTools);
Normally this would just be
import { defaultStoreProvider } from '@state-adapt/angular';
Imperative components
There was so much refactoring I wanted to do in the components... the Angular ecosystem really sucks at providing declarative tools. For example, declarative dialogs are almost completely lacking:
Framework | Library 1 | Library 2 | Library 3 |
---|---|---|---|
Vue | ✅ Declarative | ✅ Declarative | ✅ Declarative |
React | ✅ Declarative | ✅ Declarative | ✅ Declarative |
Svelte | ✅ Declarative | ✅ Declarative | ✅ Declarative |
Preact | ✅ Declarative | ✅ Declarative | ✅ Declarative |
Ember | ✅ Declarative | ✅ Declarative | ✅ Declarative |
Lit | ✅ Declarative | ✅ Declarative | ✅ Declarative |
SolidJS | ✅ Declarative | ✅ Declarative | --- |
Alpine | ✅ Declarative | --- | --- |
Angular | ❌ Imperative | ❌ Imperative | ❌ Imperative |
(I made a wrapper for Angular Material's dialog component that you should go copy and paste into your project right now.)
The result was a lot of ugly imperative code in the component:
this.actions$.pipe(ofActionSuccessful(AddMovie)).subscribe({
next: () => {
this.modalCtrl.dismiss();
this.iziToast.success('Add movie', 'Movie added successfully.');
},
error: (err) =>
console.log(
'HomePage::ngOnInit ofActionSuccessful(AddMovie) | method called -> received error' +
err
)
});
The way this would typically be done in a framework that encourages sanity, is something like this:
- Event fires
- State changes
- DOM reflects state
There were similar atrocities made convenient by NGXS's dispatch()
returning an observable to allow easy non-unidirectional logic:
this.store
.dispatch(new FetchMovies({ start: start, end: end }))
.pipe(withLatestFrom(this.movies$))
.subscribe({
next: ([movies]) => {
setTimeout(() => {
this.showSkeleton = false;
}, 2000);
},
error: (err) =>
console.log(
'HomePage::fetchMovies() | method called -> received error' + err
)
});
showSkeleton
sounds a lot like a loading state... Apparently this entire component is an action handler. This is how your code would look without a state management library.
There isn't any reliable unidirectionality in this app. The amount of freedom taken in this codebase (and in other NGXS projects I've seen) results in basically no guarantees of where you could find the cause of any given issue.
This is why I love declarative programming. It's hard to get used to at first, but once you are used to it, being able to directly inspect the thing that's wrong itself and see what's wrong with it—instead of some callback function or action handler that could literally be anywhere—really accelerates debugging.
Conclusion
For a full comparison, see the commit here.
NGXS is a very friendly library. The maintainers are extremely nice and helpful, and there’s a lot of material out there if you get stuck as well as a solid community of developers who can help you. And although the boilerplate is in the same ballpark as NgRx/Store, the philosophy of “progressive state management” means NGXS accommodates all of the imperative coding patterns you are familiar with, while also enabling higher levels of reactivity with RxJS as your skills and reactive habits develop.
StateAdapt is pretty much the complete opposite of NGXS. In fact, it was an NGXS project that first inspired it, and the original prototype was written in an NGXS state file. With a philosophy of “progressive reactivity”, the idea is that your code should always be as close to 100% declarative as possible, even if you have to bend your mind a little at first. But as you work at it, thinking reactively gets more and more as easy as thinking imperatively, and the benefits of reducing spaghetti code will pay massive dividends in the long run.
But StateAdapt is still a work in progress. I need to apply it to many more projects before I can have the confidence to release version 1.0. If you think it has potential, I'd appreciate a star, and I'd love for you to try it out and share your thoughts.
Thanks!
Top comments (0)