Q:

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

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

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.).

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}
    />
  );
}

With the useRef() hook, on each change to items write it to the ref and read items from  the ref inside the handleSave function.

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

A:

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>
  );
});

