solidjs / solid

A declarative, efficient, and flexible JavaScript library for building user interfaces.
https://solidjs.com
MIT License
32.15k stars 916 forks source link

Guidelines for using Redux #175

Closed ziriax closed 4 years ago

ziriax commented 4 years ago

For of all thanks for this nice library. I briefly worked together with Conal Elliott, the father of Functional Reactive Programming, and ever since, I have the feeling that we are all doing UIs wrong. Reactive programming should be the future IMHO. ReactJS is moving into that direction with its hooks and dependency arrays, but adds overhead that should not be required. A good synchronous dataflow engine should solve this. I have been using SodiumFRP for a while, but for some reason, that library doesn't seem to catch on. I think this is because existing languages are not a good fit for reactive programming, one needs a different language, and a compiler. FlapJAX used to be such a thing, but never became popular, and recently Svelte is moving in that direction too. While Sodium and FlapJAX are pure push-based systems, MobX uses proxies to detect the dependencies while they are being "pulled", "pushing" an update when any of the deps change. At least that's what I understand from it ;-)

Solid seems to be something in-between, and looks very promising.

I was wandering how you track updates when using an immutable normalized state, as typical in Redux.

Is Solid performant when directly using a Redux normalized state? Because when accessing store.table[id], table will be different for every update, but the value itself will typically be the same. However since table updates, all deps tracking it must be checked, and that could be slow for large UIs.

So I guess it is important to generate a denormalized view-model state, using selectors or whatever technique, and pass that as state, just as in a typical React Redux app?

Or would using a vector trie or a simple immutable dictionary with buckets already solve the performance problem, without the need for selectors etc?

Or would you recommend not using Redux at all for Solid and large apps, and using setState with a denormalized hierarchical state that potentially is a cyclic object graph?

ryansolid commented 4 years ago

Great questions. Your thinking is generally correct. Solid works the same way as MobX more or less. There are some differences in implementation and API but the mechanisms are more or less the same. The one key difference on the reactive side is that Solid attempts to handle the memory management for you by creating everything under a scope (even if it is not tracking) so that it collects computations to be freed up. So when computation scopes re-evaluate they don't only free up all the previous subscriptions they also free all computations created under that scope in the previous run. This makes nesting easy which really aids in building up views.

Immutable data is problematic since the it changes things all the way back to the root. With mutable proxies you want to isolate changes in the leaves. If the base item is a new one every time you are redoing everything. You can't do change detection easily that way. I actually did at one point have an immutable state implementation using a proxy where the proxy resolved everything to a location so it didn't care that the underlying implementation was immutable. However, it was less performant.

So the way I've been dealing with it has been doing a referential diff, banking on the fact that immutable data doesn't care about the previous state so I can mutate it with the changes. And in so I get fine-grained updates again. However, when it does care like undo's or timetravel I need to do some more awkward cloning. See https://codesandbox.io/s/pkjw38r8mj. Does this scale? Well there is a diffing overhead every update to ensure we aren't triggering unnecessary work everywhere. At one point I wrote my own version of Redux to avoid the need to do that. Basically using reducers to codify mutations that would be applied, rather than to create the next state.

But generally yes you are writing selectors to pull parts out since you don't want to diff the whole thing everywhere. I still think though that has scaling limitations since you are doing wasted work if multiple things listen to the same part. I try to support redux but it isn't going to be as optimal as using Solid's own state. Even though I setup immutable interfaces the underlying implementation is mutable so change it doesn't lose the locality of the update.

There are a lot of options here. I can picture a setup handling the de-normalization in the background. Especially based on a convention (similar to the diffing). But generally, it's just extra work. We go from knowing the change at the source, losing it in the immutable structure, then referentially checking to find it again.

ziriax commented 4 years ago

Thanks for fast response!

In my case I noticed that using JavaScript records for the Redux table is a no-go. One needs an immutable tree, vector trie, or immutable dictionary, something that provides "divide and conquer" at the root. Nothing new, the Clojure and Haskell guys figured that out a long time ago. The idea is that local changes, e.g. changing a single property, should not update the whole UI. But sometimes it gets tricky. For example, a to-do list where you keep the selected item as an identifier in the redux state. Passing this identifier to every to-do item view would basically update everything just by selecting a different one. Converting that to an isSelected property on a view-model solves that. The question is then, do you use selectors to compute the isSelected? If you do that, then way too many selectors will run when just getting an object from the table, so the selectors must understand the underlying hierarchical structure of the trie... That is not that obvious. So it's better to store that derived state in the objects themselves, but then the model is not fully normalized anymore, it violates the single point of definition... This dilemma in design is horrible, but typical IMHO. I opted for a fully normalized model, and have a special custom layer to do the conversion to a denormalized view-model, using WeakMap etc for caching. But that is fragile, and a general solution would be ideal. Also, since the conversion is not lazy, it might generate view-models that are not actually rendered at all...

One experiment I did (see my old Sodium FRP React demo that I haven't updated for years), the normalized state is actually just serialised state, like MobX often handles things. The view-model itself is a completely reactive circuit, where every readable property is a signal (cell in Sodium terms), and every writable property is a Sodium sink. Since the cells and sinks are constant, passing these to React only triggers a single render. From that point on, the components listen to changes to the cells, and update local state. Lists are a bit different, since these are Cell<Array<Record<K, Cell<V>>>>, and you need dedicated components to add/remove item-views. IMO the experiment worked fine, but using React for something like that was major overkill, because a virtual DOM is not needed with the fine grained FRP. Again your libraries seem to provide a solution for this...

However, writing all the business logic in an FRP style requires the UI team to understand such a new paradigm, something that until now I haven't been able to sell to customers...

Again Solid seems to be a good compromise.

Furthermore Sodium relies on a garbage collector with finalizers, something JS doesn't offer. So like React hooks, it also requires you to pass in explicit dependencies for some higher order functions, not ideal, although workable with a jslint rule.

Most people that I know that use reactive APIs use RxJs, but that is for asynchronous programming, and UI business logic and UIs is mostly synchronous dataflow programming, like FRP. For example, if you would make a videogame with RxJS where every object animates, and provide an Observable<number> for the current time, then your reactive tree computation would explode, since every CombineLatest(f, input, time) would evaluate twice when input depended on time, so the total number of updates would be O(n^2)... Not to mention glitches cause by the superfluous computations... So RxJs is a no-go to keep derived state in sync IMO.

Solid doesn't seem to solve the inherent problems you have with mutable state, e.g. when the mutable state also stores cached dependencies (a stupid example would be priceInclVAT = priceExclVAT * (1+customerVAT), the business logic must make sure these are kept up-to-date. Solid doesn't help with that I guess? But people are used to that anyway. With FRP that part is also handled by the framework, FRP doesn't really care if it is view or some cached derived state, all inputs are reactive, as are all derived computations. I think an interesting area of research is lazy on demand FRP circuits that are only activated when at least one actuator (view, motor, speaker, ...) needs it, and destroyed when the last actuator is gone. From your comments, it seems Solid does something like that too.

Okay enough theoretical talk, sorry for the braindump, time for me to start experimenting with Solid! ;-)

ryansolid commented 4 years ago

The selectedId vs isSelected problem is one I've wrestled with for years. When I first made Solid it was high on my list of must solves. In fact the JS Framework Benchmark(https://github.com/krausest/js-framework-benchmark) has this scenario in it in Benchmark 4. I've gone over so many iterations of this, but never found one I completely like. I've implemented it working with really verbose syntax in the view via the compiler, to now where I finally just left it in userland. I can see if I can dig up some of the old syntax but it was not very pretty.

Basically with Solid the best we can do without going down to isSelected is to make a single computation that checks against all rows. Doesn't work well with view syntax. But because it is just slicing that single aspect in the computaton update it isn't that expensive. Creation cost is cheap being a single computation instead of one per row, and update time is not as bad as if you update everything. Still solutions that go to isSelected have slightly better performance. Wiring it up manually isn't hard but I wanted was to make a view syntax for it. Like a reactive delegation syntax. It's not unlike event delegation with the DOM. I still think about it a decent amount but I just sort of conceded that current syntax is a limitation and maybe as TypeScript support more JSX features I could revisit. It's also possible there is something I could do with the reactive system but that seems harder.

ryansolid commented 4 years ago

Closing as conversation seems to have run its course.

aminya commented 3 years ago

@ryansolid

Do you want to move this issue to the discussions? It is an interesting topic which many people might face.

By the way, one of your codesandbox links wasn't working with the new Solid-js which I fixed: https://codesandbox.io/s/solid-redux-undoable-todos-forked-71fcn

ryansolid commented 3 years ago

What was the issue? I just updated.. seemed fine. But I might be missing something.

aminya commented 3 years ago

The For import was missing, and Eslint was nagging. https://codesandbox.io/s/pkjw38r8mj