This series explores how we can keep code declarative as we adapt features to progressively higher levels of complexity.
Progressive Reactivity Rule #3
Turn imperative APIs into declarative ones.
Having an imperative API is still a step up from having none at all, and more often than not, declarative APIs arrive only after their imperative counterparts have been around for a while. What drives this pattern, and how should we respond?
Code written declaratively is easier to follow than code written imperatively, as demonstrated in the opening piece of this series. Yet crafting declarative code demands genuine understanding of the underlying logic — you cannot name a variable clearly, for instance, unless you truly grasp what it represents. This makes imperative writing the quickest route when the problem at hand is unfamiliar or complex.
When someone is tackling a hard or unprecedented problem — like designing a new framework — they naturally gravitate toward an imperative approach. It fits how they think and is simply easier. Once an imperative API is in place, the applications built on top of it tend to mirror that style, gradually turning into unreadable tangles of code. Over time, the community steps in and builds declarative wrappers around those APIs, and eventually the original APIs themselves get reworked into a declarative form.
That progression should make it no surprise that Angular is full of imperative APIs. AngularJS was venturing into uncharted territory as an early SPA framework. It also introduced reactive DOM updates via change detection — a mechanism that itself spawned the very problems which imperative APIs were later created to address. When Angular came around, it aimed for familiarity and continuity with AngularJS, which meant inheriting a hefty dose of that imperative heritage.
Angular is often unfairly dismissed by developers who left for React or other libraries — yes, libraries — after AngularJS and never took the time to see what modern Angular actually offers. That is not to say Angular has kept pace on all fronts. Some newer frameworks have pushed forward in ways Angular has not fully matched. Even if they largely overlook the power of RxJS, they come with far more declarative APIs. That is something I occasionally find myself envious of.
Modals
Dialogs are where this distinction hits home most clearly for me. In the Angular world, it often feels like calling an imperative .open() method is the only path to displaying a modal. This doesn't align with how other modern frameworks handle it, where dialogs are typically declarative and react to the state of your application rather than relying on commands issued from scattered parts of your codebase. You might be skeptical, but the evidence is clear. Let's examine how Vue, React, Svelte, Preact, Ember, Lit, Alpine and SolidJS handle this, and then we'll circle back to what Angular does. This is a long list, so feel free to jump straight to the Angular section if you're pressed for time.
Vue.js
Let's start with Vue and its ecosystem of Top Vue Component Libraries.
Vuetify
Quasar
Bootstrap Vue
React
React offers a wide selection of UI frameworks; here are the Top React Component Libraries.
Material UI
Ant Design
React Bootstrap
Svelte
Svelte's approach can be seen in its Top Svelte Component Libraries.
Svelte Material UI
SvelteStrap
Smelte
Preact
Admittedly, tracking down component libraries for Preact was more of a challenge. I've included the one I did find that had reasonably discoverable documentation.
Preact Material
Based on my reading, simply including the Dialog element in your markup is enough to show it, which is inherently declarative.
Ember
For Ember, we can look at the Top Ember Component Libraries.
Ember Paper
Ember Frontile
SL Ember Components
Lit
Since Lit is centered around web components, it makes sense to look at web component libraries.
PolymerElements Paper Dialog
Vaadin Web Components
Wired Elements
Alpine
For Alpine, I came across this specific example showing a declarative modal:
SolidJS
SolidJS is a great framework, but given its relative youth, there isn't a huge ecosystem of libraries with dialogs yet. There is, however, this example in the official SolidJS docs that illustrates opening a modal with a simple conditional. It's safe to predict that any SolidJS component library that emerges will follow this declarative pattern.
I also located this unofficial Headless UI library for SolidJS:
Angular
Now for Angular. Here are the Top Angular Component Libraries.
Angular Material
Let's start with Angular Material, the de facto standard for Angular components. How does it tell you to use a dialog?
Right, it requires a method call. This seems to violate the Rule 2 from our earlier discussion. But what happens inside that method?
This is the only library out of the 20+ I've examined across 7+ frameworks that takes this imperative approach to opening a dialog.
The following two libraries follow the same imperative pattern.
ngx-bootstrap
ng-bootstrap
To put it all together
| 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 |
Imperative APIs: Not Something You Have to Live With
It's understandable that Angular exposes a wide range of imperative APIs. AngularJS emerged as an early single-page application framework, tackling novel and demanding challenges for its time.
However, you are not bound by the Angular team's design decisions. Just because a certain approach is the go-to solution from the team doesn't mean it's the only or best way. You are free to form your own conclusions.
With that in mind, I've developed a declarative wrapper for Angular Material's dialog component, which can be used in this manner:
<app-dialog
[component]="AnyComponent"
[open]="open$ | async"
></app-dialog>
I strongly recommend you grab that gist and integrate it into your project right away.
There’s no need to accept the status quo. Embrace declarative dialogs for a more pleasant development experience.
A proactive approach is to wrap every imperative API you encounter with a declarative interface.
Identifying Other Imperative Hooks in Angular
Dialogs aren't the sole offenders. Component lifecycle hooks require imperative logic. And one could argue that Angular Reactive Forms might be more aptly named "Angular Imperative Forms." I've previously delved into strategies for handling these and other imperative APIs. A note of caution: that article is on Medium and is premium content. You can find it here.
Handling Side-Effects Declaratively
Side-effects don't inherently need to be managed imperatively. Consider the DOM itself; it's a side-effect, yet in Angular we use declarative templates to represent UI state. So, the question arises: why should other side-effects be any different?
Dialogs are a user-facing side-effect, but what about APIs like localStorage that operate more behind the scenes?
For localStorage, reading data is synchronous, which is fine when you're initializing state. The challenge emerges when you need to persist changes, forcing you into an imperative localStorage.setItem() call.
Instead of invoking setItem within a callback, it would be more elegant if localStorage could define its own state flow. An ideal scenario would look like this:
this.localStorageService.connect('key', this.state$);
But this raises questions: who manages the subscription? What about the cleanup? And if state$ is derived from an http$ observable, should we subscribe immediately and trigger it? It seems incorrect for local storage to be a primary subscriber to its data source. RxJS lacks a concept for "secondary" or passive subscribers. Therefore, I see two potential approaches:
-
Attach a
tapoperator tostate$.
state$ = defineStateSomehow().pipe( tap(s => localStorage.setItem('s', JSON.stringify(s))), );
This would execute our side-effect whenever state$ emits, provided it has existing subscribers.
-
Create a wrapper component, similar to what we did with dialogs, enabling usage like this:
<app-local-storage key="key" [item]="state$ | async" ></app-local-storage>This might seem unconventional, but it's incredibly straightforward. We could even control its subscription lifecycle by placing it within an
*ngIfdirective.
My perspective is still forming, but option #1 still feels imperative due to the callback in tap(). I lean towards option #2 for its declarative nature. It could pose challenges if a more flexible scenario arises, but it's a viable syntactic path for now.
Many imperative APIs can provide observables, making them easier to integrate reactively. A POST request, for instance, can be constructed as follows:
submit$ = new Subject<void>();
submissionSuccessful$ = this.submit$.pipe(
withLatestFrom(this.form.valueChanges),
concatMap(([, data]) => this.apiService.submit(data)),
);
Many developers are accustomed to a submit method, but that's a more imperative pattern. The fact that $http.post returns an observable is a design choice—POST requests return values that are too often lost in the app's background. To properly utilize these results, a wrapper for a toast notification would be beneficial:
<app-toast
[message]="submissionSuccessful$ | async"
duration="3000"
></app-toast>
This is a significant improvement. It would be great to see component libraries starting to provide declarative APIs for all their offerings.
Concluding Thoughts
Imperative APIs are preferable to having no APIs at all. We appreciate the developers solving the complex problems that frameworks tackle. It’s not surprising that the initial solutions often take an imperative form.
However, our goal is to write declarative code. When we face an imperative API, our reflex should be to encapsulate it within a declarative wrapper. This practice helps keep our application logic clean and declarative, even as complexity grows.
























