Skip to content

Introducing Signalium

· Revised

Originally published in the Signalium docs

TL;DR

One might ask, in the year 2026: do we really need yet another JavaScript signals library?

There are, after all, several dozen out there today, and a proposal to add them to the language itself. So, what makes Signalium different?

Well, a few things. You see, I've been working with signals for a long time. Since we first added autotracking to Ember.js in 2018, in fact, which was a full 4 years before Preact introduced them to the wider JavaScript ecosystem and gave them the name that we know them by now. And in that time, I've learned a lot about how they work, how to use them effectively, and where they break down at the edges.

And believe me, when I started digging into the performance and consistency issues we were having with React Hooks in our application, I did try to survey the field and find a library that would fit. The last thing I wanted to do was fragment the ecosystem even more. But, unfortunately, there were two key things that I knew from experience were going to really bite us, and that no other implementation had really tackled:

  1. Async truly needed to be a first class concern. When we started trying to roll out signals in Ember, we almost immediately ran into a large number of edge and corner cases that were simply not easy to solve with purely synchronous signals. You essentially have to thread lifecycle through all of your signals to manage setting up and tearing down connections and resources, and it quickly became a mess without the signal graph itself being involved.
  2. Managing signal instances was very difficult. I would often find myself thinking of signal-based computations like memoized pure functions - except they weren't. Signals were stateful, and while that was kind of the point for state signals, it always felt very awkward for computed signals. You ended up having to do a whole lot of ceremony to set them up, manage their lifecycle, and tear them down every time you reached for them. And that added friction.

What I wanted, ultimately, was the simplicity and the functional feel of React Hooks, but without having to constantly think about all of the little paper cuts that have become part of daily life with Hooks. And perhaps more importantly, I wanted to be able to take that code and run it anywhere. In any framework on the client, and on the server. I wanted a true replacement for Rx.js, for Observables: that is the dream that Signals have been after.

Signalium is my attempt to square that circle. It's a functional-reactive model that feels like Hooks at first, but then it doesn't. It doesn't have the annoying rules-of-hooks, it doesn't require you to constantly think about memoization, it doesn't force you to pop out to useSyncExternalStore when you need to create a new type of data source. It also doesn't force you to manage state or lifecycle for all of your signals.

It truly does feel like the best of both worlds.

So, let's dig in.

First, a retrospective

When React Hooks first hit the frontend world back in 2018, I was, like many others, immediately enamored by them. React had already proven the benefits of component-oriented view layers, and the benefits of functional programming were finally becoming accepted in the industry after decades of dominance by object-oriented programming. I was skeptical that the entire view layer could be pushed into a "pure" functional style, but I was also curious, because at the time I was working on a very similar set of problems.

Back then, we were working on overhauling the Ember.js programming model holistically around an early version of what would today be recognized as Signals, and one core issue we kept coming back to was loading data. Much like pre-hooks React, Ember components that wanted to load data outside of the framework's router had to rely on a variety of lifecycle hooks, and this dance of managing state and dynamically loading data was, to say the least, very tricky.

Hooks, with early utilities like SWR and TanStack Query, showed a different path toward solving this problem. One that looked a lot cleaner and easier to understand, that made the whole process of managing that state self-contained in a way that wasn't really possible before. They essentially extended reactivity beyond components and into the world of data loading, DOM mutation, and general side-effect management as a whole.

But in the years since their release, there have been more and more complications with the Hooks programming model. It's not uncommon these days for React devs to decry Hooks and the complexity that they add, and there have been more and more experiments with alternatives for state management such as Legend State or Jotai in the wider ecosystem.

The React team has been attempting to solve these issues with an experimental compiler that purports to automatically add calls to useMemo, useCallback, and React.memo, reducing the cognitive overhead that plagues hooks usages - but I don't think that this is going to work, for the simple reason that adding an additional layer of compiler magic which further obfuscates the usage of hooks seems like it ultimately will add more fuel to the raging firestorm of emergent complexity. You can't dig yourself out of a hole, so to speak.

Meanwhile, on the other end of the JavaScript ecosystem, there has been an ongoing effort to standardize a new reactivity model built around Signals. Essentially every major frontend framework besides React — Angular, Vue, Svelte, Preact, Solid, and more — has more or less independently arrived at the Signals design. Even Jotai and MobX in the React world are, essentially, Signals flavors in their own ways. There is enough independent discovery and convergent evolution here that it really does suggest that we've found something interesting, at the least.

Up until recently though, none of us has really been able to put our finger on it. We feel like Signals solve many of the common problems we face in Hooks, but explaining why usually leads to a long explanation with a lot of different examples and edge cases and corner cases and so on. We struggled to find a principle behind it all for quite some time. But now, we have (and I'm really sorry for this in advance):

So.

The thing is.

It turns out that Signals are reactive monads, and Hooks are not.


I know, I know, what even is a monad???. It's one of those weird academic terms that is comically hard to explain (ironically, I think, because it's so simple in the end that it really loops back around and becomes just incredibly complex). I promise not to get too into the weeds on this, but it's important because it really is the crux of the issue. My hypothesis is that the vast majority of the complexity in Hooks-based code comes from this core issue - that they are fundamentally non-monadic.

Despite these issues, Hooks still have a more intuitive, and I would also say more powerful, API. As I got deeper into the design of Signals, I kept coming back to that magic that Hooks had first shown me all those years ago. They felt so close, like each had something the other was missing.

And after pondering it for the last 7-odd years, I think I've finally figured it out with Signalium.

Signalium is a general-purpose reactivity layer that focuses solely on making plain-old-JavaScript code reactive, in the same way that Promises made plain-old-JavaScript code async.

Plain old (reactive) JavaScript

To explain what I mean here, we do need to get back to the "what is a monad" thing, so let's get that out of the way.

I had a computer science professor in college who taught us Haskell and had a whole section on monads, and we even implemented a semicolon monad to sequence things like an imperative language (which honestly just felt like trolling at that point), but I still couldn't really grasp it.

When I first started my career and was learning Scala, one of my coworkers told me that a monad was "anything that implemented map and flatMap", which was also not really helpful. Over time I learned about more things that were monads, like Result and Option, and that helped a bit more as I started to dig into Rust and such.

But where it really finally hit me was with Futures, and by extension, Promises (and to be clear, I'm aware that Promises are not really monads, but they are close enough in purpose and, more importantly, they're familiar enough to every JavaScript dev that they provide a great reference point).

So, let's consider some code with Promises.

function loadAndProcessData(query) {
  return fetch(query)
    .then((response) => response.json())
    .then((data) => processData(data));
}

This code is pretty simple, but stepping back, let's think about what has to happen under the hood to make it all work.

  1. First, we call the loadAndProcessData function, which then calls fetch, which returns a promise.
  2. Then, we yield back to the JavaScript event loop. So, the main JS thread is going to keep on executing other tasks and doing things while we wait on the fetch to return.
  3. In order to make that work, we need to store the current state of the function, including:
    1. The current variables in scope, so that we can restore them
    2. The line of code we're waiting on, so that we know which line to execute next
    3. The external promise returned by loadAndProcessData, so we can resolve it once all steps have been completed
  4. All of these values are stored somewhere, and then when we return, we restore those values to the callstack and start executing again on the next line.

The exact details of how and where those values are stored don't really matter, because externally we don't really need to worry about them. That's all handled by the Promise (and JavaScript's closure/scope semantics).

Monads are essentially like a box that contains some context, and that box comes with a function that lets you take that context and transform it into another box with the next context in the sequence. In the case of Options or Results, you're transforming the result of an operation (Some/None or Ok/Err) into whatever you were planning on doing next with those values, and handling the edge cases if there was no value, or an error instead. In the case of Futures and Promises, the box has all of that context around the async operation, and Promise.then is the function that carries us on to the next step.

But the magic of monads is not just in what they are, but also how often they fit into an existing, perhaps just slightly tweaked, syntax. With async/await syntax we can restructure our original Promise-based function to look much more like plain-old-JavaScript:

async function loadAndProcessData(query) {
  const response = await fetch(query);
  const data = await response.json();
 
  return processData(data);
}

This reads like synchronous code, but does all of the same async sequencing and transformations as our first example. Similar syntax exists for Options or Results in functional languages like Rust and, of course, Haskell, and if we think about this it should be maybe a bit obvious why this works so well — after all, programming languages are inherently about linguistically sequencing things, either via imperative steps (turned out that semicolon lesson was useful after all), nested function calls, declarative dependencies, or some other means.

So, what does a reactive monad look like?

And more importantly, how do we incorporate it in a way that is fluid and natural in our syntax?

The Hooks version

Let's consider what the above might look like using hooks:

function useLoadAndProcessData(query) {
  const promise = useRef(fetch(query));
 
  const response = use(promise.current);
  const data = use(response.json());
 
  return processData(data);
}

This actually looks very similar overall to our async/await syntax, which is a great sign! Compare this to, say, Observables (another monad that is used for reactivity):

function createLoadAndProcessDataObservable(query: Observable<string>) {
  return query
    .map((query) => fetch(query))
    .map(async (res) => (await res).json())
    .map(async (data) => processData(await data));
}

This is a bit contrived (that could just be a single map statement, or better libraries that handle the details of sequentially awaiting piped promise values), but you can see how as we break down each individual step, we start to introduce a lot of complexity with Observables. It starts to look less and less like plain JavaScript, and Hooks are looking a lot better in this regard.

The issue with the Hooks version, however, is how it works under the hood.

As we know, Hooks rerun whenever there might be an update. This is why we have to constantly pass in our dependencies to every hook, and why all of the operations of hooks have to be idempotent for the given set of arguments. What is happening in our example above is that we are rerunning all of the steps of the useLoadAndProcessData function that we already ran in order to rebuild the previous state of the world, and we are then advancing to the next step.

And it's not just that hook that we're rerunning - we're also rerunning every other hook above it in the call stack, all the way up to the nearest component. This is where the complexity comes from. And this is why hooks are not monadic.

Imagine if this were the way that async/await syntax worked. We rerun the entire function leading up to the currently active await statement. If all of those steps were fully idempotent and non-stateful, then that would technically work. We could do that each time, and not really worry about capturing and restoring context fully in the Promise.

That may sound far-fetched to you, but going back to the days before Promises, maybe that would be a bit more appealing.

function useLoadAndProcessData(query, callback) {
  fetchCallback(query, (response) => {
    parseJson(response, () => {
      callback(processData);
    });
  });
}

It took me a good moment to dredge that syntax back up and think it through, and this has so much extra complexity going on here. Imagine if we're trying to refactor a synchronous version of this function to make it more performant, and we suddenly need to refactor everything to use this callback pattern. And it's not just here - you would need to add that callback argument to every non-async function that calls this one!

Detractors would note that this also applies to Promises and async/await. If you make a function async, you now need to go and make every function that uses it async as well. But there is a crucial difference here: It's a lot harder to mess async/await up, because fewer lines of code need to change, and there is less "wiring" that has to occur.

With the callback pattern, you now need to:

  1. Separate all of the code that comes before the async operation from the code that comes after it,
  2. Ensure that the callback is called at the correct time to execute the function above us in the call-stack,
  3. Ensure that no code is accidentally left after we schedule the callback in our function (because it could keep running and do more things in the meantime) AND after we call the callback passed to our function (oh boy, this is getting to be a lot).

And that last part is doubly tricky because lots of clever devs do want to make use of it from time to time. Yes, let's schedule something async and keep on doing things! Or call the callback and get its return value and then do something else! Maybe we call the callback twice, or three times!

You have much more power with callbacks, is the point. And 99% of the time, you don't need that power - it just makes it harder to rebuild and refactor and understand a codebase, in the end. This is why Promises (and later, async/await) were so successful in reducing complexity in async. It's not that they eliminated all of the overhead or complexity, but they reduced most of it in the common case.

But we've digressed, back to our thought experiment! We could imagine that rather than using callbacks or promises, we could do the same thing that React's use function does here - we could throw and halt execution:

const responses = new Map();
const parsed = new Map();
 
function useFetch(query) {
  if (responses.has(query)) {
    return responses.get(query);
  } else {
    fetchCallback(query, (response) => {
      responses.set(query, response);
 
      // Re-run the program after the async operation is done
      rerunProgram();
    });
 
    throw WAIT_FOR_ASYNC_EXCEPTION;
  }
}
 
function useParseJson(response) {
  if (parsed.has(response)) {
    return parsed.get(response);
  } else {
    parseJson(response, (json) => {
      parsed.set(response, json);
 
      // Re-run the program after the async operation is done
      rerunProgram();
    });
 
    throw WAIT_FOR_ASYNC_EXCEPTION;
  }
}
 
function useLoadAndProcessData(query) {
  const response = useFetch(query);
  const data = useParseJson(response);
 
  return processData(data);
}

You can see that we end up with a pretty similar looking high-level API, but we also know that the underlying code is rerunning constantly, each time a related async operation calls its callback. Again, in theory this is completely ok, because all of the operations that are called are idempotent and pure. But, we can also see how easy that would be to mess up.

For instance, let's say we decide to start integrating a telemetry library to gather performance information, and we want to get the total number of times we call useLoadAndProcessData so we can determine if it should be reduced. A naive implementation might look like:

function useLoadAndProcessData(query) {
  incrementCounter("fetching-data");
  const response = useFetch(query);
  const data = useParseJson(response);
 
  return processData(data);
}

But once we realize that this function will be called repeatedly, we can see that the incrementCounter method needs to deduplicate itself somehow. This is not as much of an issue with async/await:

async function loadAndProcessData(query) {
  incrementCounter("fetching-data");
  const response = await fetch(query);
  const data = await response.json();
 
  return processData(data);
}

This will only call incrementCounter once per full function execution, which is more of what we would expect if we can into this situation without any prior knowledge. You might point out that the hooks example also deduplicates query calls, so it's more efficient overall though! And I would say yes, that's true, but it may or may not be the desired effect in some cases, and regardless, that would be very easy to add to the async version as well:

const loadAndProcessData = memoize(async (query) => {
  incrementCounter("fetching-data");
  const response = await fetch(query);
  const data = await response.json();
 
  return processData(data);
});

Overall, if Promises worked more like Hooks, we can see that it would only add increased complexity and many gotchas and foot-guns that are currently avoided. As applications using that model grew, they would also start to experience a lot of the same emergent complexity we see from Hooks in general: Infinite re-render bugs caused by forgetting to memoize a callback; performance issues caused by calling plain functions without useMemo; and even code and infrastructure that becomes reliant on the fact that we're constantly re-executing functions in this way, because if there's one thing we know, it's that timing semantics always eventually become part of your public API.

Uno reverso

So the question becomes: How do we do the reverse? How do we make Hooks work more like Promises and other monads? Is that even possible? It turns out that it is, but it looks a bit different.

The important thing to realize is that in general, it is a lot easier to make a program reactive if you can reduce it to, essentially, something that looks like a pure function. Given this state, produce that result. This is why React and other component-oriented frameworks have been so successful, you can have mirror the callstack, have it output a DOM tree, and it's pretty much 1-to-1. Incremental updates then involve rerunning a subtree in that original function, which, given we know it's pure, should be completely fine to replace.

But this strategy can also be applied to any pure function. It doesn't need to be something that produces a tree-like value - the callstack itself is the tree.

A flame chart of nested function calls with getOuterWrapper at the bottom. Each time the counter ticks, getCounter, getInnerWrapper, quotient, divide and floor light up as they rerun, but getOuterWrapper only reruns on the third tick, when the quotient changes from 0 to 1.A flame chart of nested function calls with getOuterWrapper at the bottom. Each time the counter ticks, getCounter, getInnerWrapper, quotient, divide and floor light up as they rerun, but getOuterWrapper only reruns on the third tick, when the quotient changes from 0 to 1.

In this example, we see a visualization of a function callstack. The bars represent function calls, and the layers represent parent/child relationships, much like a flame chart. The definitions of those functions look like this:

const getCounter = reactive((ms) => {
  return relay((state) => {
    state.value = 0;
 
    const id = setInterval(() => state.value++, ms);
 
    return () => clearInterval(id);
  });
});
 
const divide = reactive((value, divideBy) => value / divideBy);
 
const floor = reactive((value) => Math.floor(value));
 
const quotient = reactive((value, divideBy) => floor(divide(value, divideBy)));
 
const getInnerWrapper = reactive(() => quotient(getCounter(3500).value, 3));
 
export const getOuterWrapper = reactive(() => getInnerWrapper());

You'll notice that whenever the counter increments, part of the stack lights up. Those are functions that are reactivating in response to mutable state updating - rerunning incrementally. If a function returns a different value, it continues propagating and its parent functions are also rerun. But if a function returns the same value, then it stops propagation.

This allows us to efficiently, incrementally recompute any function. Not just rendering frameworks, but any JavaScript in any context. And while I haven't formally proved it to myself yet, I think it's also guaranteed to rerun the minimum number of functions that must be rerun to incrementally recompute.

This is what a reactive monad looks like.

Monads, once again, are a box that contains some context, and a way to turn that context into the next thing. With promises, that context is the program counter, the variables in scope, and so on.

With this monad, the context is:

  1. The function,
  2. The parameters it receives,
  3. The mutable state it reads (if applicable), and
  4. the parent functions that called that function.

When the parameters or the mutable state changes, we rerun the function. If it changes, we rerun the parents, and continue propagating.

This is what Signalium provides: A way to annotate variables and functions in JavaScript to make them incrementally reactive.

Promises and reactivity

Reactive Functions like the ones above cover all use cases for synchronous computation, but what about asynchronous computation? Back at the start I said that async truly needed to be a first class concern, and Hooks are a good reminder of why. They didn't win people over because of useRef and use, after all. They won people over with SWR and TanStack Query, and with the idea that loading data could be declarative. If Reactive Functions are going to stand in for Hooks, they need to handle async at least as well as they do.

JavaScript has a few ways of dealing with async, but by far the most common one is with promises and async functions. So rather than inventing something new, Signalium extends promises to add reactivity to them in a declarative way, enabling functional programming patterns alongside traditional imperative ones:

const fetchJson = reactive(async (url: string) => {
  const response = await fetch(url);
  const result = await response.json();
 
  return result;
});
 
// Using async/await
const getUserName = reactive(async (id: string) => {
  const user = await fetchJson(`https://example.com/users/${id}`);
 
  return user.fullName;
});
 
// Using declarative properties
const getUserNameOrLoading = reactive((id: string) => {
  const user = fetchJson(`https://example.com/users/${id}`);
 
  return user.isPending ? "Loading user..." : user.value.fullName;
});

To understand Reactive Promises, the first thing to consider is: what does it mean to react to a promise?

Promises are based on an imperative mental model. "Do this, wait, then do this." The imperative way of thinking about loading data would be something like:

  1. Update the UI to show a loading spinner
  2. Fetch the data and wait for it to return
  3. Update the UI to hide the loading spinner and display the data

However, we want a declarative way of representing this data, one that derives directly from our state. This way of thinking looks more like:

  1. When we are loading data, show the loading spinner
  2. When we have data, show the rendered data

The way Signalium handles this is by exposing the various states of a Promise as properties:

interface PendingReactivePromise<T> extends Promise<T> {
  value: undefined;
  error: unknown;
  isPending: boolean;
  isResolved: boolean;
  isRejected: boolean;
  isSettled: boolean;
  isReady: false;
}
 
interface ReadyReactivePromise<T> extends Promise<T> {
  value: T;
  error: unknown;
  isPending: boolean;
  isResolved: boolean;
  isRejected: boolean;
  isSettled: boolean;
  isReady: true;
}
 
type ReactivePromise<T> = PendingReactivePromise<T> | ReadyReactivePromise<T>;

Whenever a Reactive Function returns a Promise, Signalium converts that Promise into a Reactive Promise with these properties. The isReady flag discriminates the two states: once isReady is true, value is guaranteed to be T (no undefined check needed), so if (p.isReady) { /* p.value: T */ } narrows correctly.

const text = signal("Hello, world");
 
const getLoader = reactive(async () => {
  const v = text.value;
  await sleep(3000);
 
  return v;
});
 
export const getText = reactive(() => {
  const { isPending, value } = getLoader();
 
  return isPending ? "Loading..." : value;
});
A flame chart of three calls: getText at the bottom, getLoader above it, and the text signal on top. getLoader starts out pending with no value, so getText returns 'Loading...' until it resolves with 'Hello, world'. When text changes to 'Hello, signals', getLoader reruns and goes pending again while keeping its last value, getText switches back to 'Loading...', and both update once the new value resolves.A flame chart of three calls: getText at the bottom, getLoader above it, and the text signal on top. getLoader starts out pending with no value, so getText returns 'Loading...' until it resolves with 'Hello, world'. When text changes to 'Hello, signals', getLoader reruns and goes pending again while keeping its last value, getText switches back to 'Loading...', and both update once the new value resolves.

The properties and flags represent the following states:

  • value: The most recent result of the Promise. This will remain the latest result until the next successful rerun of the Promise, allowing you to show the previous state while the next state is loading.
  • error: The most recent error of the Promise. This will remain the latest error until the next run, allowing you to show the current error state.
  • isPending: True when the Reactive Promise is currently running (e.g. the Promise has not yet resolved).
  • isResolved: True when the Reactive Promise resolved successfully.
  • isRejected: True when the Reactive Promise rejected.
  • isSettled: True if the Reactive Promise has settled at least once.
  • isReady: True when the Reactive Promise has resolved at least once.

This mirrors popular libraries such as TanStack Query and SWR among many others, which shouldn't come as much of a surprise, given that they're the ones who showed us this path in the first place. However, Reactive Promises have some additional niceties.

In addition to the declarative properties, you can also await Reactive Promises using standard async/await syntax:

let count = signal(0);
 
const getInnerLoader = reactive(async () => {
  const v = count.value;
  await sleep(3000);
  return v;
});
 
const getOuterLoader = reactive(async () => {
  const innerValue = await getInnerLoader();
 
  return innerValue + 1;
});
 
export const getText = reactive(() => {
  const { isPending, value } = getOuterLoader();
 
  return isPending ? "Loading..." : value;
});
A flame chart with getText at the bottom, then getOuterLoader, getInnerLoader, and the count signal on top. When count increments, getInnerLoader reruns and goes pending, getOuterLoader goes pending as it waits on the await, and getText shows 'Loading...'. Once getInnerLoader resolves, getOuterLoader reruns with the new value and the result carries on down to getText.A flame chart with getText at the bottom, then getOuterLoader, getInnerLoader, and the count signal on top. When count increments, getInnerLoader reruns and goes pending, getOuterLoader goes pending as it waits on the await, and getText shows 'Loading...'. Once getInnerLoader resolves, getOuterLoader reruns with the new value and the result carries on down to getText.

Execution pauses when values are awaited until the Promise settles (resolves or rejects). This guarantees a value or an error before proceeding, eliminating the need for undefined checks and simplifying state management.

This is also where the difference from Hooks really starts to show. Each await picks up right where it left off, just like it would in a plain async function, rather than rerunning everything that came before it the way our use-based Hook had to. And while getInnerLoader is pending, getOuterLoader doesn't rerun at all. It just waits, and only reruns once there's a new value for it to work with.

You can also compose Promises using the standard Promise methods like Promise.all, Promise.race, etc.

let countA = signal(0);
let countB = signal(0);
let countC = signal(0);
 
const loadA = reactive(async () => {
  const v = countA.value;
  await sleep(1000);
  return v;
});
 
const loadB = reactive(async () => {
  const v = countB.value;
  await sleep(3000);
  return v;
});
 
const loadC = reactive(async () => {
  const v = countC.value;
  await sleep(6000);
  return v;
});
 
const loadABC = reactive(async () => {
  const [a, b, c] = await Promise.all([loadA(), loadB(), loadC()]);
 
  return a + b + c;
});
 
export const getText = reactive(() => {
  const { isPending, value } = loadABC();
 
  return isPending ? "Loading..." : value;
});
A flame chart with getText at the bottom, loadABC above it, and loadA, loadB and loadC side by side on top of that, each reading its own count signal. At first all three loaders are pending, and loadABC stays pending until the slowest one, loadC, resolves. Later countB changes, so only loadB reruns. loadABC waits on it, then resolves straight away, because loadA and loadC still have their results.A flame chart with getText at the bottom, loadABC above it, and loadA, loadB and loadC side by side on top of that, each reading its own count signal. At first all three loaders are pending, and loadABC stays pending until the slowest one, loadC, resolves. Later countB changes, so only loadB reruns. loadABC waits on it, then resolves straight away, because loadA and loadC still have their results.

This allows you to chain Reactive Promises together and ensure that all promises are settled before continuing the computation, and it allows you to do so with familiar APIs that users already know and understand.

And you can handle errors using the semantics of standard try/catch syntax in async Reactive Functions:

let countA = signal(0);
 
const loadA = reactive(async () => {
  const v = countA.value;
 
  if (v % 2 === 0) {
    throw new Error("Even number");
  }
 
  await sleep(2500);
  return v;
});
 
const loadWithCatch = reactive(async () => {
  try {
    return await loadA();
  } catch (error) {
    return "Whoops! Something went wrong.";
  }
});
 
export const getText = reactive(() => {
  const { isPending, value } = loadWithCatch();
 
  return isPending ? "Loading..." : value;
});
A flame chart with getText at the bottom, then loadWithCatch, loadA, and the countA signal on top. While countA is even, loadA rejects and loadWithCatch catches the error, so getText shows 'Whoops! Something went wrong.' When countA becomes odd, loadA goes pending and then resolves with 1, which carries on down to getText. When it becomes even again, loadA rejects and the catch takes over once more.A flame chart with getText at the bottom, then loadWithCatch, loadA, and the countA signal on top. While countA is even, loadA rejects and loadWithCatch catches the error, so getText shows 'Whoops! Something went wrong.' When countA becomes odd, loadA goes pending and then resolves with 1, which carries on down to getText. When it becomes even again, loadA rejects and the catch takes over once more.

This allows you to handle errors and continue the computation like you would with standard promises. You can also use the isRejected and error properties to handle errors directly:

let countA = signal(0);
 
const loadA = reactive(async () => {
  const v = countA.value;
 
  if (v % 2 === 0) {
    throw new Error("Even number");
  }
 
  await sleep(2500);
  return v;
});
 
export const getText = reactive(() => {
  const { isPending, value, isRejected, error } = loadA();
 
  return isPending ? "Loading..." : isRejected ? `Error: ${error.message}` : value;
});

Step back and look at what all of these examples have in common. await, Promise.all, try/catch: none of them are new. They're the same plain-old-JavaScript you would write if there were no reactivity involved at all. No dependency arrays, no use throwing to halt execution, no callbacks to wire up. The async monad does exactly what it always did, sequencing each step and carrying the context from one to the next, and the reactive monad wraps around it, rerunning only what needs to rerun when the state underneath it changes.

Some light prognostication

Toward the beginning of this (now far too long) essay, one thing I noted was that monads tend to lend themselves quite well to syntax. It seems that every time we figure out a new monadic structure, we're able to make use of it in the syntax of some new language (probably Rust. I do love Rust.) So, what might that syntax look like in JavaScript?

reactive async function fetchJson(url) {
  //...
}

This is purely speculative and to be 100% clear, this is not part of the current direction of the Signals proposal. That proposal is about standardizing this primitive and its behavior, like the way Promises were added before async/await. And my current thought is that most likely, Signals could use function decorators instead of some kind of keyword syntax - it would be more general, easier to add, and less to maintain.

@reactive
async function fetchJson(url) {
  //...
}

What's neat (and validating) here though is how neatly either option works for this abstraction. It fits very nicely into syntax, and you could even imagine it interacting somehow with the using keyword from the Explicit Resource Management Proposal in some way.

Ultimately, I hope to see Signals added to the language in the future, and I hope that we can continue to explore these ideas in the functional-reactive space. It took us nearly 35 years for futures to go from a concept to a widely used, common language features. FRP, by contrast, was introduced in 1997. We've come a long way since then, and I think we're getting closer yet, but we still have work to do before we can fully rationalize them into something that works everywhere.

So give it a try, and let me know what you think.

Get notified

If you liked this, you can subscribe for notifications when I post new essays.

Get notified

Get an email when I post a new essay.

Or follow along with RSS.