- Intro
- TC39 Signals Proposal
- Reactive Library Comparison
- Benchmark Results
- Takeaways
- Related Resources
- License
I fell in love with MobX back in 2015, which then kind of fell out of popularity after React switched from classes to functions and legacy JS decorators took a nosedive. So when I saw the new TC39 Signals Proposal combined with TC39 Decorators hitting Stage 3 and general adoption, I got excited and started wondering what a modern replacement for MobX would look like.
I wanted to start by doing a deep dive on the current state of reactive TypeScript libraries aimed at understanding the most actively maintained, battle-tested, and feature-rich signal libraries that exist which support standalone usage1 to build upon.
Goals for this deep dive:
- Bring more awareness to the TC39 Signals Proposal.
- Better understand the space of signals design tradeoffs and adoption in order to provide feedback to the TC39 Signals Proposal and related standardization efforts.
- Choose a mature existing signals library to build off of while the Signals Proposal matures with the intent being to switch to the official polyfill once it's ready.
The TC39 Signals Proposal aims to add a standard reactive Signal primitive to JavaScript. π₯
This has the potential to greatly change how the majority of state management is handled across JS/TS, just like when Promise
was introduced as a TC39 standard back in 2015.
TC39 Signals are currently a Stage 1 Proposal. There is a signal-polyfill implementation, but it is not recommended for production just yet, and it currently lacks some common features like batch and effect. A sister project, signal-utils, adds some nice functionality like opt-in support for deep, Proxy-based tracking, decorators, an effect
implementation, and a batch
implementation. It is unclear, however, how stable / mature these APIs are.
Library | Actively developed? | Supports deep? | Autotracks deps? | Supports react ? |
Standalone usage?1 | Bundle size |
---|---|---|---|---|---|---|
Signals Proposal Polyfill | ? | β2 | β | β | β | 9kb |
Preact Signals | β | β3 | β | β | β | 4kb |
Vue Reactivity | β | β | β | β4 | β | 20kb |
Solid Signals | β | β 5 | β | β | β | 21kb |
Qwik Signals | β | β 6 | β | β? | β | 83kb |
Angular Signals | β | β | β | β | β | 360kb |
Svelte v57 | β | β | β | β | β | 35kb |
MobX | β | β | β | β | β | 64kb |
Reactively | β | β | β | β | β | 2.6kb |
Signia | β | β | β | β | β | 9kb |
NanoStores8 | β | β | β | β | β | 4kb |
Jotai | β | β | β | β | β | 7kb |
Valtio | β | β | β | β | β | 7kb |
I also considered other reactive libraries such as cellx, Glimmer's tracked, RxJS, Starbeam, S.js, compostate, uSignal, Pota, Kairo, mol wire, Oby, and more, but didn't include them above because they are either not actively maintained, don't support TypeScript, or are not close enough to the signals proposal to warrant comparison.
Note
Did I miss your favorite reactive library or get something wrong? Please create an issue to let me know. π
These results are from my fork of @milomg's excellent js-reactivity-benchmark, which aggregates signal benchmarks from several libraries together.
Note that some reactive libraries are missing from this benchmark because they either don't support standalone usage or because they didn't support the benchmark's abstract signals abstraction.
Note also that none of the benchmark tests use deep reactivity of objects, though many of them do test performance on deep graphs of shallow signals. It would be an interesting extension to compare the performance of deeply reactive objects as well.
These results were last updated September 2024 on an M3 Macbook Pro using Node.js v22.4.1.
(this section is largely subjective)
After putting this comparison together, I feel even more strongly that this ecosystem would seriously benefit from a mature Signals Standard. Maybe that's just confirmation bias at work, but there's so much duplicated work across these libs that's not interoperable, and complex state management is at the heart of so many apps. Having a standard Signal
implementation in JavaScript would do wonders for the ecosystem. π―
All of the reactive libs offer approximately similar performance on the benchmark with a few outliers.
On the negative side of perf, we have Angular Signals and signal-polyfill. I'm not surprised that the Signals standard's polyfill has significantly worse perf since it's more of a proof-of-concept and explicitly not production ready. I was surprised, however, at how poorly the perf was of Angular Signals, which is presumably backed by a team of seasoned Google engineers.
On the positive side of perf, Svelte v5's runes performed extremely well (discussion). The only unfortunate part of this is that Svelte's reactivity APIs ($state
, $derived
, $effect
, etc) are all internal and are not officially supported by the Svelte team for standalone, external usage as opposed to, for instance, @vue/reactivity
which is exposed specifically for this purpose.
Also on the positive side of perf is Reactively, which makes sense given that the author @milomg also authored most of the benchmark. It's a shame that reactively
hasn't been adopted by any larger projects that I'm aware of which could help with maintenance, but maybe the Signals standard can take some inspiration from Reactively's seemingly more efficient implementation going forwards. The author wrote a detailed description of his approach here.
I was surprised that several popular libs hung or ran into memory exceptions running some of the benchmark tests (notably Mobx and Valtio), though these benchmark tests do create fairly deep signal graphs of different shapes and usage patterns to try and stress test things.
Several projects have used Preact Signals as a sort of unofficial standard to build off of because it is lightweight, efficient, and thoroughly tested. Out of all the shallow reactive libraries which support standalone usage, Preact Signals is imho the best one in terms of DX and maturity.
The only real downside is that it doesn't support deep reactivity, which I've found to be extremely valuable as an opt-in feature for real-world usage, especially when using reactivity outside of frontend use cases (like game engines). There are several projects3 which add deep signal support to @preact/signals-core
via Proxy-based tracking, but they are less mature and not backed by a large team, which makes me hesitant to adopt them.
Vue's reactivity (@vue/reactivity
) is imho the most mature and well-maintained TypeScript signals implementation which supports both deep + shallow tracking along with standalone usage.
Naming-wise, I don't really like ref
as a name for signal
especially because refs already have a well-defined meaning in React land, but this is a minor nit.
My benchmarking exposed one perf issue with @vue/reactivity
which was promptly fixed by the Vue team, resulting in a ~5% speedup across all benchmarks.
Overall, I really like @vue/reactivity
and am leaning towards using it as a base for my work going forwards until the TC39 Signals Proposal is mature enough to use as a replacement.
MobX was one of the earliest reactive JS libraries to gain popularity, and I shipped several successful apps with it back in the day, but these days it seems to be suffering from its own success and backwards compatibility.
Namely, MobX still supports non-Proxy-based deep reactivity for older browsers. It still supports legacy decorators. It supports six different patterns for adding reactivity to a class. MobX packages don't export ESM. And their list of caveat limitations is thoroughly confusing. MobX is also contains a subtle, but very severe perf footgun when used outside of frontend frameworks that was exposed by my benchmarking. This is meant as constructive criticism, fwiw; I know how hard it is to run OSS projects.
I'd love to see a major MobX v7 release which fixes these issues and drops backwards compatibility. I'd also love to see the MobX maintainers explore using the Signals standard polyfill to help push it along.
One important design decision is whether to provide direct access to reactive values (like Vue's reactive) or to wrap the reactive value in an accessor (like Vue's ref). Most reactive libs wrap values in an accessor, even though it hurts the DX a bit, and Vue's docs have a great explanation for why this is a useful tradeoff.
Reactive wrappers generally provide access to the underlying value using one of the following: wrapper.get()
, wrapper.value
, get(wrapper)
, or wrapper()
, and some support automatic unwrapping when used in JSX or templates and/or at compile-time (like Svelte). (more info on this pattern from signia)
Another important design decision is whether reactivity should be shallow or deep.
Personally, I'd rather have an API that defaults to deep reactivity (with the creation of deep signals being lazy via Proxy-based tracking). You can then expose a shallow API that is opt-in instead of defaulting to shallow and having to opt-in to deep tracking. The reason is simple: less cognitive overhead. As an app developer, reasoning about state in a complex app is already complicated enough. I'd rather default to throwing my app state and models into a reactive system and knowing that mutations will "just work" without having to think about whether the shape of my data works with the shallow reactivity contraint β and then only opting in to shallow reactivity as a perf optimization where it makes sense.
This is, of course, a subjective preference, and one could easily make the opposite argument. E.g., defaulting to shallow means less magic (aka unseen complexity) lurking in the app's state.
It's worth noting that both Qwik and Solid differentiate between the two approaches by using shallow signals and deep stores. I think this is a nice compromise, since they're at least separated into different concepts which makes it easier to reason about as long as the shallow signals and deep stores interact in a way that "just works".
Examples of APIs that default to deep reactivity include @vue/reactivity
's ref
, MobX, and Angular. Most of the signal APIs in this comparison default to shallow signals, but that's also because most don't support deep tracking.
Some libraries like Valtio, NanoStores, and Jotai don't automatically track dependencies referenced inside of computed properties and effects, which makes their relative DX significantly more cumbersome and from my benchmarks, not any faster. The same can also be said for React's own useEffect, which requires the developer to manually declare an effect's dependencies. Ain't nobody got time for that.
One of the more important but less-visible design considerations is how a framework handles scoping and garbage collection of its reactive dependency graph(s).
Many of these frameworks are optimized for usage in component-based frontend frameworks like React/Vue/Svelte/Angular, and their default effect lifecyles therefore tend to work well out-of-the-box with component lifecycles (mount, render, unmount, etc). When it comes to standalone usage and/or usage outside of a component hierarchy, the ability to define separate root scopes becomes much more important.
Vue's effectScope RFC provides a great description of the problem and their approach to solving it. Other examples of effect scoping include Svelte v5's $effect.root, S.js's root, and Solid's createRoot.
Most of these frameworks, however, do not support any sort of effect scoping, meaning that all effects and their respective dependency graphs are shared globally by default.
For a more in-depth guide to signals, check out these awesome resources:
- TC39 Signals Proposal
- Vue's Guide to Reactivity in Depth
- Signia's Signals Overview and Design Space Exploration
- Reactive Algorithms Breakdown
MIT Β© Travis Fischer
Footnotes
-
Standalone usage refers to how easy it is to use just the reactivity functionality of the library as an isolated package βΒ without being tied to any specific frontend libraries ala React, Vue, Svelte, etc. β© β©2
-
While the official Signals Proposal focuses on core dependency tracking of shallow signals, its sister project signal-utils offers support for deep, Proxy-based tracking. β©
-
There is a third-party project built on top of Preact Signals called DeepSignal. There is also another third-party project called @preact-signals/utils which offers a port of Vue 3's deep tracking API. Note that neither
DeepSignal
or@preact-signals/utils
seems very mature, and neither are backed by a robust team of maintainers. β© β©2 -
Vue's reactivity system (
@vue/reactivity
) doesn't provide out-of-the-box support forreact
, but it should be possible to integrate via effectScope as evidenced by the now deprecated reactivue project. β© -
Solid supports both shallow signals and deep stores. β©
-
Qwik supports both shallow signals and deep stores. β©
-
Svelte has embraced reactivity perhaps more than any other frontend framework given that it's v5 reactive engine based on runes expects compiler support as opposed to direct, standalone usage. This is a really interesting and efficient approach, but it's also tightly coupled with Svelte as a whole and not currently meant for general JS/TS usage outside of Svelte. β©
-
Nanostores is a nice, lean reactivity library, but it's
computed
requires you to explicitly provide a single dependency, making its DX very awkward compared to other libraries which automatically handle any number of dependency subscriptions. β©