Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(ComboBox): add missing dependencies to useEffect #8308

Conversation

emyarod
Copy link
Member

@emyarod emyarod commented Apr 5, 2021

Closes #8306

This PR adds missing dependencies to the Combobox useEffect to avoid a render loop when the component is controlled and rerendered on input changes

Testing / Reviewing

Substitute the Combobox component in the example code sandbox with a built version of the component from this PR, and observe that removing the setTimeout in the example app no longer causes the component to render infinitely

@netlify
Copy link

netlify bot commented Apr 5, 2021

Deploy preview for carbon-elements ready!

Built with commit dde99eb

https://deploy-preview-8308--carbon-elements.netlify.app

@netlify
Copy link

netlify bot commented Apr 5, 2021

Deploy preview for carbon-components-react ready!

Built with commit dde99eb

https://deploy-preview-8308--carbon-components-react.netlify.app

@@ -150,7 +150,7 @@ const ComboBox = (props) => {
if (onInputChange) {
onInputChange(inputValue);
}
});
}, [onInputChange, inputValue]);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be helpful if we added a useSavedCallback type hook for these situations? It seems like the effect is parameterized by inputValue and we wouldn't want to re-run this effect when onInputChange happens (especially if we're passed a function that's updated each render)

useSavedCallback could look like:

const savedOnInputChange = useSavedCallback(onInputChange);

// ...

useEffect(() => {
  const { current: onInputChange } = savedOnInputChange;
  if (onInputChange) {
    onInputChange(inputValue);
  }
}, [inputValue]);

And the implementation:

function useSavedCallback(callback) {
  const ref = useRef(callback);

  useEffect(() => {
    ref.current = callback;
  });

  return ref;
}

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if I see this optimization as strictly necessary in this case but it may be worth keeping in mind

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would useCallback be enough to memoize onInputChange and avoid unnecessarily re-running the effect?

Copy link
Contributor

@joshblack joshblack Apr 8, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@emyarod I think it's something that's helpful for us to do. In this case, the effect would synchronize with the input value changing and not the handler changing (although it would need that to correctly dispatch the update).

A totally extreme example of this idea would be if the parent component had a timer:

function Parent() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const intervalId = window.setInterval(() => {
      setCount((count) => count + 1);
    }, 1000);
    return () => {
      window.clearInterval(intervalId);
    };
  }, []);

  return (
    <>
      <p>The current count is: {count}</p>
      <ComboBox onChange={() => { /* ... */ }} />
    </>
  );
}

Because we have the effect synchronizing with the callback, we end up with the effect being called repeatedly with the same value. Instead, if the callback is saved and then invoked by an effect parameterized by inputValue it would only run when the value changes.

Here is a quick demo of this idea: https://codesandbox.io/s/vigorous-goldwasser-rp3fe?file=/src/App.js

Screen.Recording.2021-04-08.at.11.01.58.mov

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wouldn't saving the handler in a ref inside the parent function accomplish the same effect?

Copy link
Contributor

@joshblack joshblack Apr 8, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure, would need to explore. In this scenario, I'm imagining that the parent would be the person using Carbon so just was illustrating that splitting the effect out in this way would help out in the default case without them having to change how they author their code.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

something like this I assume https://codesandbox.io/s/pensive-sun-4ijti

given that the event handler is provided by the Carbon consumer, I am not sure about optimizing it for them by default (including useCallback usage as @tay1orjones suggested). although I think I am less hesitant about baking useCallback into our component if we are certain we want to optimize the handler no matter what

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not opposed to optimizing it now to prevent problems down the line, though if it is an uncommon use case that the majority of consumers won't run into we can point them here (or other docs?) to instruct on how they can fix downstream in their application code.

Having this hook could be useful though, it would apply to any callback placed in a useEffect that has a dependency on one or more variables/props. I'm not sure how common that is in the codebase right now though.

Copy link
Contributor

@joshblack joshblack Apr 9, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Definitely meant the counter example as the extreme case, the big thing is that in a default setup this implementation would:

  • Have the effect and handler be called for non-related updates
    • The effect would be called because the identity changes for function declarations or arrow functions
  • Have the handler be called twice in intended situations like if the parent is using the value from onChange to update state
    • State update triggers a render that then creates a new reference for a function declaration or arrow function, this then calls the effect to be run which is the point above

And it seems like introducing this convention would:

  • Make sure the handler is only called when the value is changed and at no other time
  • Make sure the handler is only called once per input change

@nertzy
Copy link

nertzy commented Apr 19, 2021

Would it be possible to have a first pass of a fix that only considers the inputValue changing and not the callback function?

As a consumer, I only expect onInputChange to be called when the input changes. If I pass a new function on every render, I would still only expect one call, to whatever the function is set to at the time the input change occurs.

In my application, onInputChange is getting called unconditionally on every render, even though both inputValue and the callback function are not changing. When I type in an unrelated separate text field, it triggers onInputChange on every combo box on the page. We essentially had to write our own condition in our callback to remember the previous value of the input and only continue if the input value actually changed, which feels like a waste of effort.

Thanks for looking at this so deeply. I hope that we can figure out a good solution for the long term.

@joshblack
Copy link
Contributor

@nertzy would this thread address what you're describing or would it be another change? 👀 #8308 (comment)

@nertzy
Copy link

nertzy commented Apr 22, 2021

@nertzy would this thread address what you're describing or would it be another change? 👀 #8308 (comment)

If I understand it correctly, I think it would work for my use case. As long as [inputValue] is the second argument to the useEffect call, then my application won't receive a call to the callback on every render.

I don't know enough about the useSavedCallback concept to have any advice on whether that would be necessary.

@emyarod
Copy link
Member Author

emyarod commented Apr 29, 2021

@joshblack @tay1orjones circling back on this, so is the expectation here to include a new useSavedCallback hook or can this fix go in first?

@emyarod emyarod force-pushed the 8306-combobox-oninputchange-useeffect branch from e6647e6 to 7eaad2b Compare April 29, 2021 15:26
@joshblack
Copy link
Contributor

@emyarod I don't think you need to do the hook explicitly, but I think saving the callback in a ref and calling it would be great for this PR and then we can get it in 👍 (Like in this pattern: https://github.com/carbon-design-system/carbon/blob/main/docs/style.md#hooks-that-use-a-callback)

@emyarod emyarod requested a review from joshblack April 30, 2021 15:22
packages/react/src/components/ComboBox/ComboBox.js Outdated Show resolved Hide resolved
packages/react/src/components/ComboBox/ComboBox.js Outdated Show resolved Hide resolved
packages/react/src/components/ComboBox/ComboBox.js Outdated Show resolved Hide resolved
packages/react/src/components/ComboBox/ComboBox.js Outdated Show resolved Hide resolved
@emyarod emyarod force-pushed the 8306-combobox-oninputchange-useeffect branch from 8f4df8b to f9efcc7 Compare April 30, 2021 15:30
@emyarod emyarod requested a review from joshblack April 30, 2021 15:30
@emyarod emyarod force-pushed the 8306-combobox-oninputchange-useeffect branch from f9efcc7 to 1ced73f Compare April 30, 2021 15:45
@emyarod emyarod requested a review from joshblack April 30, 2021 15:52
@kodiakhq kodiakhq bot merged commit faf17a4 into carbon-design-system:main Apr 30, 2021
@emyarod emyarod deleted the 8306-combobox-oninputchange-useeffect branch May 3, 2021 15:55
@emyarod emyarod mentioned this pull request May 17, 2021
94 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
5 participants