How to prevent unnecessary re-renders with React Hooks, function components and function depending on item list

Viewed 729

List of items to render

Given a list of items (coming from the server):

const itemsFromServer = {
  "1": {
    id: "1",
    value: "test"
  },
  "2": {
    id: "2",
    value: "another row"
  }
};

Function component for each item

We want to render each item, but only when necessary and something changes:

const Item = React.memo(function Item({ id, value, onChange, onSave }) {
  console.log("render", id);

  return (
    <li>
      <input
        value={value}
        onChange={event => onChange(id, event.target.value)}
      />
      <button onClick={() => onSave(id)}>Save</button>
    </li>
  );
});

ItemList function component with a handleSave function that needs to be memoized.

And there is a possibility to save each individual item:

function ItemList() {
  const [items, setItems] = useState(itemsFromServer);

  const handleChange = useCallback(
    function handleChange(id, value) {
      setItems(currentItems => {
        return {
          ...currentItems,
          [id]: {
            ...currentItems[id],
            value
          }
        };
      });
    },
    [setItems]
  );

  async function handleSave(id) {
    const item = items[id];

    if (item.value.length < 5) {
      alert("Incorrect length.");
      return;
    }

    await save(item);

    alert("Save done :)");
  }

  return (
    <ul>
      {Object.values(items).map(item => (
        <Item
          key={item.id}
          id={item.id}
          value={item.value}
          onChange={handleChange}
          onSave={handleSave}
        />
      ))}
    </ul>
  );
}

How to prevent unnecessary re-renders of each Item when only one item changes?

Currently on each render a new handleSave function is created. When using useCallback the items object is included in the dependency list.

Possible solutions

  1. Pass value as parameter to handleSave, thus removing the items object from the dependency list of handleSave. In this example that would be a decent solution, but for multiple reasons it's not preferred in the real life scenario (eg. lots more parameters etc.).

  2. Use a separate component ItemWrapper where the handleSave function can be memoized.

function ItemWrapper({ item, onChange, onSave }) {
  const memoizedOnSave = useCallback(onSave, [item]);

  return (
    <Item
      id={item.id}
      value={item.value}
      onChange={onChange}
      onSave={memoizedOnSave}
    />
  );
}
  1. With the useRef() hook, on each change to items write it to the ref and read items from the ref inside the handleSave function.

  2. Keep a variable idToSave in the state. Set this on save. Then trigger the save function with useEffect(() => { /* save */ }, [idToSave]). "Reactively".

Question

All of the solutions above seem not ideal to me. Are there any other ways to prevent creating a new handleSave function on each render for each Item, thus preventing unnecessary re-renders? If not, is there a preferred way to do this?

CodeSandbox: https://codesandbox.io/s/wonderful-tesla-9wcph?file=/src/App.js

3 Answers

The first question I'd like to ask : is it really a problem to re-render ?

You are right that react will re-call every render for every function you have here, but your DOM should not change that much it might not be a big deal.

If you have heavy calculation while rendering Item, then you can memoize the heavy calculations.

If you really want to optimize this code, I see different solutions here:

  1. Simplest solution : change the ItemList to a class component, this way handleSave will be an instance method.
  2. Use an external form library that should work fine: you have powerfull form libraries in final-form, formik or react-hook-form
  3. Another external library : you can try recoiljs that has been build for this specific use-case

Wow this was fun! Hooks are very different then classes. I got it to work by changing your Item component.

const Item = React.memo(
  function Item({ id, value, onChange, onSave }) {
    console.log("render", id);

    return (
      <li>
        <input
          value={value}
          onChange={event => onChange(id, event.target.value)}
        />
        <button onClick={() => onSave(id)}>Save</button>
      </li>
    );
  },
  (prevProps, nextProps) => {
    // console.log("PrevProps", prevProps);
    // console.log("NextProps", nextProps);
    return prevProps.value === nextProps.value;
  }
);  

By adding the second parameter to React.memo it only updates when the value prop changes. The docs here explain that this is the equivalent of shouldComponentUpdate in classes.

I am not an expert at Hooks so anyone who can confirm or deny my logic, please chime in and let me know but I think that the reason this needs to be done is because the two functions declared in the body of the ItemList component (handleChange and handleSave) are in fact changing on each render. So when the map is happening, it passes in new instances each time for handleChange and handleSave. The Item component detects them as changes and causes a render. By passing the second parameter you can control what the Item component is testing and only check for the value prop being different and ignore the onChange and onSave.

There might be a better Hooks way to do this but I am not sure how. I updated the code sample so you can see it working.

https://codesandbox.io/s/keen-roentgen-5f25f?file=/src/App.js

I've gained some new insights (thanks Dan), and I think I prefer something like this below. Sure it might look a bit complicated for such a simple hello world example, but for real world examples it might be a good fit.

Main changes:

  • Use a reducer + dispatch for keeping state. Not required, but to make it complete. Then we don't need useCallback for the onChange handler.

  • Pass down dispatch via context. Not required, but to make it complete. Otherwise just pass down dispatch.

  • Use an ItemWrapper (or Container) component. Adds an additional component to the tree, but provides value as the structure grows. It also reflects the situation we have: each item has a save functionality that requires the entire item. But the Item component itself does not. ItemWrapper might be seen as something like a save() provider in this scenario ItemWithSave.

  • To reflect a more real world scenario there is now also a "item is saving" state and the other id that's only used in the save() function.

The final code (also see: https://codesandbox.io/s/autumn-shape-k66wy?file=/src/App.js).

Intial state, items from server

const itemsFromServer = {
  "1": {
    id: "1",
    otherIdForSavingOnly: "1-1",
    value: "test",
    isSaving: false
  },
  "2": {
    id: "2",
    otherIdForSavingOnly: "2-2",
    value: "another row",
    isSaving: false
  }
};

A reducer to manage state

function reducer(currentItems, action) {
  switch (action.type) {
    case "SET_VALUE":
      return {
        ...currentItems,
        [action.id]: {
          ...currentItems[action.id],
          value: action.value
        }
      };

    case "START_SAVE":
      return {
        ...currentItems,
        [action.id]: {
          ...currentItems[action.id],
          isSaving: true
        }
      };

    case "STOP_SAVE":
      return {
        ...currentItems,
        [action.id]: {
          ...currentItems[action.id],
          isSaving: false
        }
      };

    default:
      throw new Error();
  }
}

Our ItemList to render all items from the server

export default function ItemList() {
  const [items, dispatch] = useReducer(reducer, itemsFromServer);

  return (
    <ItemListDispatch.Provider value={dispatch}>
      <ul>
        {Object.values(items).map(item => (
          <ItemWrapper key={item.id} item={item} />
        ))}
      </ul>
    </ItemListDispatch.Provider>
  );
}

The main solution ItemWrapper or ItemWithSave

function ItemWrapper({ item }) {
  const dispatch = useContext(ItemListDispatch);

  const handleSave = useCallback(
    // Could be extracted entirely
    async function save() {
      if (item.value.length < 5) {
        alert("Incorrect length.");
        return;
      }

      dispatch({ type: "START_SAVE", id: item.id });

      // Save to API
      // eg. this will use otherId that's not necessary for the Item component
      await new Promise(resolve => setTimeout(resolve, 1000));

      dispatch({ type: "STOP_SAVE", id: item.id });
    },
    [item, dispatch]
  );

  return (
    <Item
      id={item.id}
      value={item.value}
      isSaving={item.isSaving}
      onSave={handleSave}
    />
  );
}

Our Item

const Item = React.memo(function Item({ id, value, isSaving, onSave }) {
  const dispatch = useContext(ItemListDispatch);

  console.log("render", id);

  if (isSaving) {
    return <li>Saving...</li>;
  }

  function onChange(event) {
    dispatch({ type: "SET_VALUE", id, value: event.target.value });
  }

  return (
    <li>
      <input value={value} onChange={onChange} />
      <button onClick={onSave}>Save</button>
    </li>
  );
});

Related