Skip to content
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: 5 additions & 0 deletions .changeset/bright-locks-run.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'react-simplikit': minor
---

feat(core): add useAsyncLock hook
1 change: 1 addition & 0 deletions packages/core/src/hooks/useAsyncLock/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { useAsyncLock } from './useAsyncLock.ts';
59 changes: 59 additions & 0 deletions packages/core/src/hooks/useAsyncLock/ko/useAsyncLock.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# useAsyncLock

`useAsyncLock`은 비동기 작업이 겹쳐서 실행되지 않도록 막는 리액트 훅이에요. 하나의 콜백이 실행 중일 때 추가 호출이 들어오면 콜백을 실행하지 않고 blocked 결과를 반환해요.

## 인터페이스

```ts
function useAsyncLock(): {
runWithLock: <T>(
callback: () => Promise<T> | T
) => Promise<{ status: 'executed'; data: T } | { status: 'blocked' }>;
isLocked: () => boolean;
};
```

### 파라미터

### 반환 값

<Interface
name=""
type="{ runWithLock: <T>(callback: () => Promise<T> | T) => Promise<AsyncLockResult<T>>, isLocked: () => boolean }"
description="락을 사용해 작업을 실행하기 위한 헬퍼 객체예요."
:nested="[
{
name: 'runWithLock',
type: '<T>(callback: () => Promise<T> | T) => Promise<AsyncLockResult<T>>',
description:
'락을 사용할 수 있을 때 콜백을 실행해요. 이미 다른 콜백이 실행 중이면 콜백을 호출하지 않고 <code>{ status: \"blocked\" }</code>를 반환해요.',
},
{
name: 'isLocked',
type: '() => boolean',
description: '현재 락이 잡혀 있는지 반환해요.',
},
]"
/>

## 예시

```tsx
function SubmitButton() {
const { runWithLock } = useAsyncLock();

const handleClick = async () => {
const result = await runWithLock(async () => {
return submitForm();
});

if (result.status === 'blocked') {
return;
}

console.log(result.data);
};

return <button onClick={handleClick}>제출</button>;
}
```
59 changes: 59 additions & 0 deletions packages/core/src/hooks/useAsyncLock/useAsyncLock.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# useAsyncLock

`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.

## Interface

```ts
function useAsyncLock(): {
runWithLock: <T>(
callback: () => Promise<T> | T
) => Promise<{ status: 'executed'; data: T } | { status: 'blocked' }>;
isLocked: () => boolean;
};
```

### Parameters

### Return Value

<Interface
name=""
type="{ runWithLock: <T>(callback: () => Promise<T> | T) => Promise<AsyncLockResult<T>>, isLocked: () => boolean }"
description="An object containing helpers to run work with a lock."
:nested="[
{
name: 'runWithLock',
type: '<T>(callback: () => Promise<T> | T) => Promise<AsyncLockResult<T>>',
description:
'Runs the callback when the lock is available. If another callback is already running, it returns <code>{ status: \"blocked\" }</code> without calling the callback.',
},
{
name: 'isLocked',
type: '() => boolean',
description: 'Returns whether the lock is currently held.',
},
]"
/>

## Example

```tsx
function SubmitButton() {
const { runWithLock } = useAsyncLock();

const handleClick = async () => {
const result = await runWithLock(async () => {
return submitForm();
});

if (result.status === 'blocked') {
return;
}

console.log(result.data);
};

return <button onClick={handleClick}>Submit</button>;
}
```
77 changes: 77 additions & 0 deletions packages/core/src/hooks/useAsyncLock/useAsyncLock.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { describe, expect, it, vi } from 'vitest';

import { renderHookSSR } from '../../_internal/test-utils/renderHookSSR.tsx';

import { useAsyncLock } from './useAsyncLock.ts';

describe('useAsyncLock', () => {
it('is safe on server side rendering', () => {
const result = renderHookSSR.serverOnly(() => useAsyncLock());

expect(result.current.isLocked()).toBe(false);
});

it('should execute a callback when the lock is available', async () => {
const callback = vi.fn(() => 'done');
const { result } = await renderHookSSR(() => useAsyncLock());

await expect(result.current.runWithLock(callback)).resolves.toEqual({
status: 'executed',
data: 'done',
});
expect(callback).toHaveBeenCalledTimes(1);
});

it('should block overlapping executions', async () => {
let resolvePending: (value: string) => void = () => {};
const pending = new Promise<string>(resolve => {
resolvePending = resolve;
});
const firstCallback = vi.fn(() => pending);
const secondCallback = vi.fn(() => 'blocked');
const { result } = renderHookSSR(() => useAsyncLock());

const firstResult = result.current.runWithLock(firstCallback);

expect(result.current.isLocked()).toBe(true);
await expect(result.current.runWithLock(secondCallback)).resolves.toEqual({
status: 'blocked',
});
expect(secondCallback).not.toHaveBeenCalled();

resolvePending('done');
await expect(firstResult).resolves.toEqual({
status: 'executed',
data: 'done',
});
expect(result.current.isLocked()).toBe(false);
});

it('should release the lock after a callback rejects', async () => {
const error = new Error('failed');
const rejectedCallback = vi.fn(async () => {
throw error;
});
const nextCallback = vi.fn(() => 'next');
const { result } = renderHookSSR(() => useAsyncLock());

await expect(result.current.runWithLock(rejectedCallback)).rejects.toThrow(error);
expect(result.current.isLocked()).toBe(false);

await expect(result.current.runWithLock(nextCallback)).resolves.toEqual({
status: 'executed',
data: 'next',
});
});

it('should keep returned functions stable across rerenders', async () => {
const { result, rerender } = renderHookSSR(() => useAsyncLock());
const initialValue = result.current;

rerender();

expect(result.current).toBe(initialValue);
expect(result.current.runWithLock).toBe(initialValue.runWithLock);
expect(result.current.isLocked).toBe(initialValue.isLocked);
});
});
59 changes: 59 additions & 0 deletions packages/core/src/hooks/useAsyncLock/useAsyncLock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { useCallback, useMemo, useRef } from 'react';

type AsyncLockResult<T> = { status: 'executed'; data: T } | { status: 'blocked' };

type UseAsyncLockReturn = {
runWithLock: <T>(callback: () => Promise<T> | T) => Promise<AsyncLockResult<T>>;
isLocked: () => boolean;
};

/**
* @description
* `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.
*
* @returns {UseAsyncLockReturn} An object containing:
* - runWithLock `<T>(callback: () => Promise<T> | T) => Promise<AsyncLockResult<T>>` - Runs a callback only when the lock is available.
* - isLocked `() => boolean` - Returns whether the lock is currently held.
*
* @example
* const { runWithLock } = useAsyncLock();
*
* async function handleSubmit() {
* const result = await runWithLock(async () => {
* return submitForm();
* });
*
* if (result.status === 'blocked') {
* return;
* }
*
* console.log(result.data);
* }
*/
export function useAsyncLock(): UseAsyncLockReturn {
const isLockedRef = useRef(false);

const runWithLock = useCallback(async function runWithLock<T>(
callback: () => Promise<T> | T
): Promise<AsyncLockResult<T>> {
if (isLockedRef.current) {
return { status: 'blocked' };
}

isLockedRef.current = true;

try {
const data = await callback();
return { status: 'executed', data };
} finally {
isLockedRef.current = false;
}
}, []);

const isLocked = useCallback(function isLocked() {
return isLockedRef.current;
}, []);

return useMemo(() => ({ runWithLock, isLocked }), [runWithLock, isLocked]);
}
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export { ImpressionArea } from './components/ImpressionArea/index.ts';
export { Separated } from './components/Separated/index.ts';
export { SwitchCase } from './components/SwitchCase/index.ts';
export { useAsyncEffect } from './hooks/useAsyncEffect/index.ts';
export { useAsyncLock } from './hooks/useAsyncLock/index.ts';
export { useBooleanState } from './hooks/useBooleanState/index.ts';
export { useCallbackOncePerRender } from './hooks/useCallbackOncePerRender/index.ts';
export { useConditionalEffect } from './hooks/useConditionalEffect/index.ts';
Expand Down
Loading