DEV Community

Cover image for 5 reasons to avoid imperative code
Mike Pearson for This is Learning

Posted on

5 reasons to avoid imperative code

YouTube

1. Inconsistent state

In an imperative app, events trigger event handlers, which are containers of imperative code. This imperative code is burdened with the responsibility of understanding and controlling features that often have nothing to do with the original event. Imperative code is written outside the context of the state it's controlling and all the other code that controls that state, which is scattered across other event handlers. This often results in naive update logic that fails to consider the whole picture or account for other downstream effects. This usually causes bugs from inconsistent state.

For example: This is a movies app with a search bar in the header. When you enter a search in, it calls an API and takes you to a page with the search results returned from the API.

Search Results

Many of you are already in the habit of programming this reactively: Just change the URL and let the search results page contain its own logic that reacts to the URL by fetching the search results requested.

But this feature was structured more imperatively, with the search submit handler being responsible for both fetching the results and redirecting.

This caused a bug where if you were on the search results page and refreshed, there would be no search results, because that feature was unable to react to the URL correctly by itself.

inconsistent-state-imperative

Imperative code is buggy by default: If any unforeseen event triggers a state change, downstream state will remain stale and inconsistent.

On the other hand, when downstream state is reactive, it doesn't matter what caused the state change above it. It will always react.

inconsistent-state-reactive

If you think setting the URL first and handling the API request on the route page is a special case, you simply haven't noticed yet that imperative code is the cause of almost all inconsistent state. Hunt down the origin of every bug and you'll find that imperative code can account for as much as 40% of the bugs.

2. State elevation

The movie search feature illustrates another problem with imperative code: State has to be elevated to a level where the imperative code from other features can control it. If event handlers need access to state from other features, that state cannot be local; it has to be global.

State Elevation—Imperative

This is where the original developer used NgRx/Component-Store. He created a global service/store that movie search submit handler could access and store the results of the API call.

The first issue with this is that it decoupled the search results state from the lifecycle of the component. If the search results observable was only used in the component that needed to react to it with the async pipe, it would have unsubscribed from that observable when navigating away. This would have either canceled the API request, or when revisiting the route it would have created a new request.

State Elevation—Reactive

Instead, there had to be extra logic to reset the search results, and in-flight requests were just left un-cancelled.

The second issue with this is that state accessible to every feature is potentially being changed by every feature, and you won't know until you check.

3. Large bundles

The rule of reactivity is that everything that can be downstream should be downstream. This is naturally lazy behavior.

The only framework I know of that makes taking advantage of this easy is Qwik. But even in Angular, it's easy to take advantage of code that's reactive to URL changes, because routes are very easy to lazy-load.

Unless you're using Qwik, every lazy-loaded code bundle probably contains the code of all of its event handlers. Since event handlers are often busy referencing and changing other features, this means those other features need to be included in this bundle too.

Bundle Sizes Imperative

So, if you can, try to have event handlers change the URL and have everything else react to that.

Bundle Sizes Reactive

4. Unnecessary complexity

State that's controlled externally by callback functions relies on those functions to control it in every scenario. This decentralization of control creates duplicated control. In this diagram from earlier, you can see that every new event will require 2 more arrows:

inconsistent-state-imperative

Every arrow that causes State Change 1 must be duplicated for State Change 2. This bloats the codebase.

But does the imperative code really have to be this way? Maybe we can improve it. Let's look at an example and try to make it DRY.

In this example, we'll have carModel as our State 1, and various derived states. We could create a central function for updating carModel called changeCarModel that includes an update to the derived states.

  changeCarModel(newCarModel: CarModel) {
    this.carModel = newCarModel;
    this.zeroToSixtyTime = this.zeroToSixtyTimes[newCarModel];
    this.price = this.prices[newCarModel];
    this.electricRange = this.ranges[newCarModel];
    this.canDriveLaToFresno = this.electricRange > 270 ? 'Yes' : 'No';
  }
Enter fullscreen mode Exit fullscreen mode

One of the benefits of reactive code is that the update logic is next to the state it's relevant to. Can we achieve this with imperative code too? We already have a function for changing carModel right next to it, so can't we just extract the downstream state change logic to individual functions that live next to those other states themselves too? Then we can just invoke those functions from the end of changeCarModel. Let's define the update functions as arrow functions so we can put them right next to their states:

  carModel = 'Chevy Volt' as CarModel;
  changeCarModel = (newCarModel: CarModel) => {
    this.carModel = newCarModel;
    this.changeZeroToSixtyTime(newCarModel);
    this.changePrice(newCarModel);
    this.changeElectricRange(newCarModel);
  };

  changeZeroToSixtyTime = (newCarModel: CarModel) => {
    this.zeroToSixtyTime = this.zeroToSixtyTimes[newCarModel];
  };
  zeroToSixtyTime = this.zeroToSixtyTimes[this.carModel];

  changePrice = (newCarModel: CarModel) => {
    this.price = this.prices[newCarModel];
  };
  price = this.prices[this.carModel];

  changeElectricRange = (newCarModel: CarModel) => {
    const newElectricRange = this.ranges[newCarModel];
    this.electricRange = newElectricRange;
    this.changeCanDriveLaToFresno(newElectricRange);
  };
  electricRange = this.ranges[this.carModel];

  changeCanDriveLaToFresno = (newElectricRange: number) => {
    this.canDriveLaToFresno = newElectricRange > 270 ? 'Yes' : 'No';
  };
  canDriveLaToFresno = this.electricRange > 270 ? 'Yes' : 'No';
Enter fullscreen mode Exit fullscreen mode

Look at that! changeCarModel no longer needs to worry about canDriveLaToFresno, because changeElectricRange calls that update function on its own! And this is all imperative code! Everything follows this pattern:

updateDerivedState = (aboveState: AboveState) => {
  // Update this.derivedState
  // Call update functions for states that derive from this state
}
derivedState = initialDerivedState;
Enter fullscreen mode Exit fullscreen mode

But you know what, there's sort of a circular dependency going on here. The derived state has to know about the state it's derived from, because it wouldn't exist in the first place if it weren't for the state it uses to derive itself. But the top-level state function needs to know about and directly call the derived state update functions below. What can we do about this?

Derived state knowing about state it's derived from reflects the natural relationship between the state and derived state. It's similar to how vanilla JavaScript works when you declare initial values for variables:

const a = 5;
const b = a * 10;
// Notice that `a`'s declaration includes nothing about `b`.
Enter fullscreen mode Exit fullscreen mode

So it seems like the odd part of the circular dependency we've created is that our top-level state has to reference the state change functions below it. Can we get rid of that?

What if we came up with a generic mechanism the top-level state could use to update its derived states without actually referencing them? We could define an empty array that derived states can add their update functions to. The central changeCarModel function could just loop through the array, calling each function with its new value for carModel. This would allow any derived state to just add its update function to that array and have it get called whenever changeCarModel runs. Here's what that looks like:

  carModel = 'Chevy Volt' as CarModel;
  carModelUpdates = [] as ((newCarModel: CarModel) => void)[];
  changeCarModel = (newCarModel: CarModel) => {
    this.carModel = newCarModel;
    this.carModelUpdates.forEach((update) => update(newCarModel));
  };

 // Assign dummy property just so we can run this code here
  dummyProp1 = this.carModelUpdates.push((newCarModel: CarModel) => {
    this.zeroToSixtyTime = this.zeroToSixtyTimes[newCarModel];
  });
  zeroToSixtyTime = this.zeroToSixtyTimes[this.carModel];

  // etc...
Enter fullscreen mode Exit fullscreen mode

Guess what? We basically just invented observables. Those update functions are like observers in RxJS.

Let's refactor completely to observables and compare it with what we just came up with:

Imperative to RxJS Diff

Pretty similar, but RxJS abstracted away the subscription mechanisms so all that's left is our business logic. And it's even more DRY now that initial values aren't treated as a special case.

So how did we get here?

  1. Make imperative code more DRY by centralizing state change code next to state
  2. Achieve the same thing for derived state while also improving separation of concerns, by moving derived state's update logic next to the derived state
  3. Remove pseudo-circular dependencies between state and derived state and improve separation of concerns further by making state agnostic about state that is derived from it
  4. Make code even more DRY by using RxJS

Every step that improves imperative code moves it closer to reactivity. This means the only way to fundamentally improve imperative code is to have less of it.

Let's review the basic rule of reactivity again: Everything that can be downstream should be downstream. This goal is the exact same as that 4-step process we just went through, and it is the best way to get as minimal and simple state management as possible. Downstreaming improves code at every stage.

inconsistent-state-reactive

And since reactive code is located next to the feature it controls, logically similar code will be grouped together, so it's easy to find opportunities to condense repeated logic. This is much harder with scattered, imperative code. In the app I refactored, the first event handler had these two imperative commands next to each other in an event handler:

    this.store.switchFlag(false);
    this.router.navigate(['/']);
Enter fullscreen mode Exit fullscreen mode

At first I had no way of knowing that switchFlag was only ever called at the same time.

After I had refactored to reactivity, it became obvious that store.flag was downstream from the app's URL; it was a flag to determine whether the app was supposed to be on a certain route. You might think this is a ridiculous example, but it's real code that was written, and I really couldn't see how to simplify it until I made it reactive.

Clean code is reactive code.


Here's the repository for the example in this section. And for fun, here's the diff when I convert the RxJS version to StateAdapt, the state management library I created:

RxJS vs StateAdapt

Bad function names

Functions are things that do stuff. That's what they are. So their names should reflect that, right? But if you saw this in a template, could you tell me what it does?

(click)="onClick()"
Enter fullscreen mode Exit fullscreen mode

No. Rather than being named for what it is, this event handler, like most tend to be, is named for how or when it is called.

I know this is extremely common, so at this point I'll cite an authority that might trump tradition. From the classic and extremely authoritative book Clean Code:

Methods should have verb or verb phrase names like postPayment, deletePage or save.

Is onClick a verb? Does it describe what the function is or does? No. It describes an instance in time, when a thing is clicked. All you know when you see a function called onClick is that it is called... on click.

Wouldn't a function name like this be easier to understand?

(click)="refreshData()"
Enter fullscreen mode Exit fullscreen mode

Hopefully you agree with me that that is a better name than onClick.

Alright. Can you help me come up with a better name for this function than onSubmit?

onSubmit function

This is color-coded according to what state is being affected.

Can you come up with a single name that describes what this function does? Here's my best attempt:

ifValidDeleteMoviesFetchAndSaveMoviesAndSaveSearchHeaderFlagAndNavigateElseShowAlert() {
  // ...
}
Enter fullscreen mode Exit fullscreen mode

That is a function name that almost describes everything happening in the function. But it's long. Maybe we can shorten it by extracting commonality from multiple steps and describing the common effect instead of every detail:

validateAndClearSearchAndSaveNewSearchDataAndNavigate() {
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Now we could become philosophers and eventually come up with something that describes the overall essence of what's happening, but that would take a lot of time. So it's easy to understand why we're always naming event handlers by when they get called instead of by what they do.

Another technique I've seen is to just pick our favorite behavior from the function and simply ignore everything else:

  navigateBack() {
    this.store.deleteMovies();
    this.store.switchFlag(false);
    this.router.navigate(['/']);
  }
Enter fullscreen mode Exit fullscreen mode

navigateBack? There seems to be more going on there.

Again, this isn't the original developer's fault.

The problem is that all these things need to be done, but they have little in common with each other.

The problem is imperative code.

In an imperative app, events trigger event handlers, which are containers of imperative code. This imperative code is burdened with the responsibility of understanding and controlling features that often have nothing to do with the original event. The event handler requires an umbrella name that covers everything inside it. This can be so difficult that the best name for the function truly is something hollow, like onSubmit.

Reactive code doesn't have this problem.

I refactored that onSubmit code to be reactive and here is what some of the code became:

URL Observable

Do you know how hard it was to come up with the name url$? Not hard at all. Because that's what it is. It might change over time, and it might be derived using other observables, but it still is just an observable of the URL.

Reactive code is declarative, meaning, it declares all its own behavior instead of relying on other code to control it. When no code is reliant on other code to control it, everything can just be concerned with itself only. Nothing is burdened with downstream effects or concerns, so you never have to come up with names that include downstream effects or concerns.

Conclusion

There are many reasons to program reactively. This is why the history of web development has followed an overall trend towards more and more reactivity.

But it's taking a long time. Most developers are still confused about what reactivity even is. Some think that synchronous reactivity covers reactivity completely, forgetting that asynchronous code can also be reactive with tools like RxJS and React Query.

So why is it taking developers so long to get on board with fully reactive programming?

I think it's because JavaScript developers are trained to code with an imperative mindset, because JavaScript 101 includes almost exclusively imperative APIs. I used to think all new developers should start with JavaScript 101, but I'm having second thoughts about that... What would a reactivity-first tutorial look like?

I also think it's hard to stray too far from the dominant style in the ecosystem you work in. For example, if you're using Angular Material and you notice that RxJS is tricky to work with whenever you use a dialog component, you might mistakenly blame RxJS for the bad experience instead of the outdated API for MatDialog.

For most developers, programming reactively will require a big shift in mindset. It will also require some tools and wrapper components.

But every feature I've refactored to reactivity has become simpler, less buggy and more performant.

So I think it's worth it.

Top comments (6)

Collapse
 
brense profile image
Rense Bakker

This is awesome 👍 very good explanation. I hope every frontend dev will read this! It really helps to write better code with less bugs.

Collapse
 
fchaplin profile image
Frederic CHAPLIN

After years of Angular, I now use reactive programming only if i need to listen values changing over time (yes, global state management for example) , or need to compose values from multiple async sources.

In all other cases, I think reactive programming is often over engineering your code.

Every time I can, I transform observables into promises with firstValueOf(), and use async/await. Or even direct property assignment, for local component state.

Async/await code is more understandable, easier to test (marble testing is just hell), and to maintain.

Rxjs can be useful, but I dont understand why using it for http calls, for example. It's just asynchronous, and Promises are best for that.

In fact, I think Angular would have gained a LOT more users if rxjs was only opt-in feature.

Collapse
 
mfp22 profile image
Mike Pearson

Do you prefer inconsistent state, elevated state, larger bundle sizes, unnecessary complexity and bad function names over getting comfortable with the difference between switchMap and mergeMap, for example?

Or is there something specific I'm wrong about in this article?

Do you have any examples of a feature that seems over-engineered with RxJS that is easier with imperative code? If you have an entire app somewhere, like on GitHub, I can convert it to reactivity and show that it becomes simpler. Like I did with my previous article.

Have you tried Shai Reznek's RxJS testing library? No marbles needed.

I wrote this article somewhat with all frameworks in mind, not just Angular. But in Angular, you get even more benefits with RxJS than in React. Like if you have a waterfall of HTTP requests:

data1$ = this.http.get(...)

data2$ = this.data1$.pipe(switchMap(data1 => this.http.get(...));

data3$ = this.data2$.pipe(switchMap(data1 => this.http.get(...));
Enter fullscreen mode Exit fullscreen mode

All you have to do is subscribe to data3$ and it triggers everything upstream. And all you have to do is unsubscribe, and everything upstream gets canceled.

React has React Query for this kind of control. Angular has RxJS.

RxJS allows incredible flexibility, which is the definition of clean code. Personally I wish async/await were never added to JavaScript, and I hate the idea of using an asynchronous primitive as limited as promises. One of your data sources just became a websocket? Have fun refactoring! A trivial change with clean async code can be a nightmare with dirty imperative code.

Reactive code isn't going away. It is a superior way of programming. Every way you could think to improve imperative code makes it more reactive. I would get more comfortable with it if I were you. And I'd love to hear what I'm wrong about after you've read the article.

Collapse
 
fchaplin profile image
Frederic CHAPLIN

Your article points to some valid cases of use for reactive programming and it was a good read. Many thanks for that.

But to me, inconsistent states are not the reflect of what you use to code it, but how you code it. There are a lot of developpers out there who are coding well crafted states without reactive programming.
The question if a state should be "elevated" or not is an architectural one and dépends of the domain.
And bad function names are not related at all to reactive programming.

Most of the issues you solve in your article (and you solve it well !) are architectural issues more than imperative code issues, and may be solved in other ways.

For example, Your case of waterfall requests is a case where I would use async/await instead, for its simplicity. If any change force me to use anything else, I will, but I choose to not add any complexity just in case something would change or happen (One of your data sources just became a websocket, for example ? Ok, I will change this to be reactive, this is a valid case)

async function getMyData() {
data1 = await firstValueFrom(this.http.get(...))

data2 = await  firstValueFrom(this.http.get(...data1...));

data3 =  await firstValueFrom(this.http.get(...data2...));
}

//And you don't even need to unsubscribe ! 
Enter fullscreen mode Exit fullscreen mode

When you say :

Reactive code isn't going away. It is a superior way of programming.
Every way you could think to improve imperative code makes it more reactive."

Reactive code is good to handle some cases, but I just think it can be too heavy for some other cases.
You may think I'm wrong, but I believe this may be a bad move to consider a library or a way to code as the silver bullet for everything.

I have the same feeling about functional programming : it can be awesome in some cases, but it comes with a heavy cost at the team level. And we have the responsibility to evaluate if this cost is too much for the gain, for the project.

I think that if you can do something in a simpler way, you should. The project will benefit from it in the long term.

Thank you for this interesting read.

Thread Thread
 
mfp22 profile image
Mike Pearson • Edited

But to me, inconsistent states are not the reflect of what you use to code it, but how you code it.

Yes, and reactive code is correct by default, imperative code needs an experienced developer to get it right because by default it's incorrect and needs extra management.

The question if a state should be "elevated" or not is an architectural one and depends of the domain.

It's an architectural decision if you use reactive programming. But if you have an event handler that needs to reference a complex state change function, it literally can't be anything other than accessible to that event handler.

And bad function names are not related at all to reactive programming.

Yeah below in your comment I see a function name called getMyData. Like I said in the article, every single function name that contains multiple lines of imperative code will either be hiding important details, or just not be named at all in a way that describes what it's doing.

Reactive code is good to handle some cases, but I just think it can be too heavy for some other cases.

What looks heavier? This, which is imperative:

Image description

Or this, which does the same thing but is reactive and 32% less lines of code:

Image description

I disagree that reactive code has to be heavy, but I understand where you're coming from. I get the feeling of heaviness you're describing. And that's because I have a lot of experience in Angular, like you. I'm used to situations like this:

"Ugh, I need to subscribe in my component so I can open this dialog... I wish I could just grab the state... Oh well, I need this.data$.pipe(first()).subscribe(data => this.dialog.open(DialogComponent, {data})

Or maybe like this:

"I know the easiest way to do this is to subscribe right here, but... now I need sub =, implements OnDestroy and

ngOnDestroy() {
  this.sub.unsubscribe();
}
Enter fullscreen mode Exit fullscreen mode

Then there's dealing with the template and the async pipe, the *ngIf hack with a local variable, etc...

RxJS feels heavy in Angular because Angular sucks at making it easy.

You know how to subscribe in a Svelte component?

let value = $value$;
Enter fullscreen mode Exit fullscreen mode

There, now value will be reactive to value$ and always contain the most up-to-date value. The component will also unsubscribe automatically. In a template you can do the same type of thing:

<p>{$value$}</p>
Enter fullscreen mode Exit fullscreen mode

You can even use this syntax in event handlers! Try doing this in Angular:

<button on:click={() => doSomething($value$)}>Do Something</button>
Enter fullscreen mode Exit fullscreen mode

The async pipe can't be used like that. You need that stupid first()).subscribe( nonsense like in my example from above.

So, here's my summary of where Angular went wrong:

  1. Over-ambitiousness with Angular 2
  2. Very long development time before Angular 2 was released
  3. Angular became aware of more and better ways of doing things as development progressed, such as RxJS
  4. RxJS was included in a couple of features that made sense, and not in a lot that make even more sense, such as lifecycle events and template syntax
  5. Developers were guaranteed to have a bad experience with RxJS, because they were forced to learn it, but not given convenient tools for using it
  6. The Angular team sees everyone complaining about RxJS, so they begin to talk about moving away from it instead of resolving issues like this one that have been opened almost since the beginning

It's funny, the one framework that forces developers to use RxJS is the one that has almost the most terrible integration with it. And Svelte has great support by accident.

I believe this may be a bad move to consider a library or a way to code as the silver bullet for everything.

I don't believe RxJS is a silver bullet. I think reactivity is. And it is. Do you think React, Svelte or SolidJS have issues with inconsistent, synchronous state changes? They don't! How could this be wrong?

React

const [a, setA] = useState(0);
const b = Math.sqrt(a);
// ...
setA(4)
// b will be 2 next
Enter fullscreen mode Exit fullscreen mode

Svelte

let a = 0;
$: b = Math.sqrt(a);
// ...
a = 4;
// b will be 2
Enter fullscreen mode Exit fullscreen mode

SolidJS

const [a, setA] = createSignal(0);
const b = () => Math.sqrt(a());
// ...
setA(4)
// b() will return 2 next
Enter fullscreen mode Exit fullscreen mode

Every single one of those frameworks makes this extremely easy. Angular can do this for template expressions, but you have to use RxJS to do anything reactive with any degree of sophistication, because Angular's expression syntax is limited. If you needed to do this reactively in Angular, you'd either need to create a pipe (ugh) or do this in RxJS:

a$ = new BehaviorSubject(0);
b$ = this.a$.pipe(map(a => Math.sqrt(a)));
Enter fullscreen mode Exit fullscreen mode

That's not as nice. And every new subscriber will make the sqrt run, and it will run again for each of those subscribers if you pass in the same number multiple times. RxJS is not actually great at synchronous reactivity. So no, it's not a silver bullet.

But each of those frameworks also lacks the utilities in RxJS for asynchronous reactivity, so it still has a role to play in those frameworks.

I think you'd like Svelte. You should give it a try. Here's the tutorial. But don't use Svelte stores. Use RxJS. It's more reactivity for pretty much the same syntax.

Reactivity should be easier than imperative code. It would be in Angular if it supported it as well as Svelte does. The Angular ecosystem is full of imperative APIs that could have been declarative. Compare dialogs in Smelte with dialogs in Angular Material. If you want to use RxJS with Svelte dialogs:

import { Dialog } from "smelte";
const dialogOpen$ = new BehaviorSubject(false);
Enter fullscreen mode Exit fullscreen mode
<Dialog bind:value={$dialogOpen$}>
  <!-- -->
</Dialog>
Enter fullscreen mode Exit fullscreen mode

Angular Material:

import { MatDialog, MatDialogRef } from '@angular/material/dialog';
import { DialogComponent } from './dialog.component.ts';
// ...
export class SomeComponent implements OnDestroy {
  dialogRef!: MatDialogRef;
  dialogOpen$ = new BehaviorSubject(false);
  sub = this.dialogOpen$.subscribe(isOpen => {
    if (isOpen) {
      this.dialogRef = this.dialogRef || this.dialog.open(DialogComponent);
    } else {
      this.dialogRef?.close();
    }
  });

  constructor(private dialog: MatDialog) {}

  ngOnDestroy() {
    this.sub.unsubscribe();
  }
Enter fullscreen mode Exit fullscreen mode

RxJS isn't heavy—Angular is heavy.

Also remember how DialogRef['afterClose'] returns an observable despite only emitting once and nothing else about the dialog being reactive? Sometimes I feel like Angular APIs were randomly generated.

In Svelte, and many others, the choice to use RxJS or not is trivial—as it should be.

Reactivity can be easy!

Thread Thread
 
mfp22 profile image
Mike Pearson

Also, putting all the requests in the same async event handler like that is bad separation of concerns. The first request is most likely used in other places. Also there is no auto-cancelation when you navigate away.