This series explores how we can keep code declarative as we adapt features to progressively higher levels of complexity.
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.
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.
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.
I believe simply rendering the
Dialog element opens it, so that is declarative.
Lit is for creating web components, so I will just look at web component libraries for this one.
I only found this example:
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.
Finally, Angular. Top Angular Component Libraries
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.
|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||---|
|Angular||❌ Imperative||❌ Imperative||❌ Imperative|
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.
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 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, 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
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:
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
state$'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
*ngIfthat controls when
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.
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.