Skip to content

Commit ed78f03

Browse files
committed
feat(core): add useAsyncLock hook
1 parent da1f51c commit ed78f03

7 files changed

Lines changed: 261 additions & 0 deletions

File tree

.changeset/bright-locks-run.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'react-simplikit': minor
3+
---
4+
5+
feat(core): add useAsyncLock hook
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { useAsyncLock } from './useAsyncLock.ts';
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# useAsyncLock
2+
3+
`useAsyncLock`은 비동기 작업이 겹쳐서 실행되지 않도록 막는 리액트 훅이에요. 하나의 콜백이 실행 중일 때 추가 호출이 들어오면 콜백을 실행하지 않고 blocked 결과를 반환해요.
4+
5+
## 인터페이스
6+
7+
```ts
8+
function useAsyncLock(): {
9+
runWithLock: <T>(
10+
callback: () => Promise<T> | T
11+
) => Promise<{ status: 'executed'; data: T } | { status: 'blocked' }>;
12+
isLocked: () => boolean;
13+
};
14+
```
15+
16+
### 파라미터
17+
18+
### 반환 값
19+
20+
<Interface
21+
name=""
22+
type="{ runWithLock: <T>(callback: () => Promise<T> | T) => Promise<AsyncLockResult<T>>, isLocked: () => boolean }"
23+
description="락을 사용해 작업을 실행하기 위한 헬퍼 객체예요."
24+
:nested="[
25+
{
26+
name: 'runWithLock',
27+
type: '<T>(callback: () => Promise<T> | T) => Promise<AsyncLockResult<T>>',
28+
description:
29+
'락을 사용할 수 있을 때 콜백을 실행해요. 이미 다른 콜백이 실행 중이면 콜백을 호출하지 않고 <code>{ status: \"blocked\" }</code>를 반환해요.',
30+
},
31+
{
32+
name: 'isLocked',
33+
type: '() => boolean',
34+
description: '현재 락이 잡혀 있는지 반환해요.',
35+
},
36+
]"
37+
/>
38+
39+
## 예시
40+
41+
```tsx
42+
function SubmitButton() {
43+
const { runWithLock } = useAsyncLock();
44+
45+
const handleClick = async () => {
46+
const result = await runWithLock(async () => {
47+
return submitForm();
48+
});
49+
50+
if (result.status === 'blocked') {
51+
return;
52+
}
53+
54+
console.log(result.data);
55+
};
56+
57+
return <button onClick={handleClick}>제출</button>;
58+
}
59+
```
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# useAsyncLock
2+
3+
`useAsyncLock` is a React hook that prevents overlapping execution of asynchronous work. While one callback is running, additional calls are skipped and return a blocked result.
4+
5+
## Interface
6+
7+
```ts
8+
function useAsyncLock(): {
9+
runWithLock: <T>(
10+
callback: () => Promise<T> | T
11+
) => Promise<{ status: 'executed'; data: T } | { status: 'blocked' }>;
12+
isLocked: () => boolean;
13+
};
14+
```
15+
16+
### Parameters
17+
18+
### Return Value
19+
20+
<Interface
21+
name=""
22+
type="{ runWithLock: <T>(callback: () => Promise<T> | T) => Promise<AsyncLockResult<T>>, isLocked: () => boolean }"
23+
description="An object containing helpers to run work with a lock."
24+
:nested="[
25+
{
26+
name: 'runWithLock',
27+
type: '<T>(callback: () => Promise<T> | T) => Promise<AsyncLockResult<T>>',
28+
description:
29+
'Runs the callback when the lock is available. If another callback is already running, it returns <code>{ status: \"blocked\" }</code> without calling the callback.',
30+
},
31+
{
32+
name: 'isLocked',
33+
type: '() => boolean',
34+
description: 'Returns whether the lock is currently held.',
35+
},
36+
]"
37+
/>
38+
39+
## Example
40+
41+
```tsx
42+
function SubmitButton() {
43+
const { runWithLock } = useAsyncLock();
44+
45+
const handleClick = async () => {
46+
const result = await runWithLock(async () => {
47+
return submitForm();
48+
});
49+
50+
if (result.status === 'blocked') {
51+
return;
52+
}
53+
54+
console.log(result.data);
55+
};
56+
57+
return <button onClick={handleClick}>Submit</button>;
58+
}
59+
```
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
3+
import { renderHookSSR } from '../../_internal/test-utils/renderHookSSR.tsx';
4+
5+
import { useAsyncLock } from './useAsyncLock.ts';
6+
7+
describe('useAsyncLock', () => {
8+
it('is safe on server side rendering', () => {
9+
const result = renderHookSSR.serverOnly(() => useAsyncLock());
10+
11+
expect(result.current.isLocked()).toBe(false);
12+
});
13+
14+
it('should execute a callback when the lock is available', async () => {
15+
const callback = vi.fn(() => 'done');
16+
const { result } = await renderHookSSR(() => useAsyncLock());
17+
18+
await expect(result.current.runWithLock(callback)).resolves.toEqual({
19+
status: 'executed',
20+
data: 'done',
21+
});
22+
expect(callback).toHaveBeenCalledTimes(1);
23+
});
24+
25+
it('should block overlapping executions', async () => {
26+
let resolvePending: (value: string) => void = () => {};
27+
const pending = new Promise<string>(resolve => {
28+
resolvePending = resolve;
29+
});
30+
const firstCallback = vi.fn(() => pending);
31+
const secondCallback = vi.fn(() => 'blocked');
32+
const { result } = renderHookSSR(() => useAsyncLock());
33+
34+
const firstResult = result.current.runWithLock(firstCallback);
35+
36+
expect(result.current.isLocked()).toBe(true);
37+
await expect(result.current.runWithLock(secondCallback)).resolves.toEqual({
38+
status: 'blocked',
39+
});
40+
expect(secondCallback).not.toHaveBeenCalled();
41+
42+
resolvePending('done');
43+
await expect(firstResult).resolves.toEqual({
44+
status: 'executed',
45+
data: 'done',
46+
});
47+
expect(result.current.isLocked()).toBe(false);
48+
});
49+
50+
it('should release the lock after a callback rejects', async () => {
51+
const error = new Error('failed');
52+
const rejectedCallback = vi.fn(async () => {
53+
throw error;
54+
});
55+
const nextCallback = vi.fn(() => 'next');
56+
const { result } = renderHookSSR(() => useAsyncLock());
57+
58+
await expect(result.current.runWithLock(rejectedCallback)).rejects.toThrow(error);
59+
expect(result.current.isLocked()).toBe(false);
60+
61+
await expect(result.current.runWithLock(nextCallback)).resolves.toEqual({
62+
status: 'executed',
63+
data: 'next',
64+
});
65+
});
66+
67+
it('should keep returned functions stable across rerenders', async () => {
68+
const { result, rerender } = renderHookSSR(() => useAsyncLock());
69+
const initialValue = result.current;
70+
71+
rerender();
72+
73+
expect(result.current).toBe(initialValue);
74+
expect(result.current.runWithLock).toBe(initialValue.runWithLock);
75+
expect(result.current.isLocked).toBe(initialValue.isLocked);
76+
});
77+
});
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { useCallback, useMemo, useRef } from 'react';
2+
3+
type AsyncLockResult<T> = { status: 'executed'; data: T } | { status: 'blocked' };
4+
5+
type UseAsyncLockReturn = {
6+
runWithLock: <T>(callback: () => Promise<T> | T) => Promise<AsyncLockResult<T>>;
7+
isLocked: () => boolean;
8+
};
9+
10+
/**
11+
* @description
12+
* `useAsyncLock` is a React hook that prevents overlapping execution of asynchronous work.
13+
* While one callback is running, additional calls are skipped and return a blocked result.
14+
*
15+
* @returns {UseAsyncLockReturn} An object containing:
16+
* - runWithLock `<T>(callback: () => Promise<T> | T) => Promise<AsyncLockResult<T>>` - Runs a callback only when the lock is available.
17+
* - isLocked `() => boolean` - Returns whether the lock is currently held.
18+
*
19+
* @example
20+
* const { runWithLock } = useAsyncLock();
21+
*
22+
* async function handleSubmit() {
23+
* const result = await runWithLock(async () => {
24+
* return submitForm();
25+
* });
26+
*
27+
* if (result.status === 'blocked') {
28+
* return;
29+
* }
30+
*
31+
* console.log(result.data);
32+
* }
33+
*/
34+
export function useAsyncLock(): UseAsyncLockReturn {
35+
const isLockedRef = useRef(false);
36+
37+
const runWithLock = useCallback(async function runWithLock<T>(
38+
callback: () => Promise<T> | T
39+
): Promise<AsyncLockResult<T>> {
40+
if (isLockedRef.current) {
41+
return { status: 'blocked' };
42+
}
43+
44+
isLockedRef.current = true;
45+
46+
try {
47+
const data = await callback();
48+
return { status: 'executed', data };
49+
} finally {
50+
isLockedRef.current = false;
51+
}
52+
}, []);
53+
54+
const isLocked = useCallback(function isLocked() {
55+
return isLockedRef.current;
56+
}, []);
57+
58+
return useMemo(() => ({ runWithLock, isLocked }), [runWithLock, isLocked]);
59+
}

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ export { ImpressionArea } from './components/ImpressionArea/index.ts';
22
export { Separated } from './components/Separated/index.ts';
33
export { SwitchCase } from './components/SwitchCase/index.ts';
44
export { useAsyncEffect } from './hooks/useAsyncEffect/index.ts';
5+
export { useAsyncLock } from './hooks/useAsyncLock/index.ts';
56
export { useBooleanState } from './hooks/useBooleanState/index.ts';
67
export { useCallbackOncePerRender } from './hooks/useCallbackOncePerRender/index.ts';
78
export { useConditionalEffect } from './hooks/useConditionalEffect/index.ts';

0 commit comments

Comments
 (0)