teafuljs/teaful

Proposal: Suport createSelector

aralroca opened this issue · 7 comments

Create hooks for calculated store values:

Example:

const usePriceWithIva = createSelector(
  () => useStore.cart.price, 
  (val) => val + (val * 0.21)
);

The val + (val * 0.21) calc only will be executed once for all the components that use the usePriceWithIva(); hook. It only will be calculated again when cart.price change and is going to do a rerender for all these components that use this hook.

For this simple calculation, it does not make any sense to use the createSelector, but it can be useful for very complex calculations.

And is also possible to do the same with helpers:

const getPriceWithIva = createSelector(
  () => getStore.cart.price, 
  (val) => val + (val * 0.21)
);

Example of complete example:

import createStore from "teaful";
import createSelector from "teaful/createSelector";

const initialState = {
  username: "Aral",
  age: 31,
  cart: {
    price: 3,
    items: []
  }
};

export const { useStore, getStore, withStore } = createStore(initialState);

const usePriceWithIva = createSelector(
  () => useStore.cart.price, 
  (val) => val + (val * 0.21)
);

export default function CartPriceWithIva() {
  const price = usePriceWithIva();
  const price2 = usePriceWithIva();
  const price3 = usePriceWithIva();
  const price4 = usePriceWithIva();
  const price5 = usePriceWithIva();

  return (
    <ul>
      <li>{price}</li>
      <li>{price2}</li>
      <li>{price3}</li>
      <li>{price4}</li>
      <li>{price5}</li>
    </ul>
  );
}

And an example of createSelector:

export default function createSelector(getProxy, calc) {
  let last;

  return () => {
    const [value] = getProxy()();

    if (!last || last.value !== value) {
      last = {
        calculated: calc(value),
        value
      };
    }

    return last.calculated;
  };
}

this seems to be a good idea if I want to customize some basic primitive types, some uses cases I can think of are like formatting a store value for UI, like showing price with currency or adding discount oven name concatenation. But one thing which is not clear is what's the bigger use case, since teaful already gives us fragmented access to store and we can do the memoization in React easily(assuming its same for other framework), why do this selector thing which is just basic primitive value check

@Chetan-unbounce In fact it would be very similar to useMemo with a calculated store value but with a small difference: the hook that returns the createStore makes only 1 calculation even if this hook is at the same time in many components. With useMemo it would do 1 calculation per component.

An example:

const useExpensiveCalc = createSelector(
  () => useStore.items, 
  (val) => expensiveCalc(val)
);

function Component1() {
  const val = useExpensiveCalc()
  // ...
}

function Component2() {
  const val = useExpensiveCalc()
  // ...
}

function Component3() {
  const [items] = useStore.items()
  const val = useMemo(() => expensiveCalc(items), [items])
  // ...
}

function Component4() {
  const [items] = useStore.items()
  const val = useMemo(() => expensiveCalc(items), [items])
  // ...
}

export default function App() {
    return (
     <>
        <Component1 />
        <Component2 />
        <Component3 />
        <Component4 />
     </>
    )
}

At the first render:

  • with createStore:
    • Component 1: It's doing the expensive calc
    • Component 2: It's using a cached value. It's not executing the expensive function.
  • with useMemo:
    • Component 3: It's doing the expensive calc
    • Component 4: It's doing the expensive calc

In other rerenders for other things:

  • All Components (with createStore and with useMemo) are using the cached expensive value. No calculation in this step.

After a re-render for items update:

  • with createStore:
    • Component 1: It's doing the expensive calc again
    • Component 2: It's using a cached value. It's not executing the expensive function.
  • with useMemo:
    • Component 3: It's doing the expensive calc again
    • Component 4: It's doing the expensive calc again

This is a very specific case and only makes sense for very complex calculations and heavy applications. So it does not make the createStore totally necessary. In order not to increase the size of the library since many people would not use it if anything I would upload it in a separate package.

However, for the moment it's only a proposal, it would be necessary to do several tests and to be totally sure of the results.

Perhaps the createSelector could be renamed to memoStoreCalc or something like that.

I've modified it a little bit to accept as many proxy entries as you want to consume:

Examples:

const useExpensiveCalc = createSelector(
  () => useStore.items,
  (items) => expensiveCalc(items)
);
const useExpensiveCalc = createSelector(
  () => useStore.items,
  () => useStore.user,
  (items, user) => expensiveCalc(items, user)
);
const useExpensiveCalc = createSelector(
  () => useStore.items,
  () => useStore.user,
  () => useStore.cart.price,
  (items, user, price) => expensiveCalc(items, user, price)
);

Example of createSelector (or with another name):

function createSelector(...proxies) {
  const calc = proxies.pop();
  const cache = new Map();
  let calculated;

  return () => {
    let inCache = true;

    const values = proxies.map((p) => {
      const [value] = p()();
      inCache &= cache.has(p) && cache.get(p) === value;
      return value;
    });

    if (!inCache) {
      calculated = calc(...values);
      proxies.forEach((p, index) => cache.set(p, values[index]));
    }

    return calculated;
  };
}

The example works, because we are using the same instance of the function returned from createSelector. But if we try to make the logic generic and move it to a new file like

const useSelector = (stateFun, cb) => {
  return createSelector(stateFun, cb); // same function we have just takes in custom state val and callback
};

export default useSelector;

now if we try to import this in two different files we would get different instance of the returned function and the lexical scope will be different and the caching won't work.

@aralroca can u add an example with the above use case too, where we make it generic and import it from a different file into different components in different files.

@Chetan-unbounce I did a little modification to support to define the stateFun & cb to useSelector.

I hope this works for you, if not we will look for a better way to implement it. At the moment it is just a draft.

Way 1

export const useCalculatedValue = createSelector(
    () => useStore.cart.price,
    () => useStore.username,
    (price, username) => `${username} - ${price * 3}`
);

And to use the selector:

const calculatedValue = useCalculatedValue()

Way 2

export const useSelector = createSelector();

And to use the selector:

const calculatedValue = useSelector(
    () => useStore.cart.price,
    () => useStore.username,
    (price, username) => `${username} - ${price * 3}`
 );

createSelector:

function createSelector(...proxiesCreateSelector) {
  const cache = new Map();
  let calculated;

  return (...proxiesSelector) => {
    const proxies = proxiesCreateSelector.length
      ? proxiesCreateSelector
      : proxiesSelector;

    const calc = proxies.pop();
    let inCache = true;

    const values = proxies.map((p) => {
      const [value] = p()();
      inCache &= cache.has(p) && cache.get(p) === value;
      return value;
    });

    if (!inCache) {
      calculated = calc(...values);
      proxies.forEach((p, index) => cache.set(p, values[index]));
    }

    return calculated;
  };
}

I think it is better to create the hooks directly with the createSelector. Why do you need a separate useSelector?