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 975dd899..ba942619 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';