Optimizing performance of a selectable list items with a closure

Viewed 138

I made a multi selectable list and have been trying to improve its re-rendering performance. The below code shows what I made (in my actual project, a list is much longer). Currently, when I click a list item, it re-renders every row. I want to render only a clicked row. It seems it is possible with React.memo, React.useCallback, but I haven't succeeded to use them properly for this use cases. Any feedback is welcomed! Thanks in advance.

<div id="app" />
const animals = ['Cat', 'Dog', 'Bird', 'Fish']

interface AnimalProps {
  selected: boolean;
  name: string;
  onClick: (name: string) => void;
}
const Animal = ({selected, name, onClick}: AnimalProps) => (
  <div onClick={onClick}>{`[${selected ? 'X' : ' '}]`} {name}</div>
)

const useFilter = (initialValue: string[]) => {
  const [items, setItems] = React.useState<string[]>(initialValue)
  const makeOnClick  = (item: string) => () => {
    const ix = items.indexOf(item)
    const newItems = (ix > -1) ?
      [...items.slice(0, ix), ...items.slice(ix + 1)]
      :
      [...items, item]
    setItems(newItems)
  }
  const isSelected = (item: string) => items.indexOf(item) > -1
  return { items, makeOnClick, isSelected };
 }

const App = () => {
  const {items, makeOnClick, isSelected} = useFilter([])
  return (
    <div>
      <ul>
        {animals.map(i => (
          <Animal name={i} selected={isSelected(i)} onClick={makeOnClick(i)} key={i} />
        ))}
      </ul>
      <div>Selected: {items.join(', ')}</div>
    </div>
  )
}

ReactDOM.render(<App />, document.querySelector('#app'))
"react": "17.0.2",
"react-dom": "17.0.2",
1 Answers

First, we already knew that our goal is to prevent Animal from unnecessary renders. Let's just add the React.memo to it:

const Animal = React.memo(({selected, name, onClick}: AnimalProps) => (
  <div onClick={onClick}>{`[${selected ? 'X' : ' '}]`} {name}</div>
))

Of course it's not doing anything now. We can clearly see that name and selected are static values. They are just string and boolean, so they should be able to work with React.memo well. The problem is the onClick.

In this code we can see that we are creating new functions in the loop:

const makeOnClick  = (item: string) => () => {
  // ...
}
makeOnClick(i)

On every single render, the generated function is always a different one. The current code is similar to:

<Animal onClick={() => onClick(i)} />

This will never work with React.memo, because it's always creating a new function. So our goal is to pass a static function to it. Let's just modify it to:

<Animal onClick={onClick} />

But we still need to know the i. So in the Animal component, we can do this:

const Animal = React.memo(({ selected, name, onClick }: AnimalProps) => (
  <div onClick={() => onClick(name)}>
    {`[${selected ? 'X' : ' '}]`} {name}
  </div>
));

In this case, if onClick is a static value then the Animal component won't do unnecessary renders. So our final goal is to create a static onClick function for the Animal.

Also the makeOnClick is no longer a makeOnClick now. We are not going to need a function that returns a function, so let's just name it onClick and remove the extra inner function:

const onClick  = (item: string) => {
  const ix = items.indexOf(item)
  const newItems = (ix > -1) ?
    [...items.slice(0, ix), ...items.slice(ix + 1)]
    :
    [...items, item]
  setItems(newItems)
}

Now the last step. We only need to memoize this function, but we can see that it needs the latest items. In other words, the items is its dependency. So even if we add useCallback now, we still need to add the items to the dependencies. And we know that items changes when an item is selected, useCallback won't help anything. We have to somehow remove items from the dependencies.

Luckily, setState actually supports a callback function. For exmaple:

setItems(items => [...items, 'foo'])

Which means, we can access to the current items, without having items a dependency. So we only need to change the setItems to use an update function:

const onClick = React.useCallback((item: string) => {
  setItems((items) => {
    const ix = items.indexOf(item);
    const newItems =
      ix > -1
        ? [...items.slice(0, ix), ...items.slice(ix + 1)]
        : [...items, item];
    return newItems;
  });
}, []);

It should be working now. See the full working code: https://stackblitz.com/edit/react-ts-foxpnu?file=index.tsx

You can inspect the renders by using React Developer Tools, or just add some conosle.log to the Animal component.

Related