From cf37eb82b76d544e995ed5f5f3e2df31c0916f16 Mon Sep 17 00:00:00 2001 From: kimyouknow Date: Tue, 17 Mar 2026 10:19:36 +0900 Subject: [PATCH 1/4] Revert "feat(core/hooks): add 'useThrottledCallback' hook (#342)" This reverts commit b94ff72c0ac252b4cb88ff56dda5aba7735a5ab8, reversing changes made to b40b0ce183ea3bde4609b1c1fccac64a9a96e14d. --- .changeset/tall-lions-rush.md | 5 - .../src/hooks/useThrottledCallback/index.ts | 1 - .../ko/useThrottledCallback.md | 65 ---------- .../useThrottledCallback.md | 65 ---------- .../useThrottledCallback.spec.ts | 114 ------------------ .../useThrottledCallback.ts | 76 ------------ packages/core/src/index.ts | 1 - 7 files changed, 327 deletions(-) delete mode 100644 .changeset/tall-lions-rush.md delete mode 100644 packages/core/src/hooks/useThrottledCallback/index.ts delete mode 100644 packages/core/src/hooks/useThrottledCallback/ko/useThrottledCallback.md delete mode 100644 packages/core/src/hooks/useThrottledCallback/useThrottledCallback.md delete mode 100644 packages/core/src/hooks/useThrottledCallback/useThrottledCallback.spec.ts delete mode 100644 packages/core/src/hooks/useThrottledCallback/useThrottledCallback.ts diff --git a/.changeset/tall-lions-rush.md b/.changeset/tall-lions-rush.md deleted file mode 100644 index fc1a58ff..00000000 --- a/.changeset/tall-lions-rush.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'react-simplikit': patch ---- - -feat(core/hooks): add 'useThrottledCallback' hook diff --git a/packages/core/src/hooks/useThrottledCallback/index.ts b/packages/core/src/hooks/useThrottledCallback/index.ts deleted file mode 100644 index 25299d09..00000000 --- a/packages/core/src/hooks/useThrottledCallback/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 740f9050..00000000 --- a/packages/core/src/hooks/useThrottledCallback/ko/useThrottledCallback.md +++ /dev/null @@ -1,65 +0,0 @@ -# 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 deleted file mode 100644 index e9aa0c65..00000000 --- a/packages/core/src/hooks/useThrottledCallback/useThrottledCallback.md +++ /dev/null @@ -1,65 +0,0 @@ -# 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 deleted file mode 100644 index 010d8f29..00000000 --- a/packages/core/src/hooks/useThrottledCallback/useThrottledCallback.spec.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { afterEach, 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(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - 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 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 })); - - 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 deleted file mode 100644 index c24293b8..00000000 --- a/packages/core/src/hooks/useThrottledCallback/useThrottledCallback.ts +++ /dev/null @@ -1,76 +0,0 @@ -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 = { - 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(function cleanupThrottleOnUnmount() { - const current = ref.current; - return () => { - current.clearPreviousThrottle(); - }; - }, []); - - const preservedEdges = usePreservedReference(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 1a80465e..319b8694 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -29,7 +29,6 @@ export { useRefEffect } from './hooks/useRefEffect/index.ts'; export { useSet } from './hooks/useSet/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 8353e6735ac29697e330dae1697cf4254f7e91b7 Mon Sep 17 00:00:00 2001 From: kimyouknow Date: Tue, 17 Mar 2026 10:19:45 +0900 Subject: [PATCH 2/4] Revert "feat(core/hooks): add 'useList' hook (#341)" This reverts commit b40b0ce183ea3bde4609b1c1fccac64a9a96e14d, reversing changes made to ffc61bb998fdaf129fff12e5e7515007ca5eeb51. --- .changeset/swift-birds-glow.md | 5 - packages/core/src/hooks/useList/index.ts | 1 - packages/core/src/hooks/useList/ko/useList.md | 55 ----- packages/core/src/hooks/useList/useList.md | 55 ----- .../core/src/hooks/useList/useList.spec.ts | 188 ------------------ packages/core/src/hooks/useList/useList.ts | 102 ---------- packages/core/src/index.ts | 1 - 7 files changed, 407 deletions(-) delete mode 100644 .changeset/swift-birds-glow.md delete mode 100644 packages/core/src/hooks/useList/index.ts delete mode 100644 packages/core/src/hooks/useList/ko/useList.md delete mode 100644 packages/core/src/hooks/useList/useList.md delete mode 100644 packages/core/src/hooks/useList/useList.spec.ts delete mode 100644 packages/core/src/hooks/useList/useList.ts diff --git a/.changeset/swift-birds-glow.md b/.changeset/swift-birds-glow.md deleted file mode 100644 index 1ec2d2ec..00000000 --- a/.changeset/swift-birds-glow.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'react-simplikit': patch ---- - -feat(core/hooks): add 'useList' hook diff --git a/packages/core/src/hooks/useList/index.ts b/packages/core/src/hooks/useList/index.ts deleted file mode 100644 index 8d41be6d..00000000 --- a/packages/core/src/hooks/useList/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { useList } from './useList.ts'; diff --git a/packages/core/src/hooks/useList/ko/useList.md b/packages/core/src/hooks/useList/ko/useList.md deleted file mode 100644 index 0d7021ea..00000000 --- a/packages/core/src/hooks/useList/ko/useList.md +++ /dev/null @@ -1,55 +0,0 @@ -# useList - -리액트 훅으로, 배열을 상태로 관리해요. 효율적인 상태 관리를 제공하고 안정적인 액션 함수를 제공해요. - -## 인터페이스 - -```ts -function useList(initialState?: T[]): UseListReturn; -``` - -### 파라미터 - - - -### 반환 값 - -튜플 `[list, actions]`를 반환해요. - - - - - - - - - - -## 예시 - -```tsx -import { useList } from 'react-simplikit'; - -function TodoList() { - const [todos, actions] = useList(['Buy milk', 'Walk the dog']); - - return ( -
-
    - {todos.map((todo, index) => ( -
  • - {todo} - -
  • - ))} -
- - -
- ); -} -``` diff --git a/packages/core/src/hooks/useList/useList.md b/packages/core/src/hooks/useList/useList.md deleted file mode 100644 index 526ad089..00000000 --- a/packages/core/src/hooks/useList/useList.md +++ /dev/null @@ -1,55 +0,0 @@ -# useList - -A React hook that manages an array as state. Provides efficient state management and stable action functions. - -## Interface - -```ts -function useList(initialState?: T[]): UseListReturn; -``` - -### Parameters - - - -### Return Value - -Returns a tuple `[list, actions]`. - - - - - - - - - - -## Example - -```tsx -import { useList } from 'react-simplikit'; - -function TodoList() { - const [todos, actions] = useList(['Buy milk', 'Walk the dog']); - - return ( -
-
    - {todos.map((todo, index) => ( -
  • - {todo} - -
  • - ))} -
- - -
- ); -} -``` diff --git a/packages/core/src/hooks/useList/useList.spec.ts b/packages/core/src/hooks/useList/useList.spec.ts deleted file mode 100644 index 7eaa3199..00000000 --- a/packages/core/src/hooks/useList/useList.spec.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { act } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; - -import { renderHookSSR } from '../../_internal/test-utils/renderHookSSR.tsx'; - -import { useList } from './useList.ts'; - -describe('useList', () => { - it('is safe on server side rendering', () => { - const result = renderHookSSR.serverOnly(() => useList(['a', 'b'])); - - expect(result.current[0]).toEqual(['a', 'b']); - }); - - it('should initialize with an array', async () => { - const { result } = await renderHookSSR(() => useList(['a', 'b'])); - - expect(result.current[0]).toEqual(['a', 'b']); - }); - - it('should initialize with an empty array when no arguments provided', async () => { - const { result } = await renderHookSSR(() => useList()); - - expect(result.current[0]).toEqual([]); - }); - - it('should push a value to the end of the list', async () => { - const { result, rerender } = await renderHookSSR(() => useList(['a'])); - const [, actions] = result.current; - - await act(async () => { - actions.push('b'); - rerender(); - }); - - expect(result.current[0]).toEqual(['a', 'b']); - }); - - it('should insert a value at the specified index', async () => { - const { result, rerender } = await renderHookSSR(() => useList(['a', 'c'])); - const [, actions] = result.current; - - await act(async () => { - actions.insertAt(1, 'b'); - rerender(); - }); - - expect(result.current[0]).toEqual(['a', 'b', 'c']); - }); - - it('should insert at the beginning when index is 0', async () => { - const { result, rerender } = await renderHookSSR(() => useList(['b', 'c'])); - const [, actions] = result.current; - - await act(async () => { - actions.insertAt(0, 'a'); - rerender(); - }); - - expect(result.current[0]).toEqual(['a', 'b', 'c']); - }); - - it('should update a value at the specified index', async () => { - const { result, rerender } = await renderHookSSR(() => useList(['a', 'b', 'c'])); - const [, actions] = result.current; - - await act(async () => { - actions.updateAt(1, 'x'); - rerender(); - }); - - expect(result.current[0]).toEqual(['a', 'x', 'c']); - }); - - it('should remove a value at the specified index', async () => { - const { result, rerender } = await renderHookSSR(() => useList(['a', 'b', 'c'])); - const [, actions] = result.current; - - await act(async () => { - actions.removeAt(1); - rerender(); - }); - - expect(result.current[0]).toEqual(['a', 'c']); - }); - - it('should remove the first item', async () => { - const { result, rerender } = await renderHookSSR(() => useList(['a', 'b', 'c'])); - const [, actions] = result.current; - - await act(async () => { - actions.removeAt(0); - rerender(); - }); - - expect(result.current[0]).toEqual(['b', 'c']); - }); - - it('should remove the last item', async () => { - const { result, rerender } = await renderHookSSR(() => useList(['a', 'b', 'c'])); - const [, actions] = result.current; - - await act(async () => { - actions.removeAt(2); - rerender(); - }); - - expect(result.current[0]).toEqual(['a', 'b']); - }); - - it('should replace all values with setAll', async () => { - const { result, rerender } = await renderHookSSR(() => useList(['a', 'b'])); - const [, actions] = result.current; - - await act(async () => { - actions.setAll(['x', 'y', 'z']); - rerender(); - }); - - expect(result.current[0]).toEqual(['x', 'y', 'z']); - }); - - it('should reset the list to its initial state', async () => { - const { result, rerender } = await renderHookSSR(() => useList(['a', 'b'])); - const [, actions] = result.current; - - await act(async () => { - actions.push('c'); - actions.removeAt(0); - rerender(); - }); - - expect(result.current[0]).not.toEqual(['a', 'b']); - - await act(async () => { - actions.reset(); - rerender(); - }); - - expect(result.current[0]).toEqual(['a', 'b']); - }); - - it('should reset to empty array when initialized with empty array', async () => { - const { result, rerender } = await renderHookSSR(() => useList()); - const [, actions] = result.current; - - await act(async () => { - actions.push('a'); - actions.push('b'); - rerender(); - }); - - expect(result.current[0]).toEqual(['a', 'b']); - - await act(async () => { - actions.reset(); - rerender(); - }); - - expect(result.current[0]).toEqual([]); - }); - - it('should create a new array reference when values change', async () => { - const { result, rerender } = await renderHookSSR(() => useList(['a'])); - const [originalRef] = result.current; - - await act(async () => { - result.current[1].push('b'); - rerender(); - }); - - expect(originalRef).not.toBe(result.current[0]); - }); - - it('should maintain stable actions reference after list changes', async () => { - const { result, rerender } = await renderHookSSR(() => useList()); - const [, originalActions] = result.current; - - expect(result.current[1]).toBe(originalActions); - - await act(async () => { - originalActions.push('a'); - rerender(); - }); - - expect(result.current[1]).toBe(originalActions); - }); -}); diff --git a/packages/core/src/hooks/useList/useList.ts b/packages/core/src/hooks/useList/useList.ts deleted file mode 100644 index d6586e3c..00000000 --- a/packages/core/src/hooks/useList/useList.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { useMemo, useState } from 'react'; - -import { usePreservedCallback } from '../usePreservedCallback/index.ts'; -import { usePreservedReference } from '../usePreservedReference/usePreservedReference.ts'; - -type ListActions = { - push: (value: T) => void; - insertAt: (index: number, value: T) => void; - updateAt: (index: number, value: T) => void; - removeAt: (index: number) => void; - setAll: (values: T[]) => void; - reset: () => void; -}; - -type UseListReturn = [ReadonlyArray, ListActions]; - -/** - * @description - * A React hook that manages an array as state. - * Provides efficient state management and stable action functions. - * - * @param {T[]} initialState - Initial array state - * - * @returns {UseListReturn} A tuple containing the array state and actions to manipulate it - * - `push` - Appends a value to the end of the list - * - `insertAt` - Inserts a value at the specified index - * - `updateAt` - Updates the value at the specified index - * - `removeAt` - Removes the value at the specified index - * - `setAll` - Replaces the entire list with a new array - * - `reset` - Resets the list to its initial state - * - * @example - * ```tsx - * const [list, actions] = useList(['apple', 'banana']); - * - * // Add an item - * actions.push('cherry'); - * - * // Insert at index - * actions.insertAt(1, 'grape'); - * - * // Update at index - * actions.updateAt(0, 'orange'); - * - * // Remove at index - * actions.removeAt(2); - * - * // Replace all - * actions.setAll(['kiwi', 'mango']); - * - * // Reset to initial state - * actions.reset(); - * ``` - */ -export function useList(initialState: T[] = []): UseListReturn { - const [list, setList] = useState(initialState); - - const preservedInitialState = usePreservedReference(initialState); - - const push = usePreservedCallback((value: T) => { - setList(prev => [...prev, value]); - }); - - const insertAt = usePreservedCallback((index: number, value: T) => { - setList(prev => { - const next = [...prev]; - next.splice(index, 0, value); - return next; - }); - }); - - const updateAt = usePreservedCallback((index: number, value: T) => { - setList(prev => { - const next = [...prev]; - next[index] = value; - return next; - }); - }); - - const removeAt = usePreservedCallback((index: number) => { - setList(prev => { - const next = [...prev]; - next.splice(index, 1); - return next; - }); - }); - - const setAll = usePreservedCallback((values: T[]) => { - setList(values); - }); - - const reset = usePreservedCallback(() => { - setList(preservedInitialState); - }); - - const actions = useMemo>( - () => ({ push, insertAt, updateAt, removeAt, setAll, reset }), - [push, insertAt, updateAt, removeAt, setAll, reset] - ); - - return [list, actions]; -} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 319b8694..d1b9f01b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -17,7 +17,6 @@ export { useIntersectionObserver } from './hooks/useIntersectionObserver/index.t export { useInterval } from './hooks/useInterval/index.ts'; export { useIsClient } from './hooks/useIsClient/index.ts'; export { useIsomorphicLayoutEffect } from './hooks/useIsomorphicLayoutEffect/index.ts'; -export { useList } from './hooks/useList/index.ts'; export { useLoading } from './hooks/useLoading/index.ts'; export { useLongPress } from './hooks/useLongPress/index.ts'; export { useMap } from './hooks/useMap/index.ts'; From 06278860340d50dcdb032a9225ec2f116f20ffd9 Mon Sep 17 00:00:00 2001 From: Wonsuk Choi Date: Tue, 17 Mar 2026 10:21:29 +0900 Subject: [PATCH 3/4] feat(core/hooks): add 'useList' hook (#341) --- .changeset/swift-birds-glow.md | 5 + packages/core/src/hooks/useList/index.ts | 1 + packages/core/src/hooks/useList/ko/useList.md | 55 +++++ packages/core/src/hooks/useList/useList.md | 55 +++++ .../core/src/hooks/useList/useList.spec.ts | 188 ++++++++++++++++++ packages/core/src/hooks/useList/useList.ts | 102 ++++++++++ packages/core/src/index.ts | 1 + 7 files changed, 407 insertions(+) create mode 100644 .changeset/swift-birds-glow.md create mode 100644 packages/core/src/hooks/useList/index.ts create mode 100644 packages/core/src/hooks/useList/ko/useList.md create mode 100644 packages/core/src/hooks/useList/useList.md create mode 100644 packages/core/src/hooks/useList/useList.spec.ts create mode 100644 packages/core/src/hooks/useList/useList.ts diff --git a/.changeset/swift-birds-glow.md b/.changeset/swift-birds-glow.md new file mode 100644 index 00000000..1ec2d2ec --- /dev/null +++ b/.changeset/swift-birds-glow.md @@ -0,0 +1,5 @@ +--- +'react-simplikit': patch +--- + +feat(core/hooks): add 'useList' hook diff --git a/packages/core/src/hooks/useList/index.ts b/packages/core/src/hooks/useList/index.ts new file mode 100644 index 00000000..8d41be6d --- /dev/null +++ b/packages/core/src/hooks/useList/index.ts @@ -0,0 +1 @@ +export { useList } from './useList.ts'; diff --git a/packages/core/src/hooks/useList/ko/useList.md b/packages/core/src/hooks/useList/ko/useList.md new file mode 100644 index 00000000..0d7021ea --- /dev/null +++ b/packages/core/src/hooks/useList/ko/useList.md @@ -0,0 +1,55 @@ +# useList + +리액트 훅으로, 배열을 상태로 관리해요. 효율적인 상태 관리를 제공하고 안정적인 액션 함수를 제공해요. + +## 인터페이스 + +```ts +function useList(initialState?: T[]): UseListReturn; +``` + +### 파라미터 + + + +### 반환 값 + +튜플 `[list, actions]`를 반환해요. + + + + + + + + + + +## 예시 + +```tsx +import { useList } from 'react-simplikit'; + +function TodoList() { + const [todos, actions] = useList(['Buy milk', 'Walk the dog']); + + return ( +
+
    + {todos.map((todo, index) => ( +
  • + {todo} + +
  • + ))} +
+ + +
+ ); +} +``` diff --git a/packages/core/src/hooks/useList/useList.md b/packages/core/src/hooks/useList/useList.md new file mode 100644 index 00000000..526ad089 --- /dev/null +++ b/packages/core/src/hooks/useList/useList.md @@ -0,0 +1,55 @@ +# useList + +A React hook that manages an array as state. Provides efficient state management and stable action functions. + +## Interface + +```ts +function useList(initialState?: T[]): UseListReturn; +``` + +### Parameters + + + +### Return Value + +Returns a tuple `[list, actions]`. + + + + + + + + + + +## Example + +```tsx +import { useList } from 'react-simplikit'; + +function TodoList() { + const [todos, actions] = useList(['Buy milk', 'Walk the dog']); + + return ( +
+
    + {todos.map((todo, index) => ( +
  • + {todo} + +
  • + ))} +
+ + +
+ ); +} +``` diff --git a/packages/core/src/hooks/useList/useList.spec.ts b/packages/core/src/hooks/useList/useList.spec.ts new file mode 100644 index 00000000..7eaa3199 --- /dev/null +++ b/packages/core/src/hooks/useList/useList.spec.ts @@ -0,0 +1,188 @@ +import { act } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { renderHookSSR } from '../../_internal/test-utils/renderHookSSR.tsx'; + +import { useList } from './useList.ts'; + +describe('useList', () => { + it('is safe on server side rendering', () => { + const result = renderHookSSR.serverOnly(() => useList(['a', 'b'])); + + expect(result.current[0]).toEqual(['a', 'b']); + }); + + it('should initialize with an array', async () => { + const { result } = await renderHookSSR(() => useList(['a', 'b'])); + + expect(result.current[0]).toEqual(['a', 'b']); + }); + + it('should initialize with an empty array when no arguments provided', async () => { + const { result } = await renderHookSSR(() => useList()); + + expect(result.current[0]).toEqual([]); + }); + + it('should push a value to the end of the list', async () => { + const { result, rerender } = await renderHookSSR(() => useList(['a'])); + const [, actions] = result.current; + + await act(async () => { + actions.push('b'); + rerender(); + }); + + expect(result.current[0]).toEqual(['a', 'b']); + }); + + it('should insert a value at the specified index', async () => { + const { result, rerender } = await renderHookSSR(() => useList(['a', 'c'])); + const [, actions] = result.current; + + await act(async () => { + actions.insertAt(1, 'b'); + rerender(); + }); + + expect(result.current[0]).toEqual(['a', 'b', 'c']); + }); + + it('should insert at the beginning when index is 0', async () => { + const { result, rerender } = await renderHookSSR(() => useList(['b', 'c'])); + const [, actions] = result.current; + + await act(async () => { + actions.insertAt(0, 'a'); + rerender(); + }); + + expect(result.current[0]).toEqual(['a', 'b', 'c']); + }); + + it('should update a value at the specified index', async () => { + const { result, rerender } = await renderHookSSR(() => useList(['a', 'b', 'c'])); + const [, actions] = result.current; + + await act(async () => { + actions.updateAt(1, 'x'); + rerender(); + }); + + expect(result.current[0]).toEqual(['a', 'x', 'c']); + }); + + it('should remove a value at the specified index', async () => { + const { result, rerender } = await renderHookSSR(() => useList(['a', 'b', 'c'])); + const [, actions] = result.current; + + await act(async () => { + actions.removeAt(1); + rerender(); + }); + + expect(result.current[0]).toEqual(['a', 'c']); + }); + + it('should remove the first item', async () => { + const { result, rerender } = await renderHookSSR(() => useList(['a', 'b', 'c'])); + const [, actions] = result.current; + + await act(async () => { + actions.removeAt(0); + rerender(); + }); + + expect(result.current[0]).toEqual(['b', 'c']); + }); + + it('should remove the last item', async () => { + const { result, rerender } = await renderHookSSR(() => useList(['a', 'b', 'c'])); + const [, actions] = result.current; + + await act(async () => { + actions.removeAt(2); + rerender(); + }); + + expect(result.current[0]).toEqual(['a', 'b']); + }); + + it('should replace all values with setAll', async () => { + const { result, rerender } = await renderHookSSR(() => useList(['a', 'b'])); + const [, actions] = result.current; + + await act(async () => { + actions.setAll(['x', 'y', 'z']); + rerender(); + }); + + expect(result.current[0]).toEqual(['x', 'y', 'z']); + }); + + it('should reset the list to its initial state', async () => { + const { result, rerender } = await renderHookSSR(() => useList(['a', 'b'])); + const [, actions] = result.current; + + await act(async () => { + actions.push('c'); + actions.removeAt(0); + rerender(); + }); + + expect(result.current[0]).not.toEqual(['a', 'b']); + + await act(async () => { + actions.reset(); + rerender(); + }); + + expect(result.current[0]).toEqual(['a', 'b']); + }); + + it('should reset to empty array when initialized with empty array', async () => { + const { result, rerender } = await renderHookSSR(() => useList()); + const [, actions] = result.current; + + await act(async () => { + actions.push('a'); + actions.push('b'); + rerender(); + }); + + expect(result.current[0]).toEqual(['a', 'b']); + + await act(async () => { + actions.reset(); + rerender(); + }); + + expect(result.current[0]).toEqual([]); + }); + + it('should create a new array reference when values change', async () => { + const { result, rerender } = await renderHookSSR(() => useList(['a'])); + const [originalRef] = result.current; + + await act(async () => { + result.current[1].push('b'); + rerender(); + }); + + expect(originalRef).not.toBe(result.current[0]); + }); + + it('should maintain stable actions reference after list changes', async () => { + const { result, rerender } = await renderHookSSR(() => useList()); + const [, originalActions] = result.current; + + expect(result.current[1]).toBe(originalActions); + + await act(async () => { + originalActions.push('a'); + rerender(); + }); + + expect(result.current[1]).toBe(originalActions); + }); +}); diff --git a/packages/core/src/hooks/useList/useList.ts b/packages/core/src/hooks/useList/useList.ts new file mode 100644 index 00000000..d6586e3c --- /dev/null +++ b/packages/core/src/hooks/useList/useList.ts @@ -0,0 +1,102 @@ +import { useMemo, useState } from 'react'; + +import { usePreservedCallback } from '../usePreservedCallback/index.ts'; +import { usePreservedReference } from '../usePreservedReference/usePreservedReference.ts'; + +type ListActions = { + push: (value: T) => void; + insertAt: (index: number, value: T) => void; + updateAt: (index: number, value: T) => void; + removeAt: (index: number) => void; + setAll: (values: T[]) => void; + reset: () => void; +}; + +type UseListReturn = [ReadonlyArray, ListActions]; + +/** + * @description + * A React hook that manages an array as state. + * Provides efficient state management and stable action functions. + * + * @param {T[]} initialState - Initial array state + * + * @returns {UseListReturn} A tuple containing the array state and actions to manipulate it + * - `push` - Appends a value to the end of the list + * - `insertAt` - Inserts a value at the specified index + * - `updateAt` - Updates the value at the specified index + * - `removeAt` - Removes the value at the specified index + * - `setAll` - Replaces the entire list with a new array + * - `reset` - Resets the list to its initial state + * + * @example + * ```tsx + * const [list, actions] = useList(['apple', 'banana']); + * + * // Add an item + * actions.push('cherry'); + * + * // Insert at index + * actions.insertAt(1, 'grape'); + * + * // Update at index + * actions.updateAt(0, 'orange'); + * + * // Remove at index + * actions.removeAt(2); + * + * // Replace all + * actions.setAll(['kiwi', 'mango']); + * + * // Reset to initial state + * actions.reset(); + * ``` + */ +export function useList(initialState: T[] = []): UseListReturn { + const [list, setList] = useState(initialState); + + const preservedInitialState = usePreservedReference(initialState); + + const push = usePreservedCallback((value: T) => { + setList(prev => [...prev, value]); + }); + + const insertAt = usePreservedCallback((index: number, value: T) => { + setList(prev => { + const next = [...prev]; + next.splice(index, 0, value); + return next; + }); + }); + + const updateAt = usePreservedCallback((index: number, value: T) => { + setList(prev => { + const next = [...prev]; + next[index] = value; + return next; + }); + }); + + const removeAt = usePreservedCallback((index: number) => { + setList(prev => { + const next = [...prev]; + next.splice(index, 1); + return next; + }); + }); + + const setAll = usePreservedCallback((values: T[]) => { + setList(values); + }); + + const reset = usePreservedCallback(() => { + setList(preservedInitialState); + }); + + const actions = useMemo>( + () => ({ push, insertAt, updateAt, removeAt, setAll, reset }), + [push, insertAt, updateAt, removeAt, setAll, reset] + ); + + return [list, actions]; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d1b9f01b..319b8694 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -17,6 +17,7 @@ export { useIntersectionObserver } from './hooks/useIntersectionObserver/index.t export { useInterval } from './hooks/useInterval/index.ts'; export { useIsClient } from './hooks/useIsClient/index.ts'; export { useIsomorphicLayoutEffect } from './hooks/useIsomorphicLayoutEffect/index.ts'; +export { useList } from './hooks/useList/index.ts'; export { useLoading } from './hooks/useLoading/index.ts'; export { useLongPress } from './hooks/useLongPress/index.ts'; export { useMap } from './hooks/useMap/index.ts'; From 24fcc8308427875985f017200e50847ad1cc84a1 Mon Sep 17 00:00:00 2001 From: Wonsuk Choi Date: Tue, 17 Mar 2026 10:21:43 +0900 Subject: [PATCH 4/4] feat(core/hooks): add 'useThrottledCallback' hook (#342) --- .changeset/tall-lions-rush.md | 5 + .../src/hooks/useThrottledCallback/index.ts | 1 + .../ko/useThrottledCallback.md | 65 ++++++++++ .../useThrottledCallback.md | 65 ++++++++++ .../useThrottledCallback.spec.ts | 114 ++++++++++++++++++ .../useThrottledCallback.ts | 76 ++++++++++++ packages/core/src/index.ts | 1 + 7 files changed, 327 insertions(+) create mode 100644 .changeset/tall-lions-rush.md 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/.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 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..010d8f29 --- /dev/null +++ b/packages/core/src/hooks/useThrottledCallback/useThrottledCallback.spec.ts @@ -0,0 +1,114 @@ +import { afterEach, 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(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + 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 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 })); + + 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..c24293b8 --- /dev/null +++ b/packages/core/src/hooks/useThrottledCallback/useThrottledCallback.ts @@ -0,0 +1,76 @@ +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 = { + 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(function cleanupThrottleOnUnmount() { + const current = ref.current; + return () => { + current.clearPreviousThrottle(); + }; + }, []); + + const preservedEdges = usePreservedReference(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 319b8694..1a80465e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -29,6 +29,7 @@ export { useRefEffect } from './hooks/useRefEffect/index.ts'; export { useSet } from './hooks/useSet/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';