From 453ee876b7028aefc00d30a94c5d5ab9dd2e1887 Mon Sep 17 00:00:00 2001 From: Wonsuk Choi Date: Mon, 9 Mar 2026 17:00:21 +0900 Subject: [PATCH 1/4] feat(core/hooks): add 'useThrottledCallback' hook --- .../src/hooks/useThrottledCallback/index.ts | 1 + .../ko/useThrottledCallback.md | 65 +++++++++++++ .../useThrottledCallback.md | 65 +++++++++++++ .../useThrottledCallback.spec.ts | 96 +++++++++++++++++++ .../useThrottledCallback.ts | 77 +++++++++++++++ packages/core/src/index.ts | 1 + 6 files changed, 305 insertions(+) create mode 100644 packages/core/src/hooks/useThrottledCallback/index.ts create mode 100644 packages/core/src/hooks/useThrottledCallback/ko/useThrottledCallback.md create mode 100644 packages/core/src/hooks/useThrottledCallback/useThrottledCallback.md create mode 100644 packages/core/src/hooks/useThrottledCallback/useThrottledCallback.spec.ts create mode 100644 packages/core/src/hooks/useThrottledCallback/useThrottledCallback.ts diff --git a/packages/core/src/hooks/useThrottledCallback/index.ts b/packages/core/src/hooks/useThrottledCallback/index.ts new file mode 100644 index 00000000..25299d09 --- /dev/null +++ b/packages/core/src/hooks/useThrottledCallback/index.ts @@ -0,0 +1 @@ +export { useThrottledCallback } from './useThrottledCallback.ts'; diff --git a/packages/core/src/hooks/useThrottledCallback/ko/useThrottledCallback.md b/packages/core/src/hooks/useThrottledCallback/ko/useThrottledCallback.md new file mode 100644 index 00000000..740f9050 --- /dev/null +++ b/packages/core/src/hooks/useThrottledCallback/ko/useThrottledCallback.md @@ -0,0 +1,65 @@ +# useThrottledCallback + +제공된 콜백 함수의 스로틀링된 버전을 반환하는 React 훅이에요. 스로틀링된 콜백은 지정된 간격당 최대 한 번만 호출돼요. + +## Interface + +```ts +function useThrottledCallback any>( + callback: F, + wait: number, + options?: { edges?: Array<'leading' | 'trailing'> } +): F & { cancel: () => void }; +``` + +### 파라미터 + + + + + + + +### 반환 값 + + + +## 예시 + +```tsx +function SearchInput() { + const throttledSearch = useThrottledCallback((query: string) => { + console.log('검색어:', query); + }, 300); + + return throttledSearch(e.target.value)} />; +} +``` diff --git a/packages/core/src/hooks/useThrottledCallback/useThrottledCallback.md b/packages/core/src/hooks/useThrottledCallback/useThrottledCallback.md new file mode 100644 index 00000000..e9aa0c65 --- /dev/null +++ b/packages/core/src/hooks/useThrottledCallback/useThrottledCallback.md @@ -0,0 +1,65 @@ +# useThrottledCallback + +`useThrottledCallback` is a React hook that returns a throttled version of the provided callback function. The throttled callback will only be invoked at most once per specified interval. + +## Interface + +```ts +function useThrottledCallback any>( + callback: F, + wait: number, + options?: { edges?: Array<'leading' | 'trailing'> } +): F & { cancel: () => void }; +``` + +### Parameters + + + + + + + +### Return Value + + + +## Example + +```tsx +function SearchInput() { + const throttledSearch = useThrottledCallback((query: string) => { + console.log('Searching for:', query); + }, 300); + + return throttledSearch(e.target.value)} />; +} +``` diff --git a/packages/core/src/hooks/useThrottledCallback/useThrottledCallback.spec.ts b/packages/core/src/hooks/useThrottledCallback/useThrottledCallback.spec.ts new file mode 100644 index 00000000..56c0900c --- /dev/null +++ b/packages/core/src/hooks/useThrottledCallback/useThrottledCallback.spec.ts @@ -0,0 +1,96 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { renderHookSSR } from '../../_internal/test-utils/renderHookSSR.tsx'; + +import { useThrottledCallback } from './useThrottledCallback.ts'; + +describe('useThrottledCallback', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + it('is safe on server side rendering', () => { + const onChange = vi.fn(); + renderHookSSR.serverOnly(() => useThrottledCallback({ onChange, timeThreshold: 100 })); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it('should throttle the callback with the specified time threshold', () => { + const onChange = vi.fn(); + const { result } = renderHookSSR(() => useThrottledCallback({ onChange, timeThreshold: 100 })); + + result.current(true); + expect(onChange).toBeCalledTimes(1); + expect(onChange).toBeCalledWith(true); + + result.current(true); + vi.advanceTimersByTime(50); + expect(onChange).toBeCalledTimes(1); + + vi.advanceTimersByTime(50); + expect(onChange).toBeCalledTimes(1); + }); + + it('should call on leading edge by default', () => { + const onChange = vi.fn(); + const { result } = renderHookSSR(() => useThrottledCallback({ onChange, timeThreshold: 100 })); + + result.current(true); + expect(onChange).toBeCalledTimes(1); + expect(onChange).toBeCalledWith(true); + }); + + it('should handle trailing edge', () => { + const onChange = vi.fn(); + const { result } = renderHookSSR(() => useThrottledCallback({ onChange, timeThreshold: 100, edges: ['trailing'] })); + + result.current(true); + expect(onChange).not.toBeCalled(); + + vi.advanceTimersByTime(100); + expect(onChange).toBeCalledTimes(1); + expect(onChange).toBeCalledWith(true); + }); + + it('should not trigger callback if value has not changed', () => { + const onChange = vi.fn(); + const { result } = renderHookSSR(() => useThrottledCallback({ onChange, timeThreshold: 100 })); + + result.current(true); + vi.advanceTimersByTime(100); + expect(onChange).toBeCalledTimes(1); + + result.current(true); + vi.advanceTimersByTime(100); + expect(onChange).toBeCalledTimes(1); + }); + + it('should cleanup on unmount', async () => { + const onChange = vi.fn(); + const { result, unmount } = await renderHookSSR(() => + useThrottledCallback({ onChange, timeThreshold: 100, edges: ['trailing'] }) + ); + + result.current(true); + unmount(); + vi.advanceTimersByTime(100); + + expect(onChange).not.toBeCalled(); + }); + + it('should handle value toggling', () => { + const onChange = vi.fn(); + const { result } = renderHookSSR(() => useThrottledCallback({ onChange, timeThreshold: 100 })); + + result.current(true); + expect(onChange).toBeCalledTimes(1); + expect(onChange).toBeCalledWith(true); + + vi.advanceTimersByTime(100); + + result.current(false); + expect(onChange).toBeCalledTimes(2); + expect(onChange).toBeCalledWith(false); + }); +}); diff --git a/packages/core/src/hooks/useThrottledCallback/useThrottledCallback.ts b/packages/core/src/hooks/useThrottledCallback/useThrottledCallback.ts new file mode 100644 index 00000000..e5981086 --- /dev/null +++ b/packages/core/src/hooks/useThrottledCallback/useThrottledCallback.ts @@ -0,0 +1,77 @@ +import { useCallback, useEffect, useMemo, useRef } from 'react'; + +import { usePreservedCallback } from '../usePreservedCallback/index.ts'; +import { throttle } from '../useThrottle/throttle.ts'; + +type ThrottleOptions = { + edges?: Array<'leading' | 'trailing'>; +}; + +/** + * @description + * `useThrottledCallback` is a React hook that returns a throttled version of the provided callback function. + * The throttled callback will only be invoked at most once per specified interval. + * + * @param {Object} options - The options object. + * @param {Function} options.onChange - The callback function to throttle. + * @param {number} options.timeThreshold - The number of milliseconds to throttle invocations to. + * @param {Array<'leading' | 'trailing'>} [options.edges=['leading', 'trailing']] - An optional array specifying whether the function should be invoked on the leading edge, trailing edge, or both. + * + * @returns {Function} A throttled function that limits invoking the callback. + * + * @example + * function ScrollTracker() { + * const throttledScroll = useThrottledCallback({ + * onChange: (scrollY: number) => console.log(scrollY), + * timeThreshold: 200, + * }); + * return
throttledScroll(e.currentTarget.scrollTop)} />; + * } + */ +export function useThrottledCallback({ + onChange, + timeThreshold, + edges = ['leading', 'trailing'], +}: ThrottleOptions & { + onChange: (newValue: boolean) => void; + timeThreshold: number; +}) { + const handleChange = usePreservedCallback(onChange); + const ref = useRef({ value: false, clearPreviousThrottle: () => {} }); + + useEffect(() => { + const current = ref.current; + return () => { + current.clearPreviousThrottle(); + }; + }, []); + + const preservedEdges = useMemo(() => { + return edges; + }, [edges]); + + return useCallback( + (nextValue: boolean) => { + if (nextValue === ref.current.value) { + return; + } + + const throttled = throttle( + () => { + handleChange(nextValue); + + ref.current.value = nextValue; + }, + timeThreshold, + { edges: preservedEdges } + ); + + ref.current.clearPreviousThrottle(); + + throttled(); + + ref.current.clearPreviousThrottle = throttled.cancel; + }, + [handleChange, timeThreshold, preservedEdges] + ); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 975dd899..7dfc961b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -27,6 +27,7 @@ export { usePrevious } from './hooks/usePrevious/index.ts'; export { useRefEffect } from './hooks/useRefEffect/index.ts'; export { useStorageState } from './hooks/useStorageState/index.ts'; export { useThrottle } from './hooks/useThrottle/index.ts'; +export { useThrottledCallback } from './hooks/useThrottledCallback/index.ts'; export { useTimeout } from './hooks/useTimeout/index.ts'; export { useToggle } from './hooks/useToggle/index.ts'; export { useVisibilityEvent } from './hooks/useVisibilityEvent/index.ts'; From e6e4bc4cb6ae9bba96913bb7780cc1663ea5bef6 Mon Sep 17 00:00:00 2001 From: Wonsuk Choi Date: Mon, 9 Mar 2026 17:01:12 +0900 Subject: [PATCH 2/4] chore(changeset): add changeset for 'useThrottledCallback' hook --- .changeset/tall-lions-rush.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/tall-lions-rush.md diff --git a/.changeset/tall-lions-rush.md b/.changeset/tall-lions-rush.md new file mode 100644 index 00000000..fc1a58ff --- /dev/null +++ b/.changeset/tall-lions-rush.md @@ -0,0 +1,5 @@ +--- +'react-simplikit': patch +--- + +feat(core/hooks): add 'useThrottledCallback' hook From 4a042c435da934f72e1106692b85f04743320501 Mon Sep 17 00:00:00 2001 From: Wonsuk Choi Date: Tue, 10 Mar 2026 09:39:48 +0900 Subject: [PATCH 3/4] test(useThrottledCallback): add 'vi.useRealTimers' in 'afterEach' --- .../hooks/useThrottledCallback/useThrottledCallback.spec.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/core/src/hooks/useThrottledCallback/useThrottledCallback.spec.ts b/packages/core/src/hooks/useThrottledCallback/useThrottledCallback.spec.ts index 56c0900c..fc532bea 100644 --- a/packages/core/src/hooks/useThrottledCallback/useThrottledCallback.spec.ts +++ b/packages/core/src/hooks/useThrottledCallback/useThrottledCallback.spec.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { renderHookSSR } from '../../_internal/test-utils/renderHookSSR.tsx'; @@ -9,6 +9,10 @@ describe('useThrottledCallback', () => { vi.useFakeTimers(); }); + afterEach(() => { + vi.useRealTimers(); + }); + it('is safe on server side rendering', () => { const onChange = vi.fn(); renderHookSSR.serverOnly(() => useThrottledCallback({ onChange, timeThreshold: 100 })); From cec3206b65d31ac6fd6fdfdcc04983ec61574939 Mon Sep 17 00:00:00 2001 From: Wonsuk Choi Date: Tue, 10 Mar 2026 13:25:18 +0900 Subject: [PATCH 4/4] refactor(useThrottledCallback): use named function in 'useEffect', replace 'useMemo' with 'usePreservedReference', and add leading+trailing test --- .../useThrottledCallback.spec.ts | 14 ++++++++++++++ .../useThrottledCallback/useThrottledCallback.ts | 9 ++++----- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/packages/core/src/hooks/useThrottledCallback/useThrottledCallback.spec.ts b/packages/core/src/hooks/useThrottledCallback/useThrottledCallback.spec.ts index fc532bea..010d8f29 100644 --- a/packages/core/src/hooks/useThrottledCallback/useThrottledCallback.spec.ts +++ b/packages/core/src/hooks/useThrottledCallback/useThrottledCallback.spec.ts @@ -83,6 +83,20 @@ describe('useThrottledCallback', () => { expect(onChange).not.toBeCalled(); }); + it('should handle leading and trailing edges together', () => { + const onChange = vi.fn(); + const { result } = renderHookSSR(() => + useThrottledCallback({ onChange, timeThreshold: 100, edges: ['leading', 'trailing'] }) + ); + + result.current(true); + expect(onChange).toBeCalledTimes(1); + expect(onChange).toBeCalledWith(true); + + vi.advanceTimersByTime(100); + expect(onChange).toBeCalledTimes(1); + }); + it('should handle value toggling', () => { const onChange = vi.fn(); const { result } = renderHookSSR(() => useThrottledCallback({ onChange, timeThreshold: 100 })); diff --git a/packages/core/src/hooks/useThrottledCallback/useThrottledCallback.ts b/packages/core/src/hooks/useThrottledCallback/useThrottledCallback.ts index e5981086..c24293b8 100644 --- a/packages/core/src/hooks/useThrottledCallback/useThrottledCallback.ts +++ b/packages/core/src/hooks/useThrottledCallback/useThrottledCallback.ts @@ -1,6 +1,7 @@ -import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import { usePreservedCallback } from '../usePreservedCallback/index.ts'; +import { usePreservedReference } from '../usePreservedReference/index.ts'; import { throttle } from '../useThrottle/throttle.ts'; type ThrottleOptions = { @@ -39,16 +40,14 @@ export function useThrottledCallback({ const handleChange = usePreservedCallback(onChange); const ref = useRef({ value: false, clearPreviousThrottle: () => {} }); - useEffect(() => { + useEffect(function cleanupThrottleOnUnmount() { const current = ref.current; return () => { current.clearPreviousThrottle(); }; }, []); - const preservedEdges = useMemo(() => { - return edges; - }, [edges]); + const preservedEdges = usePreservedReference(edges); return useCallback( (nextValue: boolean) => {