This series explores how we can keep code declarative as we adapt features to progressively higher levels of complexity.


divider

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

Vue—Vuetify Dialogs

Quasar

Vue—Quasar Dialogs

Bootstrap Vue

Vue—Bootstrap Vue Modals

React

React offers a wide selection of UI frameworks; here are the Top React Component Libraries.

Material UI

React—MUI Dialogs

Ant Design

React—Ant Design Modals

React Bootstrap

React—React Bootsrap Modals

Svelte

Svelte's approach can be seen in its Top Svelte Component Libraries.

Svelte Material UI

Svelte—Svelte Material UI Dialogs

SvelteStrap

Svelte—SvelteStrap Modals

Smelte

Svelte—Smelte Modals

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

Preact—Preact Material Modals

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—Ember Paper Dialog

Ember Frontile

Ember—Frontile Modal

SL Ember Components

Ember—SL Ember Components Modal

Lit

Since Lit is centered around web components, it makes sense to look at web component libraries.

PolymerElements Paper Dialog

PolymerElements Paper Dialog

Vaadin Web Components

Vaadin Web Components Dialog

Wired Elements

Wired Elements Dialog

Alpine

For Alpine, I came across this specific example showing a declarative modal:

Alpine Dialog 2

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.

SolidJS Dialog

I also located this unofficial Headless UI library for SolidJS:

unofficial component library for Headless UI—Dialog

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?

Angular Material—Dialog HTML

Right, it requires a method call. This seems to violate the Rule 2 from our earlier discussion. But what happens inside that method?

Angular Material—Dialog TypeScript

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

Angular—ngx-bootstrap Modal

ng-bootstrap

Angular—ng-bootstrap Modal


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:

  1. Attach a tap operator to state$.

    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.

  1. 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 *ngIf directive.

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.

divider