Watch the companion video for this article
I ported the Angular Ionic Movies app from NGXS to StateAdapt, and the state management layer shrank by 62%.

Event sources
StateAdapt's first major win is in how it models events. Here's an NGXS action definition:
export class AddMovie {
static readonly type = '[Movies] Add movie';
constructor(public payload: Movie) {}
}
The equivalent event source in StateAdapt looks like this:
addMovie$ = new Source<Movie>('addMovie$');
State transitions
This NGXS codebase relied on an NGXS Labs plugin for wiring actions to handlers defined outside the state class. That wiring looks like this:
attachAction(MovieState, AddMovie, addMovie(moviesService));
StateAdapt's syntax is close:
addMovies: this.addMovieRequest.success$,
The extra observable was produced by a stream that fed addMovie$ into StateAdapt's HTTP helpers, turning it into standard HTTP observables:
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 defined inside a state adapter:
add: (state, movie: Movie) => [...state, movie],
Why is it named addMovies and not add? I'll get to that in the next section.
StateAdapt deliberately keeps pure functions isolated from async code and side-effects. That separation pays off in reusability, as you'll see shortly.
The NGXS action handler, by contrast, mixes side-effects and async logic into one function:
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])
})
);
}
})
);
};
It could have been written more tersely, but the sheer depth of nesting that some developers prefer is a symptom of how many layers of function calls are required.
State adapters
The name addMovies wasn't my choice. StateAdapt derived it automatically by merging the moviesAdapter into a higher-level state shape called 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
})();
Because add is a valid state change on moviesAdapter, catalogAdapter exposes it as addMovies. Every state change follows the naming pattern <first word><Property name><Rest of words>.
I'm currently working on a createListAdapter factory that produces state changes like addOne and addMany. Those become addMoviesOne and addMoviesMany—still a bit clunky, but acceptable.
Why bother?
You might have noticed that the previous snippet referenced moviesAdapter twice. That happened because, while rewriting NGXS action handlers as RxJS operators and adapter methods, I started typing something inside the favoritesAdapter that felt oddly familiar:
add: (state, movie: Movie) => [...state, movie],
I had already written that logic in moviesAdapter. Both movies and favorites were typed as Movie[]. Why wouldn't they share the same kinds of state changes and derived state?
State adapters are best understood as companions to data types. It's reminiscent of object-oriented programming, but with immutability baked in, and messages are expressed as declarative, self-contained entities rather than imperative commands pointing downstream. That distinction matters a great deal. It enables clean separation of concerns—something classic OOP struggles with whenever asynchrony enters the picture.
I'll expand on this in a future post. The potential is genuinely exciting.
So, what looked like 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])
})
);
};
became just two event sources and one minimal state change in StateAdapt. (Favorites were persisted to localStorage, not the server.)
Was reuse possible here? Absolutely. Boilerplate is itself repetitive, and that repetition tends to hide repetitive business logic underneath.
Miscellaneous observations
Forms plugin
The NGXS implementation relied on its forms plugin. With StateAdapt, that functionality had to be written by hand, which contributed a handful of extra lines to the codebase.
Local storage plugin
Since StateAdapt doesn't yet ship with a local storage plugin, a custom state sanitizer had to be defined manually:
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);
In a typical scenario, the setup would be as simple as:
import { defaultStoreProvider } from '@state-adapt/angular';
Imperative components
The refactoring opportunities inside the components were extensive, and the Angular ecosystem offers surprisingly limited support for declarative patterns. Dialog handling is a prime example of this gap:
| 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 |
(A wrapper I built around Angular Material's dialog component is available as a gist — feel free to drop it into your own project.)
The result was a considerable amount of awkward imperative logic parked inside 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
)
});
In a framework that prioritizes a sensible, declarative flow, this would normally be expressed as:
- An event fires
- State updates accordingly
- The DOM re-renders based on that state
NGXS actually encouraged other questionable patterns too, thanks to dispatch() returning an observable and making it convenient to break unidirectional data flow:
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 is essentially a loading flag by another name. The whole component functions as an action handler, which is really how code looks when no state management library is in play at all.
There's no dependable unidirectionality in this application. The liberties taken in this codebase — and in other NGXS projects I've encountered — leave you with few guarantees about where the source of any given bug might live.
This is exactly why I gravitate toward declarative programming. It takes effort to adapt to initially, but once it clicks, you can look directly at the problematic entity itself and spot what's off with it — as opposed to tracking down some callback or action handler that could be hiding anywhere — which makes debugging noticeably faster.
Wrapping up
The full comparison is captured in this commit.
NGXS is a welcoming library. Its maintainers are approachable and responsive, the documentation and community resources are plentiful, and a healthy pool of developers can help when you're stuck. While the boilerplate sits in the same range as NgRx/Store, its "progressive state management" stance means NGXS supports the imperative patterns you're already comfortable with, while also opening the door to deeper RxJS reactivity as your comfort and fluency grow.
StateAdapt sits at the opposite end of the spectrum from NGXS. In fact, it was an NGXS project that first sparked the idea, and the initial prototype was drafted inside an NGXS state file. With a philosophy of "progressive reactivity," the aim is for your code to stay as close to fully declarative as possible, even if that demands some mental stretching early on. With practice, thinking reactively becomes as natural as imperative thinking, and the payoff in reduced spaghetti code pays off substantially over time.
That said, StateAdapt isn't finished yet. To feel confident releasing version 1.0, I need to test it across more real-world projects. If you see promise in it, a star would mean a lot, and I'd genuinely appreciate any feedback if you give it a try.
Thanks for reading!
