Replies: 6 comments 23 replies
-
I'm just gonna reply here for High-Level Direction for Future Work.
const [get, set] = createWritable({
get(track) {
track(); // implicit subscription
return someValue();
},
set(value, trigger) {
doSomething(value);
trigger(); // Notify subscriptions
},
}); |
Beta Was this translation helpful? Give feedback.
-
There is another interesting case. Suppose we have an ActiveComponent state and 100500 components with the IsActive state. The naive solution looks like drawing the second state as a function of the first:
The problem with this approach is that changing the active component causes a recalculation of 100500 states, 100498 of which will not change. Reactions are much better in such cases:
It is important that the state of the previous active component be reset automatically, since the reaction has not accessed it. |
Beta Was this translation helpful? Give feedback.
-
One of the missed thing is flow / tracked transaction - ability to manipulate around nested side effects. Mobx has it, but as I know it isn't popular (coz of generators needed, probably). |
Beta Was this translation helpful? Give feedback.
-
About the function setter. Wouldn't it be better to have function setters as a property of a normal setter? setCount.derived(count => count + 1) This will allow users to set with functions like this: setHandler(() => console.log('foo')) I'm unsure which interface is more commonly used between the plain value setter and the function setter. However if the function setter is more frequently used in common, we can also just provide a plain value setter with something like |
Beta Was this translation helpful? Give feedback.
-
Given that we're getting rid of computeds, is there any reason we couldn't remove the current renderEffects and rename createComputed to createRenderEffect? I'm not entirely sure what we're benefitting by separating out the queue if we can't rerun here... |
Beta Was this translation helpful? Give feedback.
-
I recently read Future Reactivity Design. What do you think of the dirty marking and topological sorting hybrid? |
Beta Was this translation helpful? Give feedback.
-
I may edit this over time so keep that in mind as I intend this to be extensive.
Introduction
Before we can talk about reactivity for the future we need to get on the same page. This is an extensive topic with a lot of nuances so it is important that the expectations and requirements are understood up front. Right down to language and definitions. This will not be an introduction or even an advanced tutorial. My intention is to lay out the problem space and the design decisions in a way that will be easy for anyone to pick up and be on the same page who is already familiar with these topics.
Reactive Fundamentals
Signals
Examples:
createSignal
,createStore
Signals are the base primitive of any fine-grained reactive library and thankfully are probably the least contentious when looking at the design space. In so they are the least likely to change and the primitive I'm generally the happiest with. That being said there are still 3 consideration points that will and do come up.
1. Read/Write Segregation
Decision - We love read/write segregation
Importance - high
Confidence - highest
There is a decent amount of conversation on what API should be and it mostly comes down to assignment syntax vs setter and shared function for read and write. Solid employs read/write segregation throughout. Getters separate from setters. This means that mutation is via setter since by assignment would tie read and write together.
2. Equality Comparison
Decision - Block write on equivalent value and allow custom comparer
Importance - high
Confidence - high
Different libraries decide whether to set their reactive atom with the same value triggers. Some even offer custom comparers. Solid defaults to not setting a new value if it is equivalent to the previous. It blocks write, not just notification. After checking other libraries this seems standard. Besides doing less work, I think it is preferable that downstream dependencies are working with the same value currently in the signal. If they were to drift apart due to an equality check it may be unexpected.
Having the ability to update with the same value in some libraries has an important impact. For example for things like streams (RxJS) values are seen as a form iterable so repeat values are just a sequence. Svelte makes good use of setting the same values to allow things like:
It is a good way to do mutations and still have things update. Immutable interfaces don't require it. One could always have just done this in Svelte and have it work:
However, things that mutate in-place like
.sort
get accidentally lost with this. In-place mutations are awkward for us anyway though since we don't really diff so they should be avoided. Additionally, since any downstream derivation is going to guard on equality trying to do a chain on mutation isn't going to necessarily propagate unless you make that also not compare.3. Function Setter
Decision - Allow the function setter
Importance - low
Confidence - medium
The ability to use a function setter is probably more useful for us than even in React because it untracks the read, something important in tracked contexts (and can prevent infinite loops). It honestly is great, except when setting functions the unfamiliar will trip on this, not understanding why their functions are called immediately. And the way Solid is built, there are a lot functions. So any generic place you use it you end up always setting in the function form.
This also makes Typescript more of a pain but it is good. Usually the times it fails it's when you pass something in and don't ensure it isn't a function.
Effects
Examples:
createEffect
,createRenderEffect
,createReaction
Effects are the reactive side effects that let our reactive system transform the world. Solid really doubles down on this, where classically these things are just seen as reactions and don't have any extra meaning. Why they take this on is because we are a framework that does a lot of rendering and certain guarantees are beneficial.
1. Effects writing Signals
Decision - Generally, don't do this
Importance - high
Confidence - high
Even getting past the infinite loop potential, effects are for interacting with the outside world not synchronizing the reactive system. We can only record the dependencies of Effects and the Signals they would write only contain their subscribers. This breaks our ability to traverse the graph since we don't even know what Signals could potential be updated when an Effect may need to be updated. From a render cycle perspective means we start over.
When people look at reactive notation and think about the equivalent in Solid, derivations should never be Effects. Like if the following describes a derivation.
A
is always the sum ofB + C
The implementation in Solid for that is definitely not this:
Picture:
If we used Effects this falls apart. As the program has to choose an order to notify. Generally, it will go
A, C, skip E, B, D, E
.. Without knowing the dependencies theD
has onB
to know to run those first, it will read the previous value ofD
insideE
when it runs, and it will run E twice. The problem here is the first run will show an inconsistent state with updatedC
and outdatedD
.Like anything, there are exceptions to the rule. Things like Animations or things that synchronize with the rendered DOM like triggered layouts. These will always result in more rendering and it is expected. But these are really the only place you want to be doing this.
2. Synchronous Execution
Decision - This is the way to have consistent reactivity
Importance - highest
Confidence - high
This is a funny one as most pure fine-grained reactive libraries are synchronous, but almost no JS framework is. Now all DOM updates should be applied at the same time to be consistent, but they are often deferred to the next microtask. People assume this means Solid doesn't have the means to batch or schedule updates, but we do. It's just on the main task. That's why we have function wrappers on
render
. All our code works in function wrappers which means we can always run the queue at the end of execution.So Solid does schedule Effects to the end. Reactive libraries don't maintain multiple queues, but we do as sort of a middle ground. We have that aspect from frameworks while keeping synchronicity. It also lets us collect and defer executing effects for any given change. We know everything we need to know about it synchronously so when we would run effects we can just hold them instead. This is important for Suspense and Transitions.
3. Effects don't run during SSR
Decision - Render Effects are special effects that run on the Server
Importance - high
Confidence - highest
The obvious reason is no DOM on the server would make this awkward. In a sense, we're just holding them for when the app gets to its final home. However, rendering in Solid is an Effect and similarly, we need to render new paths during Concurrent rendering. So not all Effects can be deferred. Render Effects run as soon as they can and are not beholden to normal Effect rules first run. Afterward, on update, they are scheduled. But Render Effects are guaranteed to run before user-defined ones.
We define these as a different primitive rather than a scheduling option because they have different usage. A different purpose
we want people to associate with them as a hint of when they are the right choice to use.
4. Effects are reducing
Decision - Effects store the result of the previous run and pass them into the next execution
Importance - low
Confidence - medium
Being built on the same base as derivations storing a value is trivial. At first this appears useless perhaps, and it means we don't have space to have returns be cleanup functions. However, this is a good means to do diffs. Solid minimally does diffs but it is convenient to store a previous value to compare against. Moreover being part of the primitive it is concurrent rendering safe. We basically remove an unnecessary closure that would be stale across branches.
Derivations
Examples:
createMemo
,createResource
,createDeferred
,createSelector
Derivations connect the dots between our Signals and Effects and are basically the only other primitive type. So if something can't be categorized as Signal, Effect or Derivation it probably doesn't belong (sorry
createComputed
). The key to derivations is they keep subscriptions and dependencies so they are able to be traversed to ensure optimal execution.1. Equality Comparison
Decision - Block write on equivalent value and allow custom comparer
Importance - high
Confidence - high
Most of the reasoning for this one is the same as for Signals. However, this ability has big implications on the reactive system. If derivations can choose update/notify then they cannot purely be evaluated on read. Updating a dependency doesn't guarantee that its subscriber will run. Many simpler reactive systems just subscribe through. The benefit of that is it ensures the correct execution order without extra consideration.
2. Evaluate on Pull
Decision - Stale derived values will update immediately if read instead of waiting for execution
Importance - high
Confidence - highest
This is what allows consistency while executing without holding all values in the past. Solid today does this hybrid push/pull behavior. We always execute the first time eagerly though and then queue future updates that may get read early. In the future we can consider deferring first execution until read the first time.
3. Reactive Tree Ownership
Decision - Regardless of laziness of evaluation derivations are owned where they are defined
Importance - high
Confidence - high
Solid uses auto registering disposal to handle complex tree hierarchies without messing with manual disposal which makes authoring the UI much nicer. However it is the other main reason (other than synchronous batching) that we have
createRoot
. Signals are not owned, and it is fairly understandable that Effects need cleanup. Derivations are in this middle ground where in theory you could have them cleanup based on whether they currently have any subscribers.This might even tempt you to push ownership into where they are read and this could beneficial for some things. However, since the whole point of derivations is reduce execution it would only run under the context of the first read. This would lead hard to trace behavior. Since it would depend on the history of execution and not just the current state.
High-Level Direction for Future Work
1. Deprecating
createComputed
Given the learnings of now more than a decade of these sorts of reactive systems being used to build JavaScript UIs I think that while the majority of the current structures are good, we need to re-evaluate these systems to proceed into the future. For the most part since Knockout we've all more or less been building the same things through MobX, Vue, Solid and many more. We've gotten better guarantees and more predictable execution but the types of primitives we have focused on have basically been the same.
One thing that is very clear is the importance of keeping side effects isolated and keeping derivations pure. The thing is derivations today may not be fully adequate for the task. Our need for purity isn't just to avoid side effect management, but because only through derivations can we properly handle our guarantees of single execution on change. We need both subscribers and dependencies to walk our graph.
That means that some things classically that fall under side effects don't in our model. Things like async data requests are to be viewed as an async derivation rather than an Effect. It is just an async transformation on the data flow that starts with a query => data out.
createComputed
, a particularly useful tool, is also particularly problematic for this reason so a decent part of the effort here is to deprecate its usage.2. Writable Memo's
One primitive missing from Solid but common in libraries like Vue and Knockout are writable memos. They, mostly are syntactic sugar in those frameworks. For example imagine something like:
You might note that in Solid you could just:
So this isn't really necessary since we separate read/write anyway. However there is a desire to reset signals with computations, or override calculated values manually. So writeable memo's are definitely important tool in allowing us to turn code that looks like Effects into derivations.
This generalization makes sense however it still has important details to consider.
3. Lazy Memo's
Making derivations run lazy without messing with ownership is probably relatively easy to do and beneficial for performance. More so it probably won't have any implications on behavior so we can do this without introducing breaking changes. However, it does open the door for new types of considerations since it will be possible to express derivations in loops without them immediately triggering an infinite loop until first read. Currently doing so will execute immediately and error out.
4. Derived Resources
One of the trickiest things about resources is that they register reads (trigger Suspense) where they are accessed. So if you read a resource in a Memo that is used further down in the tree, it's actually where that Memo is instantiated. For instance if you were chaining resources it is where they are defined now that is Suspended instead of where the resource is read.
One solution is chain the promise factory but it is more complicated. The canonical solution for this involves reading from
.latest
probably but this doesn't help when we do want to propagate the it through.Earliest ideas involved pushing Owner Context (which Suspense uses) down to read but as mentioned above this probably won't work. Staleness colors any derived data so it is a unique property that would need to propagate through all subscribers. And since we hold running Effects when doing Suspense and Transitions we'd need to register the read when notifying from any derivation that is subscribed to by Effects.
The thinking here means most of the read logic out of the resources themselves and into the core of the reactive system. Staleness needs to be a core reactive concern moving forward which feels like a sane place to be. And it also re-enforces the desire for lazy derivation behavior. But there are still a lot of details to work through.
5. Derived Stores
Derivations have the role of either merging finer grained updates together into coarser projections or creating more fine-grained guards around specific expressions. This makes them natural fit for
getters
in Stores. However, sometimes we are getting immutable data we need to diff and need a way to opt in. This is especially true for resources coming from the server. The process for doing so usually involves intercepting the setter to callreconcile
on the internal storage, and having that internal storage wrapped as store proxy when passed out. An easy way to do the first part is through the comparator but it usually also involves wrapping the getter. Unfortunately all these primitives have different signatures so still looking for a one size fits all API.6. Better Transitions
The current solution is barely adequate leveraging the fact that we can oversubscribe without much consequence as we are building towards a shared future. @modderme123 has been working on an idea for an approach where we can cleanup a lot of this logic around Transitions based still on a single future. But we should consider for 2.0 time period the viability of multiple Transitions.
My current mindset towards multiple Transitions is not that actually have separate ones where there is overlap on the reactive graph but only them to be independent if they are not entangled. If a change would pull in computations from a different transition then their fates are joined. This isn't the only approach but it is sensible because it ensures we aren't creating excessive DOM nodes on different branches (if they are the same they will use the same), and it removes the need for merging. We can just play updates on top of each other as it makes sense.
7. Serializing the Reactive Graph
The other big effort is planning for serializing the reactive graph. In Solid the reactive graph is basically everything which means if we can serialize/deserialize it we can pause it and resume it opening up completely new capabilities. This is more than just serializing/deserializing values but being able to handle dependencies and subscribers so that already existing or seriailized dependencies can be wired up.
This area needs more defining but obvious applications are:
Basically all places where we could really benefit. The challenge is this may require a completely different structure for reactivity(to do the lookups globally) and we need to be conscious of performance.
Continuing the Discussion
This article is aimed to collect as much of the decision and potential directions we could take things, and possibly link and act as index on the effort. For now it might be best to open individual discussions for any of the future direction topics. But more than happy to continue this discussion on anything presented here, or other ideas worth considering.
Beta Was this translation helpful? Give feedback.
All reactions