Skip to content

feat(hooks): Add useDebounce Hook to Hooks #2561

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

Open
wants to merge 16 commits into
base: master
Choose a base branch
from

Conversation

ZakaryH
Copy link
Contributor

@ZakaryH ZakaryH commented May 14, 2025

Motivations

we need a convenient hook for using debounce in React. we have one that is supposed to work elsewhere but it doesn't unless it's under very specific circumstances which is not particularly useful, whereas this hook is

for a quick version of why this hook is needed at all, it's because React re-makes things including functions on re-renders. the way debouncing works is that it wants to make sure the exact same function was called within the debounce window, and so those two things conflict making debouncing more complicated in React.

Changes

Added

useDebounce hook that was previously an internal util hook now available publicly along with some docs about it.

Changed

Deprecated

Removed

Fixed

Security

Testing

verify debounce works on Combobox, DataList and components-native's InputSearch

if you simply put some logging in the method that is supposed to be getting debounced, if you type asdfghj quickly you should only get that final result as the callback argument. not a whole bunch for a as asd asdf etc.

you can also try out the hook somewhere new and exciting if you'd like.

Changes can be
tested via Pre-release


In Atlantis we use Github's built in pull request reviews.

Copy link

cloudflare-workers-and-pages bot commented May 14, 2025

Deploying atlantis with  Cloudflare Pages  Cloudflare Pages

Latest commit: 7be8f5d
Status: ✅  Deploy successful!
Preview URL: https://525fd9b8.atlantis.pages.dev
Branch Preview URL: https://export-use-debounce-hook-for.atlantis.pages.dev

View logs

Copy link

github-actions bot commented May 14, 2025

Published Pre-release for 69f7bac with versions:

  - @jobber/components@6.58.3-export-use-69f7bac.3+69f7bac8
  - @jobber/components-native@0.81.1-export-use-69f7bac.11+69f7bac8
  - @jobber/design@0.79.1-export-use-69f7bac.17+69f7bac8
  - @jobber/eslint-config@0.13.3-export-use-69f7bac.816+69f7bac8
  - @jobber/formatters@0.4.1-export-use-69f7bac.274+69f7bac8
  - @jobber/generators@0.12.4-export-use-69f7bac.257+69f7bac8
  - @jobber/hooks@2.15.1-export-use-69f7bac.88+69f7bac8
  - @jobber/stylelint-config@0.7.3-export-use-69f7bac.816+69f7bac8

To install the new version(s) for Web run:

npm install @jobber/components@6.58.3-export-use-69f7bac.3+69f7bac8 @jobber/design@0.79.1-export-use-69f7bac.17+69f7bac8 @jobber/eslint-config@0.13.3-export-use-69f7bac.816+69f7bac8 @jobber/formatters@0.4.1-export-use-69f7bac.274+69f7bac8 @jobber/generators@0.12.4-export-use-69f7bac.257+69f7bac8 @jobber/hooks@2.15.1-export-use-69f7bac.88+69f7bac8 @jobber/stylelint-config@0.7.3-export-use-69f7bac.816+69f7bac8

To install the new version(s) for Mobile run:

npm install @jobber/components-native@0.81.1-export-use-69f7bac.11+69f7bac8 @jobber/design@0.79.1-export-use-69f7bac.17+69f7bac8 @jobber/eslint-config@0.13.3-export-use-69f7bac.816+69f7bac8 @jobber/formatters@0.4.1-export-use-69f7bac.274+69f7bac8 @jobber/generators@0.12.4-export-use-69f7bac.257+69f7bac8 @jobber/hooks@2.15.1-export-use-69f7bac.88+69f7bac8 @jobber/stylelint-config@0.7.3-export-use-69f7bac.816+69f7bac8

@ZakaryH ZakaryH marked this pull request as ready for review May 14, 2025 19:23
@ZakaryH ZakaryH requested a review from a team as a code owner May 14, 2025 19:23
Copy link
Contributor

@nad182 nad182 left a comment

Choose a reason for hiding this comment

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

Hi @ZakaryH . This PR is almost 2 moths old. Is it still relevant? I skimmed through it and it looked good. So after some additional testing, I can approve it. Just wondering if we still need this.

@ZakaryH
Copy link
Contributor Author

ZakaryH commented Jul 4, 2025

@nad182 definitely still relevant, possibly more relevant since I made it even!

we have a useDebounceCallback in JFE but it has a huge deficiency that isn't documented. it's a copy of another library's method, and tbh it's just not made in a way that is practical.

the problem is that the callback you pass to useDebounceCallback(myFunction) must be a stable reference, meaning you cannot do inline functions, functions that are remade on re-render, and even if you do useCallback or useMemo the function - you have to be careful that it doesn't have a dependency that will cause it to be rebuilt at an important time that could interfere with the debouncing.

it's too complicated to use, and none of that is communicated. the existing usages all don't work as intended due to this.

looks like someone else needed a debounce hook for React and they copied that exact same hook, so now there are 2 versions of it, along with copies of a debounce function to power it. furthermore, I'm seeing new usages pop up so the longer we don't have a working solution the more issues we're going to accrue. Some of the flows are unfortunately relying on the broken behavior.

having a single React hook to handle debouncing will remove the need to have duplicated hooks and functions. then, this one actually works the way you'd expect.

the one thing that is worth considering with this IMO is the lodash usage, but I'm pretty sure we require lodash for a bunch of other hooks anyway.

Copy link
Contributor

@nad182 nad182 left a comment

Choose a reason for hiding this comment

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

I've made 2 small suggestions.

But I have another one that is a suggestion/question: can we/should we replace the existing usages of debounce from "lodash" with this hook in the atlantis repo?

image

Also, regarding your comment whether using lodash is fine, I think it is! We're using several utilities provided by lodash in ~40 files. So it's not like we're adding a new dependency.

@ZakaryH
Copy link
Contributor Author

ZakaryH commented Jul 4, 2025

@nad182

But I have another one that is a suggestion/question: can we/should we replace the existing usages of debounce from "lodash" with this hook in the atlantis repo?

we should, but that's a lot more work than I want to do in this PR.

some of those instances work, some of them don't. we need to go through them, and either fix them if they're broken which for at least one of them is non a trivial change, or move them over to use useDebounce.

@scotttjob
Copy link
Contributor

@nad182

But I have another one that is a suggestion/question: can we/should we replace the existing usages of debounce from "lodash" with this hook in the atlantis repo?

we should, but that's a lot more work than I want to do in this PR.

some of those instances work, some of them don't. we need to go through them, and either fix them if they're broken which for at least one of them is non a trivial change, or move them over to use useDebounce.

In general I'm in favour of any effort to reduce our reliance on lodash.

@ZakaryH
Copy link
Contributor Author

ZakaryH commented Jul 14, 2025

the last commit changes how the hook works a bit. we're using useCallback to create the funcRef to avoid reading/writing ref.current during render

Do not write or read ref.current during rendering, except for initialization. This makes your component’s behavior unpredictable.
https://react.dev/reference/react/useRef#caveats

this was a suggestion from Eric, and he has implemented this in a temp version of useDebounceCallback in the frontend repo.

testing it out, it continues to work as expected on the hook doc example, and Autocomplete where we use it for debouncing the search's onchange

wait: number,
options?: Parameters<typeof debounce>[2],
) {
const funcRef = useCallbackRef(func);
Copy link
Contributor

Choose a reason for hiding this comment

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

Great improvement. Eric enlightened me roughly a year ago about not updating refs during rendering and I never got around to fixing this 👍

@ZakaryH
Copy link
Contributor Author

ZakaryH commented Jul 22, 2025

the only outstanding topic is replacing instances of lodash's debounce being used directly. I replaced the easy ones that were truly debouncing previously: Combobox, DataList and InputSearch.

the last two, Chips and InputTime(s) are currently not working and somewhat rely on the broken behavior. a larger refactor is required for these that I'd rather not do right now.

expect(mockFn).toHaveBeenCalledWith("second");
});

it("should not recreate debounced function when options object reference changes", () => {
Copy link
Contributor

Choose a reason for hiding this comment

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

This is very close! I just gave it a test run to see if it would fail as expected, if options were included in the dependencies array. Unfortunately this still passes for me locally. Another test below actually does fail, which is great.

There's 2 easier ways we can verify the debounce function was not recreated.

  1. Compare func references haven't changed
const mockFn = jest.fn();

// Use a function that returns a new options object each time
const { result, rerender } = renderHook(
  ({ options }) => useDebounce(mockFn, DEBOUNCE_WAIT, options),
  { initialProps: { options: { maxWait: 1000 } } },
);

const debounceRef = result.current;

rerender({ options: { maxWait: 1000 } });

// The function's ref should never change
expect(debounceRef).toEqual(result.current);
  1. Use the leading option to determine if the config change took affect
const mockFn = jest.fn();

// Use a function that returns a new options object each time
const { result, rerender } = renderHook(
  ({ options }) => useDebounce(mockFn, DEBOUNCE_WAIT, options),
  { initialProps: { options: { leading: false } } },
);

result.current("first");
act(() => {
  jest.advanceTimersByTime(1);
});
expect(mockFn).not.toHaveBeenCalled();

 // This means it calls immediately at the leading edge of the timeout.
rerender({ options: { leading: true } });
result.current("second");

act(() => {
  jest.advanceTimersByTime(1);
});
// The config change should be ignored, options are hardcoded
expect(mockFn).not.toHaveBeenCalled();

Both of these pass locally when options is not a dep, and fail if it is 👍

Copy link
Contributor Author

Choose a reason for hiding this comment

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

great suggestions. pushed these up!

Copy link
Contributor

@jdeichert jdeichert left a comment

Choose a reason for hiding this comment

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

Everything else looks great, tested the updated components too 👌

Just the one test adjustment to ensure it fails if someone incorrectly adds options as a dependency 🙏

Copy link
Contributor

@jdeichert jdeichert left a comment

Choose a reason for hiding this comment

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

Looks great! Thanks for all the improvements here 🙏

Copy link
Contributor

@jdeichert jdeichert left a comment

Choose a reason for hiding this comment

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

Tested the new es-toolkit debounce is working, looks good!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Development

Successfully merging this pull request may close these issues.

4 participants