This series explores how we can keep code declarative as we adapt features to progressively higher levels of complexity.
Progressive Reactivity Rule #3
Wrap imperative APIs with declarative ones.
Imperative APIs are better than no APIs, and they tend to precede declarative APIs. Why is that, and what can we do about it?
Declarative code is more comprehensible than imperative code, as you saw in the example in the first article in this series. But in order to write comprehensible code, you have to comprehend what you're writing. For example, it's easy to declare a variable with a bad name, but writing a comprehensible name requires a comprehension of what that variable represents.
When developers solve difficult or novel problems (like creating a new framework) they lean towards an imperative style of programming, because it is easier and they are used to thinking imperatively. The imperative APIs cause applications that use them to become more imperative as well, which then grow into incomprehensible balls of spaghetti code. Inevitably the community creates declarative wrappers for the APIs, and then finally the APIs themselves are changed into something more declarative.
So, we should not be surprised or upset that Angular has plenty of imperative APIs. AngularJS was an early SPA framework, and was solving difficult and novel problems. In fact, AngularJS brought reactivity to DOM updates with change detection, and it was that very mechanism that created the problems that ended up being solved with the imperative APIs. And then Angular tried to maintain some continuity with AngularJS, so it inherited much of that imperative style.
Angular is unfairly disregarded by many developers who moved to React or another framework (yes, framework) after AngularJS, and have no actual clue about what modern Angular looks like. However, other modern frameworks have made progress that Angular has not been able to make. Although they are largely ignorant of the benefits of RxJS, they do have more many more declarative APIs than Angular, and that sometimes make me jealous.
Modals
My favorite example is modals. In the Angular ecosystem, it seems a given that you have to open dialogs with an imperative .open()
command. But it doesn't have to be this way. Literally every other component library in literally every other modern front-end framework has declarative dialogs that react to state, instead of depending on out-of-context imperative commands to open them. Don't believe me? Well, even if you do, I want to actually show you. Let's look at Vue, React, Svelte, Preact, Ember, Lit, Alpine and SolidJS. Feel free to skip to Angular. It's a long list.
Vue.js
Vuetify
Quasar
Bootstrap Vue
React
Material UI
Ant Design
React Bootstrap
Svelte
Top Svelte Component Libraries
Svelte Material UI
SvelteStrap
Smelte
Preact
It was hard finding component libraries for Preact, to be honest. I've included the only one I found with documentation that was easy to find.
Preact Material
I believe simply rendering the Dialog
element opens it, so that is declarative.
Ember
Ember Paper
Ember Frontile
SL Ember Components
Lit
Lit is for creating web components, so I will just look at web component libraries for this one.
PolymerElements Paper Dialog
Vaadin Web Components
Wired Elements
Alpine
I only found this example:
SolidJS
SolidJS is an amazing library, but it is still very new. I couldn't find many component libraries with dialogs. But there is this example on SolidJS's own website, and it shows a modal being opened declaratively. I guarantee that any component library that pops up for SolidJS will be declarative like this.
I did find this unofficial component library for Headless UI:
Angular
Finally, Angular. Top Angular Component Libraries
Angular Material
Ah, Angular Material, the official component library for Angular. Let's see how to use dialogs:
Okay, so it's calling a method. That's breaks our Rule 2. What does that method do?
This is the first component library out of the 20+ for 7+ frameworks I've seen that opens dialogs imperatively.
The 2nd and 3rd libraries are also imperative.
ngx-bootstrap
ng-bootstrap
To summarize,
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 |
But you don't have to suffer.
Again, we should not be surprised or upset that Angular has plenty of imperative APIs. AngularJS was an early SPA framework, and was solving difficult and novel problems.
But guess what else? The Angular team is not the pope. You can have an opinion, even if it goes against what the community assumes is correct because it is the default solution handed down from the beloved Angular team.
So I created a wrapper for Angular Material's dialog component that you can use like this:
<app-dialog
[component]="AnyComponent"
[open]="open$ | async"
></app-dialog>
GO TO THAT GIST AND COPY IT INTO YOUR CODEBASE RIGHT NOW.
Stop living in pain. Enjoy declarative dialogs.
You should be proactive and wrap ALL imperative APIs in declarative APIs.
Other Imperative APIs in Angular
Dialogs are not the only place Angular has imperative APIs. We still have to write imperative code for component lifecycle hooks. Angular Reactive Forms should be called Angular Imperative Forms. There are others, too. I've written in the past about how to deal with these other imperative Angular APIs. Careful, it's a premium Medium article. Here's the link.
Side-Effects
Side-effects do not have to be be imperative. The entire DOM is technically a side-effect, but in Angular we (usually) write declarative templates for UI state. So why can't we handle all side-effects declaratively?
Dialogs are examples of APIs that end up outputting something to the user, but what about more behind-the-scenes APIs like localStorage
?
For localStorage
, reading state can be done synchronously, so that's not an issue when initializing state. The problem is when we need to push data into it because it has to be done imperatively with localStorage.setItem()
.
Rather than calling setItem
in a callback function, we wish localStorage
itself could declare its own state over time. Something like this would be nice:
this.localStorageService.connect('key', this.state$);
But what subscribes? What unsubscribes? And what if state$
chains off of an http$
observable? Do we want to trigger it immediately by subscribing? Clearly local storage should not be a primary subscriber to what it's watching. But RxJS does not support "secondary" subscribers, or passive listening of any kind. So, I see 2 possible solutions:
-
Tack on a
tap
tostate$
's declaration. So everything that subscribes to
state$ = defineStateSomehow().pipe( tap(s => localStorage.setItem('s', JSON.stringify(s))), );
automatically triggers our callback function every time state$
updates (if it has subscribers).
-
Create a wrapper component like we did for dialogs, so we can use it like this:
<app-local-storage key="key" [item]="state$ | async" ></app-local-storage>
Is this weird? It kind of is. But it's so convenient. And if we want we can wrap that element in an
*ngIf
that controls whenapp-local-storage
subscribes.
My thoughts are evolving on this, but #1 is still imperative, with that callback function passed into tap()
. So I would personally prefer #2. But it might be a syntactic dead end that we'd have to undo if we encountered an unexpected scenario that needed more flexibility.
Other imperative APIs can return observables, so they can be expressed reactively much more easily. For example, a POST request can be done like this:
submit$ = new Subject<void>();
submissionSuccessful$ = this.submit$.pipe(
withLatestFrom(this.form.valueChanges),
concatMap(([, data]) => this.apiService.submit(data)),
);
Most of you are probably used to having a submit
method instead. But that is imperative when it could be reactive. Why do you think $http.post
returns an observable? Because POST requests return values, and it's not just so they can be lost in the depths of our app. We should probably have a wrapper for a toast component so we can show the user that their submission was successful:
<app-toast
[message]="submissionSuccessful$ | async"
duration="3000"
></app-toast>
This is really nice. Hopefully Angular component libraries start providing declarative APIs for all of their components.
Summary
Imperative APIs are better than no APIs. We are grateful for developers who work on the difficult problems frameworks are solving. We are not surprised that the first APIs that solve problems turn out to be imperative.
But we want to code declaratively. So, when we encounter an imperative API, our first instinct is to wrap it inside a declarative API. By doing this, we make it easier for our application code to stay clean and declarative as it grows in complexity.
Top comments (1)
Got to learn something new. Thanks!