Introduction
When I first saw SolidJS and Preact Signals, I thought they are interesting but they are different from React. What made me motivated is @preact/signals-react. I didn’t like the original implementation using REACT INTERNALS, and that drove me to create something.
We already had Jotai. Jotai atoms are like signals, representing reactive sources, and allowing to form a dependency graph or a data flow. The missing piece was signal-like syntax without hooks.
The initial version of jotai-signal
library was developed as a proof of concept.
It provided a custom JSX transfomer to hide hooks behind the scene.
It was using experimentl_use
to read React context.
I also developed jotai-uncontrolled to bypass diffing,
which is another aspect of signals.
While jotai-signal
was a syntax sugar, jotai-uncontrolled
has a performance benefit.
Meanwhile, the next version of jotai-signal
uses Jotai v2 API,
and avoids experimental_use
by exposing Store interface.
This allows us to consider using it more seriously.
Furthermore, when discussing internally, I got an idea to combine
jotai-signal
and jotai-uncontrolled
.
We can bypass diffing automatically in some cases.
Along with it, I developed valtio-signal
and zustand-signal
by almost copying code from jotai-signal
.
This made me think it would be possible to create
an abstract layer, and create-react-signals
was born.
This post will explain how create-react-signals
work internally.
What are signals?
In the context of create-react-signals
,
a signal is kind of a store that has three functions:
subscribe
: a function to add a callback which will be invoked when a signal value changes.getValue
: a function to return the signal value.setValue
: a function to update the signal value.
Using a signal with a custom hook should be pretty easy.
const [value, setValue] = useSignal(signal);
You can just subscribe in the hooks and return the current value.
Implementation should be trivial with useSyncExternalStore
.
But, we want to just use the signal so that it automatically subscribes and updates value.
It could be done with a compiler at build time, but our approach is a runtime transformation.
How does it transform code?
Let’s assume we have a simple signal.
Suppose countSignal
contains a number.
Our component would look like this:
const Component = () => {
return (
<div>{countSignal}</div>
);
};
React doesn’t understand this, because countSignal
is a special object
that can’t be rendered.
We transform the code into something like the following.
const SignalsRerenderer = ({ signal, render }) => {
const [, rerender] = useReducer((c) => c + 1), 0);
useEffect(() => signal.subscribe(), [signal]);
return render();
};
const Component = () => {
return SignalsRerenderer({
signal: countSignal,
render: () => (
<div>{countSignal.getValue()}</div>
),
});
};
.subscribe
. and .getValue
methods are pseudo code,
as a signal doesn’t explicitly have such methods.
There are other simplifications, like only handling one signal
and omitting memoization code.
To implement the transformation,
create-react-signals
creates a custom createElement
from
original React.createElement
.
Overriding the original React.createElement
might be
one solution, but it seems too hacky.
Why custom JSX transfomer?
Forunately, we can customize JSX transfomer.
The recent bundler supports @jsxImportSource
.
- https://babeljs.io/docs/babel-preset-react#importsource
- https://www.typescriptlang.org/tsconfig#jsxImportSource
You can also specify a pragma in your source code.
/** @jsxImportSource jotai-signal */
This technique is used in some projects, for example, Emotion.
One of the biggest questions in this approach is that specifying the custom transformer can be seen unusual. (Well, it’s a hack after all, so it’s not usual.)
How can signals create signals?
What if a signal value is an object?
Suppose personSignal
has a value { firstName: 'first', lastName: 'last' }
.
Using an object signal in JSX like the following doesn’t work.
const Component = () => {
return (
<div>{personSignal}</div>
);
};
As an object can’t be rendered, we need to do something like the following.
const Component = () => {
return (
<div>{personSignal.firstName}</div>
);
};
Now, to make that work, personSignal.firstName
shouldn’t be a string.
It has to be another signal. Otherwise, we can’t subscribe to it.
How do we solve it? Our current solution is Proxies. When there’s a property access to a signal, it will create a new signal. This is done recursively.
If personSignal.firstName
is used in JSX,
it will skip updating when only lastName
changes.
(Note that object signals are currently not supported in jotai-signal
.)
How does it skip diffing?
As noted previously, jotai-uncontrolled
allows skipping diffing.
It works like uncontrolled components.
The technique is using ref
and manipulating DOM directly.
This post doesn’t go too much in details about the implementation of uncontrolled components.
There’s fallback mechanism if uncontrolled components don’t work.
jotai-signal v0.7.0 is released! https://t.co/aonMlSdAtj
— Daishi Kato (@dai_shi) January 21, 2023
This is the first library I developed, inspired by preactjs/signals-react. This version has a new capability, which is to use `callback ref` to manipulate DOM directly if possible. pic.twitter.com/BrfcmZ8laV
This approach is still considered experimental and might have a pitfall.
What are difficulties?
One of unsolved issues is
how to get signal values outside JSX.
Our signal has getValue
function, so we have to call it.
There are some options:
// callable signal
const value = countSignal();
// string property access
const value = countSignal.value;
// symbol property access
const value = countSignal[VALUE_SYMBOL];
So, how is it difficult?
Consider the personSignal
case.
// callable signal
const value = personSignal.firstName();
// string property access
const value = personSignal.firstName.value;
// symbol property access
const value = personSignal.firstName[VALUE_SYMBOL];
At first, the callable signal style looked the best, but it can’t be typed in TypeScript.
The string property looks the most familiar, but it can cause naming conflict.
The symbol property has no drawbacks, but not very handy.
The same problem applies to setValue
.
// callable signal
const value = personSignal.firstName('newValue');
// string property access
const value = personSignal.firstName.value = 'newValue';
// symbol property access
const value = personSignal.firstName[VALUE_SYMBOL] = 'newValue';
We could change our mind and use a property to return a signal.
const countValue = countContainer.value;
const countSignal = countContainer.signal;
In summary, it’s still an open problem.
Closing notes
In addition to jotai-signal
, valtio-signal
and zustand-signal
,
we can technically create redux-signal
.
In addition to jotai/valtio/zustand-signal, redux-signal is technically possible. But, I feel like I've already done too much. 😂 https://t.co/87tUeKVlw6
— Daishi Kato (@dai_shi) February 25, 2023
I think signals in React are still a open research field.
If React will provide a new primitive such as use(Observable)
,
we could explore this approach further.
I hope such a primitive to be `use(AsyncIterable)`. https://t.co/F9hn0JHKrI https://t.co/uLx2YaJqf8
— Daishi Kato (@dai_shi) February 24, 2023
Until then, let’s play with userland solutions.