downshift: Infinite render loop when the toggle button's ref is stored in a state variable

  • downshift version: 8.1.0
  • node version: 18
  • pnpm version: 8.6.12

Relevant code or config

<div {...getToggleButtonProps({ref: setRef})}>

What you did:

I am trying to set a ref on the toggle button of a custom Select component.

What happened:

Selecting an item on a mobile device triggers a re-render loop.

Uncaught Error: Maximum update depth exceeded. This can happen when a component repeatedly calls setState inside componentWillUpdate or componentDidUpdate. React limits the number of nested updates to prevent infinite loops.
    at checkForNestedUpdates (react-dom.development.js:27292:11)
    at scheduleUpdateOnFiber (react-dom.development.js:25475:3)
    at dispatchSetState (react-dom.development.js:17527:7)
    at eval (downshift.esm.js:124:9)
    at Array.forEach (<anonymous>)
    at eval (downshift.esm.js:122:10)
    at safelyDetachRef (react-dom.development.js:22908:22)
    at commitMutationEffectsOnFiber (react-dom.development.js:24351:13)
    at recursivelyTraverseMutationEffects (react-dom.development.js:24273:7)
    at commitMutationEffectsOnFiber (react-dom.development.js:24293:9)
    ...

Reproduction repository:

Open this codesandbox’s app in a new tab, open the devtool with touch simulation enabled, click the label and select an item.

Problem description:

This only happens when trying to store the toggle button’s ref in a state variable. The issue does not happen when removing the ref prop given to getToggleButtonProps.

This only happens with react-dom’s createRoot function, so it may be related to https://github.com/downshift-js/downshift/issues/1384.

This issue is similar to https://github.com/downshift-js/downshift/issues/1511, but it happens without any other library like formik or floating-ui and the stack trace isn’t the same, so I assumed this is a different underlying issue.

About this issue

  • Original URL
  • State: closed
  • Created 10 months ago
  • Reactions: 2
  • Comments: 21 (1 by maintainers)

Most upvoted comments

Experiencing this issue as well. Originally, I noticed this as we use Downshift alongside Floating UI which uses this method to store their refs through a callback ref - I’ve replicated that behaviour in CodeSandbox as well.

I believe this comment in the Formik issue is similar to what we’re experiencing, with the stack trace pointing to the handleRefs function.

Also similar to the other issue reported with Formik, it seems like the itemClick function is being called multiple times (also logged in the code sandbox above as well).

Hi @silviuaavram, problem seems to be on getToggleButtonProps when i pass ref: setReference from floating-ui i have error: image

If the ref prop is not passed, everything works fine. If i will pass ref directly to button works everthing fine but i have other error from downshift: downshift: The ref prop “ref” from getToggleButtonProps was not applied correctly on your element. image

What does not make sense at all is why only the clicking items triggers this infinite loop. Toggling the button works, arrow keys work, enter key works.

So far I can only tell that, after the clicking action happens, the dispatch kicks a ItemClick state change, state changes, render happens, and then the useControlledReducer will go wild. It will re-calculate state with the ItemClick state change, apparently the new state is different, another render happens, then the re-calculate happens again, and so on.

Hi

I’ve run into the same kind of the issue today, If you look closer into implementation it does make sense that re-render occurs, following props getters:

  • getMenuProps
  • getInputProps
  • getToggleButtonProps

Are calling ref callback (in this case, setState) when we call getXProps getter. If you’re using setState for storing ref you’re running into setState/re-render loop, pseudo code:

function Component() {
  const combobox = useCombobox({ items: [] });
  const [ref, setRef] = useState(null)

  return <div {...combobox.getToggleButtonProps({ ref: setRef })} />
}

getToggleProps() {
   ....
   refs.forEach(() => ref(node)) //call setRef (which is actually `setState` call) and re-renders if underlying dom node is different (different reference).
   ... 
}

One thing is weird to me though, why this is only reproducible on touch devices. That’s why my theory might be completely invalidated by downshift team.

My fix for this issue is wrapping call getXProps into useMemo with proper dependencies. In your case @aliceHendicott my fix is:

  const setReference = useCallback(
    (node) => {
      _setReference(node);
    },
    [_setReference]
  );

  const toggleProps = useMemo(() => {
    return getToggleButtonProps({ ref: setReference });
  }, [setReference]);
  
  <div {...toggleProps} />