Introduction
A Brief Manifesto
I call it a "manifesto", but that's mostly facetious. Really, the issue is that over the years as I've tried many different ways to explain both my interest in Signals and my issues with Hooks, and every time I end up with something resembling a small novella. There's just a lot of context to cram in there, and it's hard to cut any of it.
This time I got better. We're down to less than 4k words! (My first draft was 8k. I wish I was joking). So grab a cup of coffee and find a nice place to settle in for the next little bit, because this is a bit of a journey.
Alternatively
If you're not in the mood, feel free to skip ahead to the actual docs and come back to this if you have a moment. It's useful context, but it's not necessary to read it all before you start using Signalium.
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 React pre-hooks, 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 - have 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.
A Quick Note
I also want to take a moment to thank all of the other engineers who contributed to this project, directly or indirectly. Specifically, my mentors on the Ember.js core team that started me down this path, the React team for providing the inspiration, and the wider Signals community for keeping the dream alive.
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 flapMap
", 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.
- First, we call the
loadAndProcessData
function, which then callsfetch
, which returns a promise. - 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. - In order to make that work, we need to store the current state of the function, including:
- The current variables in scope, so that we can restore them
- The line of code we're waiting on, so that we know which line to execute next
- The external promise returned by
loadAndProcessData
, so we can resolve it once all steps have been completed
- 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 let's 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);
}
const loadAndProcessData = createAsyncComputed((query) => {
const { result: response, isLoading } = useFetch(query);
const data = useJson(response).await();
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 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:
- Separate all of the code that comes before the async operation from the code that comes after it,
- Ensure that the callback is called at the correct time to execute the function above us in the call-stack,
- 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 rerender 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.
In this example, we see a visualization of a real 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 useCounter = subscription(
(state, ms) => {
const id = setInterval(() => state.set(state.get() + 1), ms);
return () => clearInterval(id);
},
{ initValue: 0 },
);
const useDivide = computed((value, divideBy) => value / divideBy);
const useFloor = computed((value) => Math.floor(value));
const useQuotient = computed((value, divideBy) =>
useFloor(useDivide(value, divideBy)),
);
const useInnerWrapper = computed(() => useQuotient(useCounter(3500), 3));
export const useOuterWrapper = computed(() => useInnerWrapper());
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:
- The function,
- The parameters it receives,
- The mutable state it reads (if applicable), and
- 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.
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?
subscription function useCounter(state, ms) {
state.set(0);
const id = setInterval(() => state.set(state.get() + 1), ms)
return () => clearInterval(id)
}
computed function useDivide(value, divideBy) {
return value / divideBy;
}
computed function useFloor(value) {
return Math.floor(value);
}
computed function useQuotient(value, divideBy) {
return useFloor(useDivide(value, divideBy));
}
computed function useInnerWrapper() {
useQuotient(useCounter(5000), 3);
}
export computed function useOuterWrapper() {
return useInnerWrapper()
}
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.
@subscription({ initValue: 0 })
function useCounter(state, ms) {
const id = setInterval(() => state.set(state.get() + 1), ms)
return () => clearInterval(id)
}
@computed
function useDivide(value, divideBy) {
return value / divideBy;
}
@computed
function useFloor(value) {
return Math.floor(value);
}
@computed
function useQuotient(value, divideBy) {
return useFloor(useDivide(value, divideBy));
}
@computed
function useInnerWrapper() {
useQuotient(useCounter(5000), 3);
}
@computed
export function useOuterWrapper() {
return useInnerWrapper()
}
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.
Now, let's dive in
So there you have it, that's all of the context that went into the motivation and design behind Signals and Signalium, condensed into a (really, very, much too long) essay. I think I've finally gotten it all out there.
Now that you have all of that, it's time to start learning how to use Signals. And we'll start off with the two core-most concepts: Computeds and State.