preact icon indicating copy to clipboard operation
preact copied to clipboard

Add `preact/reactive` addon

Open marvinhagemeister opened this issue 2 years ago • 19 comments

Reactive addon

Summary

This PR adds a new addon called reactive, which is an alternative to hooks for managing state. It's main purpose is to decouple the rendering phase from state updates to ensure optimal rendering performance by default, even for complex apps. It comes with some DX improvements, such as that dependencies are tracked automatically and don't have to be specified by hand for effects.

Basic Example

import { signal } from 'preact/reactive';

// Won't re-render despite state being updated. The effect will
// trigger only when `count.value` changes, not when the component
// re-renders.
function App() {
	const [count, setCount] = signal(0);

	// Look, no dependency args
	effect(() => {
		console.log(`count: ${count.value}`);
	});

	return <NeverReRendered count={count} />;
}

// Will only render once during mounting, will not re-render
// when state is updated in <App />
function NeverReRendered({ count }) {
	return <Inner count={count} />;
}

// Whenever the button is clicked, only the <Inner /> component will rerender
function Inner({ count }) {
	return (
		<button onClick={() => setCount(count.value + 1)}>
			increment {count.value}
		</button>
	);
}

Motivation

Since the introduction of hooks we've seen more and more developers pick that as their main way to manage state. This works great for smaller applications but becomes complex and can easily lead to performance issues in bigger projects. Hooks tie state updates to rendering itself and thereby cause unnecessary re-renders of large portions of the component tree whenever state is updated. One can avoid most of these issues by manually placing memoized calls at the correct spots, but these are hard to track down.

This has been a frequent source of performance issues in projects I've consulted for over the past few years. In one example just rendering the page took 500ms on my Macbook M1 Air and 2s on a slower Android phone. This is the sole time the framework spent rendering. The layout resembled a product list which displays a few filters and a carousel at the top.

Virtual-DOM-based frameworks typically render top down from the component which holds the state. But often the state is only used in components quite a bit further down the tree which means we'll spent a lot of time comparing components which didn't change.

const MyContext = createContext(null);

function Foo(props) {
	const [data, setData] = useState(null);

	// Provider and all its children will be re-rendered on state changes,
	// unless they manually opted out.
	return (
		<MyContext.Provider value={{ data }}>{props.children}</MyContext.Provider>
	);
}

As applications grow the state of components becomes more intertwined and state often ends up being only triggered to fire an effect.

// Example of where an effect triggers a state update to trigger
// another effect
function Foo(props) {
	const [foo, setFoo] = useState(null);
	const [bar, setBar] = useState(null);

	useEffect(() => {
		// Update foo, to trigger the other effect
		setFoo('foobar');
	}, []);

	useEffect(() => {
		console.log(foo);
	}, [foo]);

	// ...
}

Whilst memoization can help in improving the performance, it tends to be used incorrectly or is easily defeated by passing a value which changes on each render.

So let's fix that. Let's find a system which avoids these problems by design. A system that only re-renders components that depend on the state that was updated.

A couple of years back when IE11 was still a thing, I've made very good experiences with various reactive libraries that track either or both read and write access to state. One example of that is the excellent MobX library.

Due to the separation of state updates and rendering the framework knows which exact components need to be updated. Instead of passing values down, you're passing descriptions on how to get that value down the tree. Depending on the framework this can be a function, a getter on an object or another kind of "box". That allows frameworks to skip huge portions of the component tree and would not trigger accidental renders caused by state updates to trigger effects.

Detailed design

One key goal of preact/reactive is to have a very lean API, similar to hooks. Frameworks based around observable primitives tend to have a rather large API surface with a lot of new concepts to learn. Our system should be minimal and allow users to compose complex features out of smaller building blocks. It should have a lot of familiar aspects and enough new ones to make the transition to it as beginner friendly as possible.

Signals

The core primitive of this new system are signals. Signals are very similar to useState.

function App() {
	const [count, setCount] = signal(0);
	// ...
}

But instead of count being the number 0, it is an object on which the original value can be accessed via the count.value. That object reference stays the same throughout all re-renderings. By ensuring a stable reference we can safely pass it down to components and track which components read its .value property. Whenever it's read inside a component, we add this component as a dependency to that signal. When the value changes we can directly update only these components and skip everything else.

Computed Signals (aka derived values)

Computed signals are a way to derive a single value from other signals. It is automatically updated whenever the signals it depends upon are updated.

function App() {
	const [name, setName] = signal('Jane Doe');
	const [age, setAge] = signal(32);

	// Automatically updated whenever `name` or `age` changes.
	const text = computed(() => {
		return `Name: ${name.value}, age: ${age.value}`;
	});

	// ...
}

This also works for conditional logic where new signals need to be subscribed to on the fly and old subscriptions need to be discarded.

function App() {
  const [count, setCount] = signal(0);
  const [foo, setFoo] = signal('foo');
  const [bar, setBar] = signal('bar');

  // Automatically subscribes and unsubscribes from `foo` and `bar`
  const text = computed(() => {
    if (count.value > 10) {
      return foo.value;
    }

    return bar.value;
  });
});

Effects

The way effects are triggered is basically a combination of useEffect and the tracking abilities of computed(). The effect will only run when the component is mounted or when the effect dependencies changes.

function App() {
	const [count, setCount] = signal(0);

	effect(() => console.log(count.value));

	// ...
}

Similar to useEffect we can return a cleanup function that is called whenever the effect is updated or the component is unmounted.

function App() {
	const [userId, setUserId] = signal(1);

	effect(() => {
		const id = userId.value;
		api.subscribeUser(id);

		return () => {
			api.unsubscribeUser(id);
		};
	});

	// ...
}

Readonly: Reacting to prop changes

Because the props of a component are a plain object and not reactive by default, effects or derived signals would not be updated whenever props change. We can use the readonly() function to create a readonly signal that always updates when the passed value changes.

function App(props) {
	// Signal is a stable reference. Value always updates
	// when `props.name` has changed.
	const name = readonly(props.name);
	const text = computed(() => `My name is: ${name.value}`);
	// ...
}

Inject: Subscribing to context

The inject() function can be used to subscribe to context. It works similar to useContext with the sole difference that the return value is wrapped in a signal.

const ThemeCtx = createContext('light');

function App(props) {
	const theme = inject(ThemeCtx);

	return <p>Theme: {theme.value}</p>;
}

Debugging

Due to the nature of the system of being able to track signal reads, we can better show in devtools why a value was updated and how state relates to each component. This work has not yet started.

Error handling

Errors can be caught via typicall Error boundaries. Since we track the component context a signal was created in, we rethrow the error on that component.

function Foo() {
	const [foo, setFoo] = signal('foo');

	const text = computed(() => {
		if (foo.value !== 'foo') {
			// Errors thrown inside a computed signal can be caught
			// with error boundaries
			throw new Error('fail');
		}

		return foo.value;
	});

	// ...
}

Drawbacks

With hooks developers had to learn about closures in depth. With a reactive system one needs to now about how variables are passed around in JavaScript (pass by value vs by reference).

Similar to hooks the callsite order must be consistent. This means that you cannot conditionally create signals on the fly.

Alternatives

Libraries like MobX track read access of observables via getters.

// Psuedo code:
// Turns object properties into observables
const obj = reactive({ foo: 1, bar: true });

// Read access is tracked via getters
const text = computed(() => obj.foo);

Whilst tracking reactivity via getters makes the code more brief, it poses a few considerable downsides. For them to work one must preserve the getters and destructuring prevents reads from being registered properly.

// Pseudo code
// Turns object properties into observables
const { foo } = reactive({ foo: 1, bar: true });

// This breaks...
const text = computed(() => foo);

Consider the use case of reacting to changes to props. Making them reactive would only be possible by keeping props as an object.

// This would work
function App(props) {
	const $props = reactive(props);
	// ...
}

// But how to track this? This is much better solved by readonly()
function App({ foo = 1, bar = true }) {
	// ...
}

Another use case to consider are other types than Objects. In a system based on tracking read access via getters we'd need to support .entries(), .values() and other iterable methods. And not just for objects, but also collection primitives like Array, Map and Set have to be intercepted.

Debugging is also made a bit trickier, because from the output of console.log can easily lead to infinite loop due to invoking all getters, if the collection object is not serialized manually beforehand.

I think this approach would introduce too many rabbit holes and complex issues, that I'd rather avoid entirely.

Adoption Strategy

With the addition of the new addon developers can introduce reactive components on a component bases in their existing projects. Adoption can therefore happen incrementally and the system happily co-exists with all current solutions.

The current plan is to leverage this module in our devtools extension as the first real world project.

Unresolved questions

This is mostly bikeshedding:

  1. Should it be foo.value or foo.$ ?
<button onClick={() => setCount(count.value + 1)}>{count.value}</button>

vs

<button onClick={() => setCount(count.$ + 1)}>{count.$}</button>
  1. inject is not a good name for reading from context.
  2. Do we have a better name for readonly?
  3. How to work with refs?

EDIT: Enhanced section about "alternative solutions" EDIT2: Add section about error handling

marvinhagemeister avatar Mar 08 '22 22:03 marvinhagemeister

Size Change: 0 B

Total Size: 47.4 kB

ℹ️ View Unchanged
Filename Size Change
compat/dist/compat.js 3.46 kB 0 B
compat/dist/compat.module.js 3.45 kB 0 B
compat/dist/compat.umd.js 3.52 kB 0 B
debug/dist/debug.js 3.01 kB 0 B
debug/dist/debug.module.js 3 kB 0 B
debug/dist/debug.umd.js 3.09 kB 0 B
devtools/dist/devtools.js 231 B 0 B
devtools/dist/devtools.module.js 240 B 0 B
devtools/dist/devtools.umd.js 307 B 0 B
dist/preact.js 3.98 kB 0 B
dist/preact.min.js 4.01 kB 0 B
dist/preact.module.js 4 kB 0 B
dist/preact.umd.js 4.04 kB 0 B
hooks/dist/hooks.js 1.15 kB 0 B
hooks/dist/hooks.module.js 1.17 kB 0 B
hooks/dist/hooks.umd.js 1.23 kB 0 B
jsx-runtime/dist/jsxRuntime.js 317 B 0 B
jsx-runtime/dist/jsxRuntime.module.js 327 B 0 B
jsx-runtime/dist/jsxRuntime.umd.js 395 B 0 B
reactive/dist/reactive.js 1.67 kB 0 B
reactive/dist/reactive.module.js 1.69 kB 0 B
reactive/dist/reactive.umd.js 1.74 kB 0 B
test-utils/dist/testUtils.js 437 B 0 B
test-utils/dist/testUtils.module.js 439 B 0 B
test-utils/dist/testUtils.umd.js 515 B 0 B

compressed-size-action

github-actions[bot] avatar Mar 09 '22 06:03 github-actions[bot]

Coverage Status

Coverage decreased (-0.08%) to 99.541% when pulling 487e3eb6d3a675ee6d9b26ae363c822b16f3d83d on reactive-addon into 1bbd687c13c1fd16f0d6393e79ea6232f55fbec4 on master.

coveralls avatar Mar 09 '22 06:03 coveralls

Wow that's really nice. I have been using Valtio by @dai-shi in one of my side projects (pure Preact, not preact/compat). I have really enjoyed the DX and UX benefits of Proxy-based reactivity:

https://github.com/pmndrs/valtio

I will be following this PR closely :) (I haven't jumped into your implementation yet, but I know that Valtio's internals are quite tricky, such as many subtle details in @dai-shi 's proxy-compare utility library, nested Proxys, cycles in the dependency graph, etc.

Thanks :) 👍

danielweck avatar Mar 09 '22 08:03 danielweck

This is awesome @marvinhagemeister glad to see reactivity coming to Preact!

porfirioribeiro avatar Mar 09 '22 18:03 porfirioribeiro

@danielweck I knew about his other projects but not valtio. It looks fantastic! Need to study it some more over the weekend 👍

marvinhagemeister avatar Mar 10 '22 11:03 marvinhagemeister

Hi! Just stopping by to ask some questions regarding the API design of these features:

  • If our intent for signal is to return a tuple, why do we need for it to be an object with a value property? Have you considered functions? (This question also applies to the other functions involved).
  • If we are returning an object with a value property, shouldn't signal return the same object but with writable capabilities instead of returning another function for writing? What was the motivation for not doing so?
  • I assume since this is usable in function components, is this, in any way, required to follow the hook rules?

lxsmnsyc avatar Mar 15 '22 12:03 lxsmnsyc

I would be interested, why you did opt to not use a prefix for those ,hook likes‘. Why the functions are called signal(…) instead of useSignal(…).
I think it is quite nice to be able to differentiate hooks from other functions just by looking at the name and it could potentially be helpful for linters. Could also be something similar maybe ,let‘, if the difference to non reactive hooks should be very obvious.

OliverGrack avatar Mar 16 '22 01:03 OliverGrack

If our intent for signal is to return a tuple, why do we need for it to be an object with a value property? Have you considered functions? (This question also applies to the other functions involved).

I've worked on a few reactive systems a few years back which used a function as the observable primitive. Similar to how solid does it. Whilst it looks nicer from syntactically, it's not possible for TypeScript to infer the value as part of control flow.

// Imagine this is either a `null` or `{ bar: number }`
const [foo] = signal(null)

if (foo() !== null) {
  // ERROR: foo() can be null
  console.log(foo().bar);
}

A workaround for that is to always pull out the value of the observable:

// Imagine this is either a `null` or `{ bar: number }`
const [foo] = signal(null)

// Pull out value from observable
const value = foo();
if (value !== null) {
  console.log(value.bar);
}

But with a property-based approach TypeScript is able to infer the correct value in control flow constructs:

// Imagine this is either a `null` or `{ bar: number }`
const [foo] = signal(null)

if (foo.value !== null) {
  // TS knows at this point that `foo.value` is not `null`
  console.log(foo.value.bar);
}

Both approaches certainly have their pros and cons. The function-primitive approach looks syntactically cleaner, but always requires the value to be pulled out of it, whereas the property approach trades a little bit of verbosity to avoid that problem.

If we are returning an object with a value property, shouldn't signal return the same object but with writable capabilities instead of returning another function for writing? What was the motivation for not doing so?

The main goal was to not deviate too much from the existing hooks API as a first step. One could certainly add writable capabilities to a signal, although there is something to be said about keeping reads and writes separate. With a separated write function you can pass a callback and avoid subscribing to that signal accidentally.

const [foo, setFoo] = signal(0)

// Update foo signal without subscribing to it
setFoo(prevValue => prevValue + 1);

I think keeping them separate we can avoid a few potential problems devs may run into. Maybe there is a way to expose the write function on the signal itself. Your comment certainly makes me think that having to pass both the signal and the write function around is a little awkward.

I assume since this is usable in function components, is this, in any way, required to follow the hook rules?

Yup, it works in function components and follows similar constraints to hooks. That means that branching should be encapsulated inside computed signals preferably. I was pondering whether to allow creating signals on the fly inside computed or dynamically inside a component, but I don't have a good grasp on what kind of problems and edge cases that will lead to. So for now I'm trying to not introduce too many new concepts at once and see where we can go from there.

marvinhagemeister avatar Mar 16 '22 08:03 marvinhagemeister

I would be interested, why you did opt to not use a prefix for those ,hook likes‘. Why the functions are called signal(…) instead of useSignal(…). I think it is quite nice to be able to differentiate hooks from other functions just by looking at the name and it could potentially be helpful for linters. Could also be something similar maybe ,let‘, if the difference to non reactive hooks should be very obvious.

Agree, I'd love to have a common prefix to be able to differentiate signals from other constructs. It would make tooling and even preact-devtools a lot easier to detect them. It's something still up for debate and I just went with the unprefixed one to get the discussion started. The use prefix seems kinda overloaded by hooks, so I'm not sure that it's good to use that for other things. Maybe something like Svelte's $ label would be a good alternative to use. What do you think?

marvinhagemeister avatar Mar 16 '22 08:03 marvinhagemeister

Maybe something like Svelte's $ label would be a good alternative to use. What do you think?

It seems nice to me $signal, $computed, ... And even "custom" reactivity functions could also be prefixed: $mousePosition(), $isDarkModePerefered()

Just be aware that Vue uses $ for compile time stuff like $ref https://vuejs.org/guide/extras/reactivity-transform.html Dunno if it might not bring some confusion

porfirioribeiro avatar Mar 16 '22 20:03 porfirioribeiro

Agreed. I like the $ prefix too. While it is convention for different things throughout the JS community. In the preact and react community I did not see it being used often.
In rxjs and angular $ is also a postfix used quite a lot, to mark an observable, which seems quite fitting to me.

OliverGrack avatar Mar 16 '22 22:03 OliverGrack

@marvinhagemeister those are some good points, thank you!

lxsmnsyc avatar Mar 17 '22 02:03 lxsmnsyc

Maybe might be valuable to someone: I did something similar with statin + statin-preact.

It's pretty much a <4kB min, <2KB gz replacement for MobX made to be used with preact (via the statin-preact bindings). I used it as an exclusive state engine for Drovp, so it's pretty stable with no known issues atm.

tomasklaen avatar Mar 22 '22 07:03 tomasklaen

  • inject is not a good name for reading from context.

What about select? The way inject works sounds like we only track the change of selected parts from the given context.

SukkaW avatar Mar 31 '22 16:03 SukkaW

If our intent for signal is to return a tuple, why do we need for it to be an object with a value property? Have you considered functions? (This question also applies to the other functions involved).

I've worked on a few reactive systems a few years back which used a function as the observable primitive. Similar to how solid does it. Whilst it looks nicer from syntactically, it's not possible for TypeScript to infer the value as part of control flow.

Arguably that is incorrect type checking though, one way or another the actual value is encapsulated into something and could change at any time, a successful if ( signal.value ) { check doesn't actually tell you anything that will provably hold true inside the whole if, unless I'm missing something 🤔, does the API maybe disallow setting the value back to undefined/null or something?

On the type checking side I'm also interested in learning how props will need to be typed, like will there be a type that says "this value but wrapped in { value: T }"? will there be a type that says "this could either be the actual value or a signal to the value"? will there be some built-in function for unwrapping props which may or may not be signals? will there be an isSignal function to tell signals apart?

fabiospampinato avatar Apr 05 '22 14:04 fabiospampinato

Btw it would be cool to see an implementation with preact/reactive of the cellx benchmark (https://codesandbox.io/s/cellx-bench-forked-s6kusj), it'd be interesting to see how it performs compared to some of the other reactive libraries.


Edit: and a preact-reactive implementation for js-framework-benchmark of course.

fabiospampinato avatar Apr 05 '22 14:04 fabiospampinato

@fabiospampinato Can check once the work here is completed. Not sure though how much the benchmark reflects real world use cases. Do you have more insight on that?

marvinhagemeister avatar Apr 05 '22 15:04 marvinhagemeister

@marvinhagemeister I think whether the differences measured by the benchmark would be measurable in the end app really depend entirely on what the app does. On average I wouldn't expect the cellx benchmark to be particularly presentative of real world performance, but I mean on average React is probably more than enough already, so I guess that means performance doesn't matter much on average maybe. FWIW I found it to be a good sort of sanity check for performance and it has helped me optimizing the library further.

fabiospampinato avatar Apr 05 '22 16:04 fabiospampinato

FWIW something like this to me seems to be the right way to do control flow type narrowing with great performance in JSX with signals, in some cases at least:

<Show when={signal}>
  {( nonNullableComputedOfSignal ) => {
     // do something with nonNullableComputedOfSignal...
  }}
</Show>

Basically you can pass to the child component a computed of the input signal that's non-nullable at the type level. This provides actually safe type checking, compared to what Preact seems to going for in this draft, and performant updates compared to Solid's solution, since we are passing down a signal, which can be leveraged for granular updates, rather than passing down a plain value, which implies the whole function will have to be called again if the signal changes.

This doesn't seem to solve the issue for more complicated conditions though, like conditions involving multiple signals, and it requires wrapping the child computation in a function, so it's not really as clean of a replacement for a plain if.

fabiospampinato avatar Apr 07 '22 15:04 fabiospampinato

Closing as we released a reactive addon in the form of @preact/signals

marvinhagemeister avatar Sep 25 '22 14:09 marvinhagemeister