solid-courses / solidjs-the-complete-guide

A comprehensive guide to reactive web development with Solid and TypeScript
0 stars 0 forks source link

Improving Selectors #6

Closed snnsnn closed 23 hours ago

snnsnn commented 6 days ago

Selecting Items with Selectors

Solid provides a utility function, createSelector, for the efficient comparison of a given value to the one stored in a signal, which comes in handy when working with lists.

createSelector takes a signal, creates an internal computation to monitor the signal, and returns a function. The returned function takes a value and returns a boolean, indicating whether the provided value is equal to the one stored in the signal:

const [active, setActive] = createSignal(1);
const isSelected = createSelector(active);

isSelected(2); // ← This acts like an accessor.

The function returned from createSelector is neither a signal nor a memo. It is a hand-crafted function that acts like a signal accessor with an argument. Listeners are captured and cached by their keys. Key is the value passed to the selector function. When the original signal updates, the value corresponding to each key is re-evaluated. If the result is different, listeners registered under that key are notified. This makes the update operations O(1) instead of O(n). Please note that the selector function reduces the number of updates, not the number of comparisons; we still have each item compared to the value stored in the signal.

The createSelector function is designed to determine if a particular list item is selected or meets a specific criterion, using a predicate function. This enables the application of a distinctive style and the association of specific behaviors with the items that match. The default predicate function simply compares the current value to the one stored in the signal. Let's have an example where we select an item using its index value:

import { createSelector, createSignal, For } from 'solid-js';
import { render } from 'solid-js/web';

interface State {
  selected: number | undefined;
  items: Array<{ name: string, price: number, quantity: number }>
}

const App = () => {
  const [state, setState] = createSignal<State>({
    selected: undefined,
    items: [
      { name: 'T-Shirt', price: 3.20, quantity: 3 },
      { name: 'Shoes', price: 10.00, quantity: 1 },
      { name: 'Jeans', price: 10.30, quantity: 2 },
    ]
  });

  const isSelected = createSelector(() => state().selected);

  const handleClick = (index: number) => {
    setState(prev => ({ ...prev, selected: index }))
  };

  return (
    <div>
      <p>Please select an item by clicking on it.</p>
      <div>Selected Index: {state().selected}</div>
      <ul>
        <For each={state().items}>
          {(item, index) => {
            return (
              <li
                onClick={[handleClick, index()]}
                style={{ color: isSelected(index()) ? 'green' : 'inherit' }}
              >
                <input type="checkbox" checked={isSelected(index())} />
                <span>{item.name} x {item.quantity}</span>
                <span>Price: ${(item.price * item.quantity).toFixed(2)}</span>
              </li>
            );
          }}
        </For>
      </ul>
    </div>

  );
};
render(() => <App />, document.body);

Please note that isSelected(index()) returns a boolean value, which re-executes in a reactive context. As with signals, if we assign it to a variable, we would be opting out of the reactivity:

const activeIndex = isSelected(index());
//        ↑ A non-reactive boolean value.

The createSelector function accepts a comparator function that overrides the default comparison logic. This feature provides flexibility with our selection algorithm:

createSelector<Stored, Received>(
  active,
  fn?: (received: Received, stored: Stored) => boolean
): (k: Received) => boolean;

For example, we can select multiple items by using an array to store the selected items. The custom comparator function receives the value we pass to the selector callback as its first argument and the value of the signal as the second.

import { createMemo, createSelector, createSignal, For } from 'solid-js';
import { render } from 'solid-js/web';

interface State {
  selecteds: Array<number>;
  items: Array<{ name: string, price: number, quantity: number }>
}

const App = () => {
  const [state, setState] = createSignal<State>({
    selecteds: [],
    items: [
      { name: 'T-Shirt', price: 3.20, quantity: 3 },
      { name: 'Shoes', price: 10.00, quantity: 1 },
      { name: 'Jeans', price: 10.30, quantity: 2 },
    ]
  });

  const isSelected = createSelector<Array<number>, number>(
    () => state().selecteds,
    (received, stored) => {
      return stored.includes(received);
    });

  const totalCost = createMemo(() => state().items.reduce((total, curr) => {
    total = total + (curr.price * curr.quantity);
    return total;
  }, 0));

  const handleClick = (index: number) => {
    setState(prev => {
      const selecteds = prev.selecteds.includes(index) ?
        prev.selecteds.filter(el => el !== index) :
        [...prev.selecteds, index];
      return { ...prev, selecteds };
    });
  };

  return (
    <div>
      <p>Please toggle selection of an item by clicking on it.</p>
      <div>Selected Items: {JSON.stringify(state().selecteds)}</div>
      <ul>
        <For each={state().items}>
          {(item, index) => {
            return (
              <li
                onClick={[handleClick, index()]}
                style={{ color: isSelected(index()) ? 'green' : 'inherit' }}
              >
                <input type="checkbox" checked={isSelected(index())} />
                <span>{item.name} x {item.quantity}</span>{` `}
                <span>Price: ${(item.price * item.quantity).toFixed(2)}</span>
              </li>
            );
          }}
        </For>
      </ul>
      <div>Total: {totalCost().toFixed(2)}$</div>
    </div>

  );
};
render(() => <App />, document.body);

To toggle an item’s selection status, we click on it. If the item is not already in the selecteds array, we add it; if it is already selected, we remove it.

We also introduced a memo to calculate the total cost directly from the state. This could be refined to consider only the selected items, giving clients one last chance to review their purchasing decisions. You might try implementing this logic as an exercise.

grobitto commented 6 days ago

Hi mate! It was my comment on reddit that started this conversation originally.

I already saw the new chapter 2 days ago after you've updated the book, and it looks great. Thank you for all the hard work

snnsnn commented 6 days ago

I already saw the new chapter 2 days ago after you've updated the book,

Yes, but the examples did not felt right. I updated the examples, re-structured the flow, also had a reminder here and there:

Please note that the selector function reduces the number of updates, not the number of comparisons; we still have each item compared to the value stored in the signal.

People usually think that it is the number of comparison that gets eliminated.

This is the updated content, but did not updated the book yet. I would really appreciate if you could go over the text once more. Thank you.

grobitto commented 5 days ago

It is really hard to spot something now, cause when you get it - it becomes obvious :)

But I remember I was struggling to understand what "updates" or "re-renders" this eliminates, because the result will be exactly the same with createSelector and without it, no additional dom nodes will be created/re-created etc. Later I got that although result will be exactly the same, number of effects solid has to run on signal change will be much less, just the changed ones.

snnsnn commented 5 days ago

number of effects solid has to run on signal change

Yes, thinking about it, number of updates is not clear enough. I should've emphasized that it is the number of effects to be re-executed.

Please note that the selector function reduces the number of effects that need to be re-executed when the original signal changes, not the number of comparisons.

That confusing term, "updates" (and the signal which is supposed to be returned from the comparator) came from the earlier documentation, I wanted to keep them to make the explanation more relatable to the people who consult the documentation, but I should have get rid of it.

Thanks, your insight was really helpful and to the point.