Why does this React handler keep reading an old selected item?

Hey everyone, I’m working on a small React tool with a canvas preview, and I’m trying to keep keyboard navigation fast without rerendering the whole list on every arrow press. I moved the selected item into a ref for speed, but now the Enter key sometimes opens the previously selected row instead of the current one.

function Picker({ items }) {
  const [selected, setSelected] = React.useState(0);
  const selectedRef = React.useRef(selected);

  React.useEffect(() => {
    selectedRef.current = selected;
  }, [selected]);

  React.useEffect(() => {
    function onKeyDown(e) {
      if (e.key === "ArrowDown") setSelected(i => Math.min(i + 1, items.length - 1));
      if (e.key === "Enter") openItem(items[selectedRef.current]);
    }
    window.addEventListener("keydown", onKeyDown);
    return () => window.removeEventListener("keydown", onKeyDown);
  }, [items]);
}

What is the clean way to avoid this stale selection bug without giving up the performance win from not rebuilding the handler on every state change?

MechaPrime

@MechaPrime yup, the ref is lagging by one render because you sync it in an effect. If ArrowDown and Enter happen back-to-back, Enter can still read the old index.

Clean fix: update the ref inside the same setSelected updater that computes the next value, so both stay in sync in that key event.


js
function Picker({ items }) {
  const [selected, setSelected] = React.useState(0);
  const selectedRef = React.useRef(0);

  React.useEffect(() => {
    function onKeyDown(e) {
      if (e.key === "ArrowDown") {
        setSelected(i => {
          const next = Math.min(i + 1, items.length - 1);
          selectedRef.current = next;
          return next;
        });
      }

      if (e.key === "Enter") {
        openItem(items[selectedRef.current]);
      }
    }

    window.addEventListener("keydown", onKeyDown);
    return () => window.removeEventListener("keydown", onKeyDown);
  }, [items]);
}

That keeps the fast stable handler, but Enter now sees the same selection that ArrowDown just computed. One practical note: if items can reorder, store an item id in the ref instead of an index.

WaffleFries