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

feat(@yourssu/react): create usePreventDuplicateClick #55

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion apps/docs/src/pages/react/hooks/_meta.en.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
{
"useInterval": "useInterval",
"useMediaQuery": "useMediaQuery",
"useSecTimer": "useSecTimer"
"useSecTimer": "useSecTimer",
"usePreventDuplicateClick": "usePreventDuplicateClick",
"useDebounce": "useDebounce",
"useThrottle": "useThrottle"
}
5 changes: 4 additions & 1 deletion apps/docs/src/pages/react/hooks/_meta.ko.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
{
"useInterval": "useInterval",
"useMediaQuery": "useMediaQuery",
"useSecTimer": "useSecTimer"
"useSecTimer": "useSecTimer",
"usePreventDuplicateClick": "usePreventDuplicateClick",
"useDebounce": "useDebounce",
"useThrottle": "useThrottle"
}
51 changes: 51 additions & 0 deletions apps/docs/src/pages/react/hooks/usePreventDuplicateClick.en.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# usePreventDuplicateClick

The `usePreventDuplicateClick` hook prevents duplicate clicks on a button or other clickable elements by disabling the
click handler while an asynchronous callback function is being executed. This ensures that the callback is not called
multiple times concurrently, which is useful for avoiding issues such as submitting a form multiple times.

## API

```ts
function usePreventDuplicateClick(): { disabled: boolean; handleClick: (callback: () => Promise<void>) => void };
```

## Parameters

None

## Return value

- `disabled` (boolean): A state indicating whether the click handler is currently disabled.
- `handleClick` (function): A function that takes an asynchronous callback function as an argument and ensures it is
only executed once at a time.

```jsx
import React from 'react';
import { usePreventDuplicateClick } from './usePreventDuplicateClick';

const ExampleComponent = () => {
const { disabled, handleClick } = usePreventDuplicateClick();

const handleSubmit = async () => {
// Simulated async operation
await new Promise(resolve => setTimeout(resolve, 1000));
console.log('Form submitted!');
};

return (
<div>
<button onClick={() => handleClick(handleSubmit)} disabled={disabled}>
{disabled ? 'Submitting...' : 'Submit'}
</button>
</div>
);
};
```

## Notes

- This hook internally manages a `loadingRef` to track whether the callback is currently
executing (`loadingRef.current`).
- It sets the `disabled` state of the button to prevent multiple clicks until the callback completes.
- This pattern is commonly used in UI elements such as forms to prevent duplicate user actions.
49 changes: 49 additions & 0 deletions apps/docs/src/pages/react/hooks/usePreventDuplicateClick.ko.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# usePreventDuplicateClick

`usePreventDuplicateClick` 훅은 버튼이나 기타 클릭 가능한 요소에서 중복 클릭을 방지합니다. 이는 비동기 콜백 함수가 실행 중일 때 클릭 핸들러를 비활성화하여, 콜백이 여러 번 동시에 호출되는
것을
방지합니다. 이는 폼을 여러 번 제출하는 등의 문제를 방지하는 데 유용합니다.

## API

```ts
function usePreventDuplicateClick(): { disabled: boolean; handleClick: (callback: () => Promise<void>) => void };
```

## Parameters

None

## Return value

- `disabled` (boolean): 클릭 핸들러가 현재 비활성화된 상태인지를 나타내는 상태 값입니다.
- `handleClick` (function): 비동기 콜백 함수를 인자로 받아 한 번에 한 번씩만 실행되도록 보장하는 함수입니다.

```jsx
import React from 'react';
import { usePreventDuplicateClick } from './usePreventDuplicateClick';

const ExampleComponent = () => {
const { disabled, handleClick } = usePreventDuplicateClick();

const handleSubmit = async () => {
// 비동기 작업 시뮬레이션
await new Promise(resolve => setTimeout(resolve, 1000));
console.log('폼 제출됨!');
};

return (
<div>
<button onClick={() => handleClick(handleSubmit)} disabled={disabled}>
{disabled ? '제출 중...' : '제출'}
</button>
</div>
);
};
```

## Notes

- 이 훅은 내부적으로 `loadingRef`를 관리하여 현재 콜백이 실행 중인지를 추적합니다 (`loadingRef.current`).
- 콜백이 완료될 때까지 여러 번의 클릭을 방지하기 위해 버튼의 `disabled` 상태를 설정합니다.
- 이 패턴은 주로 중복 사용자 작업을 방지해야 하는 폼 등의 UI 요소에서 사용됩니다.
1 change: 1 addition & 0 deletions packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"test": "vitest"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.4.6",
"@testing-library/react": "^15.0.7",
"@yourssu/utils": "workspace:*"
}
Expand Down
64 changes: 64 additions & 0 deletions packages/react/src/hooks/usePreventDuplicateClick.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import '@testing-library/jest-dom';
import { render, waitFor, fireEvent } from '@testing-library/react';
import { usePreventDuplicateClick } from './usePreventDuplicateClick';
import { describe, it, expect, vi } from 'vitest';

const TestComponent = ({ callback }: { callback: () => Promise<void> }) => {
const { disabled, handleClick } = usePreventDuplicateClick();

return (
<button onClick={() => handleClick(callback)} disabled={disabled}>
Click me
</button>
);
};

describe('usePreventDuplicateClick', () => {
it('should disable click during callback execution and enable after', async () => {
const callback = vi.fn().mockImplementation(() => {
return new Promise<void>((resolve) => setTimeout(resolve, 100));
});

const { getByText } = render(<TestComponent callback={callback} />);
const button = getByText('Click me');

// Initial state
expect(button).not.toBeDisabled();

// Act: simulate click
fireEvent.click(button);

// Check if disabled during callback
expect(button).toBeDisabled();

// Wait for callback to finish
await waitFor(() => expect(button).not.toBeDisabled());

// Check that the callback was called
expect(callback).toHaveBeenCalledTimes(1);
});

it('should not allow multiple clicks', async () => {
const callback = vi.fn().mockImplementation(() => {
return new Promise<void>((resolve) => setTimeout(resolve, 100));
});

const { getByText } = render(<TestComponent callback={callback} />);
const button = getByText('Click me');

// Act: simulate first click
fireEvent.click(button);

// Act: simulate second click before the first one completes
fireEvent.click(button);

// Check if disabled during callback
expect(button).toBeDisabled();

// Wait for callback to finish
await waitFor(() => expect(button).not.toBeDisabled());

// Check that the callback was called only once
expect(callback).toHaveBeenCalledTimes(1);
});
});
20 changes: 20 additions & 0 deletions packages/react/src/hooks/usePreventDuplicateClick.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { useCallback, useRef, useState } from 'react';

export const usePreventDuplicateClick = () => {
const [disabled, setDisabled] = useState(false);
const loadingRef = useRef(false);

const handleClick = useCallback(async (callback: () => Promise<void>) => {
if (loadingRef.current) return;

loadingRef.current = true;
Comment on lines +8 to +10
Copy link

@fecapark fecapark Oct 27, 2024

Choose a reason for hiding this comment

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

p3) 굳이 ref를 사용할 필요가 없어보여요.
본래 목적이 이벤트 핸들러로 국한된다면 내부에서 상태와 세터를 같이 사용해도 문제 없을거예요.

setDisabled(true);

await callback();

loadingRef.current = false;
setDisabled(false);
}, []);

return { disabled, handleClick };
};
Comment on lines +3 to +20

Choose a reason for hiding this comment

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

p4) 유틸리티의 관점에서는 usePreventDuplicateClick 보다는 usePromise 가 더 낫지 않나 싶네요.

Suggested change
export const usePreventDuplicateClick = () => {
const [disabled, setDisabled] = useState(false);
const loadingRef = useRef(false);
const handleClick = useCallback(async (callback: () => Promise<void>) => {
if (loadingRef.current) return;
loadingRef.current = true;
setDisabled(true);
await callback();
loadingRef.current = false;
setDisabled(false);
}, []);
return { disabled, handleClick };
};
export const usePromise = () => {
const [isPending, setIsPending] = useState(false);
const callPromise = useCallback(async (callback: () => Promise<void>) => {
setIsPending(true);
await callback();
setIsPending(false);
}, []);
return { isPending, callPromise };
};

Loading
Loading