JavaScript Just Got Better!

We’ll look at some code snippets to better understand the opportunities brought to the table by this new addition, but first, let’s do a quick JavaScript history recap.

JavaScript History

“In the beginning God created the heavens and the earth”, also known as JavaScript and the DOM. The whole thing was a bit rushed, so jQuery tried to fix some of the problems. Then, proving they are not the sharpest tool in the shed, web developers decided to bet everything on this crude, rudimentary environment and a bunch of frontend frameworks were born. But guess what? JavaScript is not good enough, so everybody should switch to TypeScript. And those frontend frameworks are not good enough either, so here are more options. And some more. [more, more] Scratch that… JavaScript is good enough, and the DOM can hold its own against most frameworks, so the “circle of competence” is finally complete.

Here we are, 15 years later finally learning that the perfect frontend framework is a thin layer of abstraction over native APIs, where reactivity is handled via Signals, JS template literals are improved through some popular templating library, and TypeScript is used as a Linter.

And, thanks to this new proposal, the Signals bit can now be removed from the framework, and deferred to the native platform.

JS Signals Code Example

So let’s see this in action with a basic counter app.

<div>
  <h1></h1>
  <button>+1</button>
</div>

Note that JS Signals are still in Stage 1, so they have a long road ahead, but in the meantime we can use a polyfill to test it.

We’ll define a counter signal, which will be updated every second through an interval, and a computed double signal built based on the counter.

import { Signal } from "signal-polyfill";
import { effect } from "./effect.js";

let $ = document.querySelector.bind(document);
const counter = new Signal.State(0);
const double = new Signal.Computed(() => counter.get() * 2);

setInterval(() => counter.set(counter.get() + 1), 1000);

effect(() => {
  $("h1").innerHTML = `${counter.get()} x 2 = ${double.get()}`;
});

Then, whenever the counter value is updated, we’ll manually update the DOM node to reflect the changes.

Interacting with the elements is straightforward as well, and we can increase the value of our counter in a click event listener.

import { Signal } from "signal-polyfill";
import { effect } from "./effect.js";

let $ = document.querySelector.bind(document);
const counter = new Signal.State(0);
const double = new Signal.Computed(() => counter.get() * 2);

setInterval(() => counter.set(counter.get() + 1), 1000);

effect(() => {
  $("h1").innerHTML = `${counter.get()} x 2 = ${double.get()}`;
});

// increase value of counter
$("button").addEventListener("click", () => {
  counter.set(counter.get() + 1);
});

Easy enough, right?

What are Signals?

If you are not familiar with Signals, here is the TLDR: these are wrappers over raw values which also maintain an internal list of dependencies. The dependencies can either be other computed signals, or effects that run whenever associated signals are updated.

While the JS Signals proposal comes with an implementation for Computed signals, we’ll need to write the effect implementation on our own.

By the way, you are following ”The Snippet” – the series where we are analyzing code snippets from various programming languages to better understand useful software concepts. So, in effects.js we’ll first define a watcher which will do all the heavy lifting, and an effect function accepting the callback as an argument.

import { Signal } from "signal-polyfill";

let enqueue = true;
const watcher = new Signal.subtle.Watcher(() => {
  if (enqueue) {
    enqueue = false;
    queueMicrotask(() => {
      enqueue = true;
      for (const computed of watcher.getPending()) {
        computed.get();
      }
      watcher.watch();
    });
  }
});

function effect(callback) {
  const computed = new Signal.Computed(() => {
    callback();
  });
  watcher.watch(computed);
  computed.get();
  return () => {
    watcher.unwatch(computed);
  };
}

Our function is then wrapped into a computed signal and passed over to the watcher. Here, we’ll simply react whenever a signal change is detected and call the computed get method, which, in turn, will execute our callback function.

The microtask queue is the go to choice in reactive systems, since it ensures consistent, efficient UI updates without blocking the main thread or interfering with other user actions. This is linked to some interesting JavaScript internals worth exploring further, so let me know in the comments if you are interested in such topics.

If you feel like you learned something, you should watch some of my youtube videos or subscribe to the newsletter.

Until next time, thank you for reading!