Bloomca / veles

UI library with main focus on performance
https://bloomca.github.io/veles/
MIT License
48 stars 0 forks source link

Add support for `.select` method on `State` #36

Open Bloomca opened 5 months ago

Bloomca commented 5 months ago

This is just an idea, but I think there is value in allowing to create substates.

Basically, we'll be able to do const newState = state.select(selector). What is the point? Well, we can effectively denormalize data without touching the "main" store.

For example, we can have:

type Store = {
  tasks: { [id: number]: Task },
  projects: { [id: number]: Project }
}

Meanwhile a denormalized state can contain task arrays which are grouped by due date, project ID, section ID, etc. This can have several benefits compared to typical selector functions from reselect.

First, we can use useValueSelector and if we write a custom comparator, we can easily solve a problem of invalidating previous results. E.g. if we have 12 projects, it is pretty tricky to write a selector which will correctly memoize tasks for each project, we have to compromise to either call it on the first render (create a custom selector per component and memoize it), or just hope that the re-rendering component will have the same project ID, and hence the selector won't be invalidated.

This problem won't exist here, because we can ensure that a subset of a group will be the same by reference (if no tasks have changed there, of course).

Second, we'll still have access to combineStates, which by default doesn't do anything without subscriptions, so with smart comparators we can potentially avoid pretty much any updates where the data didn't change.

Last, by creating a tree of subscriptions, we can calculate pretty much everything in a single iteration. This is a pretty niche use-case, because technically both a single traversal and multiple ones are O(1), but in case we have thousands of items, and we are not too careful, there definitely can be a difference between traversing over 10000 items once, and traversing between 10000 items 30 times (imagine calculating number of tasks per each project in the sidebar separately).

The only downside is that we can calculate some things prematurely, especially if we utilize comparators, so maybe some lazy mechanics can be introduced.

Bloomca commented 5 months ago

I might be reinventing the wheel here.

First, it is not really necessary to include that in the State type at all. Consider this:

import { createState, type State } from "veles";

function selectState<F, T>(
  state: State<F>,
  selector: (state: F) => T
): State<T> {
  const initialValue = selector(state.getValue());

  const newState = createState(initialValue);
  state.trackValueSelector(
    selector,
    (selectedState) => {
      newState.setValue(selectedState);
    },
    { skipFirstCall: true }
  );

  return newState;
}

This implementation is more or less what I wanted, and essentially is a map operation on observables. If you think of State as a stream of values, we can subscribe to it, modify it as we want, and then continue to use it. Also we can insert custom comparator at every opportunity.

Bloomca commented 5 months ago

After thinking some more on the nature of createState and potential composability, I feel like it is better to refactor the approach and to extract useAttribute, useValue and so on into their own functions.

So:

<div>
  {useValue(nameState)}
</div>

The idea is that this way we can accept other observables. I am only somewhat familiar with Rxjs, I will need to review what's available, what is their API and if I can offer selector functionality (imo selectors are pretty important to avoid unnecessary re-renders, and cascading selectors should be really good for that).

For now, the API will stay the same, but since I basically re-created observables, I don't see the point to expand basic tools for them. Maybe I can implement from for rxjs to transform, and that would be enough (Solidjs does that).