From d47f7850157da064af96380d0552b59f8f4d834a Mon Sep 17 00:00:00 2001 From: Yoonjae Choi Date: Fri, 9 May 2025 23:03:06 +0900 Subject: [PATCH 1/5] feat(useMap): add useMap hook --- src/hooks/useMap/index.ts | 1 + src/hooks/useMap/useMap.ts | 86 ++++++++++++++++++++++++++++++++++++++ src/index.ts | 1 + 3 files changed, 88 insertions(+) create mode 100644 src/hooks/useMap/index.ts create mode 100644 src/hooks/useMap/useMap.ts diff --git a/src/hooks/useMap/index.ts b/src/hooks/useMap/index.ts new file mode 100644 index 00000000..0605716d --- /dev/null +++ b/src/hooks/useMap/index.ts @@ -0,0 +1 @@ +export { useMap } from './useMap.ts'; diff --git a/src/hooks/useMap/useMap.ts b/src/hooks/useMap/useMap.ts new file mode 100644 index 00000000..52d53fb3 --- /dev/null +++ b/src/hooks/useMap/useMap.ts @@ -0,0 +1,86 @@ +import { useCallback, useMemo, useState } from 'react'; + +import { usePreservedReference } from '../usePreservedReference/usePreservedReference.ts'; + +/** + * Defines the type for either a Map or an array of key-value pairs. + */ +type MapOrEntries = Map | [K, V][]; + +/** + * Actions to manipulate the Map state. + */ +type MapActions = { + /** Sets a key-value pair in the map. */ + set: (key: K, value: V) => void; + /** Sets multiple key-value pairs in the map at once. */ + setAll: (entries: MapOrEntries) => void; + /** Removes a key from the map. */ + remove: (key: K) => void; + /** Resets the map to its initial state. */ + reset: () => void; +}; + +/** + * Return type of the useMap hook. + * Hides certain methods to prevent direct mutations. + */ +type UseMapReturn = [Omit, 'set' | 'clear' | 'delete'>, MapActions]; + +/** + * A React hook that manages a key-value Map as state. + * Provides efficient state management and stable action functions. + * + * @param initialState - Initial Map state (Map object or array of key-value pairs) + * @returns A tuple containing the Map state and actions to manipulate it + * + * @example + * ```tsx + * const [userMap, actions] = useMap([ + * ['user1', { name: 'John', age: 30 }] + * ]); + * + * // Using values from the Map + * const user1 = userMap.get('user1'); + * + * // Updating the Map + * actions.set('user2', { name: 'Jane', age: 25 }); + * ``` + */ +export function useMap(initialState: MapOrEntries = new Map()): UseMapReturn { + // Initialize Map state + const [map, setMap] = useState(() => new Map(initialState)); + + // Use usePreservedReference to maintain stable reference to initialState + const preservedInitialState = usePreservedReference(initialState); + + const set = useCallback((key: K, value: V) => { + setMap(prev => { + const nextMap = new Map(prev); + nextMap.set(key, value); + return nextMap; + }); + }, []); + + const setAll = useCallback((entries: MapOrEntries) => { + setMap(() => new Map(entries)); + }, []); + + const remove = useCallback((key: K) => { + setMap(prev => { + const nextMap = new Map(prev); + nextMap.delete(key); + return nextMap; + }); + }, []); + + const reset = useCallback(() => { + setMap(() => new Map(preservedInitialState)); + }, [preservedInitialState]); + + const actions = useMemo>(() => { + return { set, setAll, remove, reset }; + }, [set, setAll, remove, reset]); + + return [map, actions]; +} diff --git a/src/index.ts b/src/index.ts index 5a5911dc..4828533b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,7 @@ export { useIntersectionObserver } from './hooks/useIntersectionObserver/index.t export { useInterval } from './hooks/useInterval/index.ts'; export { useIsomorphicLayoutEffect } from './hooks/useIsomorphicLayoutEffect/index.ts'; export { useLoading } from './hooks/useLoading/index.ts'; +export { useMap } from './hooks/useMap/index.ts'; export { useOutsideClickEffect } from './hooks/useOutsideClickEffect/index.ts'; export { usePreservedCallback } from './hooks/usePreservedCallback/index.ts'; export { usePreservedReference } from './hooks/usePreservedReference/index.ts'; From e21554c097928a196db6ab5f1d86a57eaa8ed421 Mon Sep 17 00:00:00 2001 From: Yoonjae Choi Date: Fri, 9 May 2025 23:03:14 +0900 Subject: [PATCH 2/5] test(useMap): add unit tests for useMap hook functionality --- src/hooks/useMap/useMap.spec.ts | 155 ++++++++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 src/hooks/useMap/useMap.spec.ts diff --git a/src/hooks/useMap/useMap.spec.ts b/src/hooks/useMap/useMap.spec.ts new file mode 100644 index 00000000..c5ce4b21 --- /dev/null +++ b/src/hooks/useMap/useMap.spec.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from 'vitest'; + +import { renderHookSSR } from '../../_internal/test-utils/renderHookSSR.tsx'; + +import { useMap } from './useMap.ts'; + +describe('useMap', () => { + it('should initialize with a Map', async () => { + const initialMap = new Map([[1, 'initial']]); + const { result } = await renderHookSSR(() => useMap(initialMap)); + + expect(result.current[0].get(1)).toBe('initial'); + }); + + it('should initialize with an array of entries', async () => { + const { result } = await renderHookSSR(() => useMap([[1, 'initial']])); + + expect(result.current[0].get(1)).toBe('initial'); + }); + + it('should initialize with an empty Map when no arguments provided', async () => { + const { result } = await renderHookSSR(() => useMap()); + + expect(result.current[0].size).toBe(0); + }); + + it('should add a new value to the Map', async () => { + const { result, rerender } = await renderHookSSR(() => useMap()); + const [, actions] = result.current; + + expect(result.current[0].get(1)).toBeUndefined(); + + actions.set(1, 'added'); + rerender(); + + expect(result.current[0].get(1)).toBe('added'); + }); + + it('should update an existing value in the Map', async () => { + const initialMap = new Map([[1, 'initial']]); + const { result, rerender } = await renderHookSSR(() => useMap(initialMap)); + const [, actions] = result.current; + + actions.set(1, 'edited'); + rerender(); + + expect(result.current[0].get(1)).toBe('edited'); + }); + + it('should replace all values with setAll', async () => { + const initialMap = new Map([ + [1, 'initial'], + [2, 'example'], + ]); + const { result, rerender } = await renderHookSSR(() => useMap(initialMap)); + const [, actions] = result.current; + + expect(result.current[0].get(1)).toBe('initial'); + expect(result.current[0].get(2)).toBe('example'); + expect(result.current[0].size).toBe(2); + + actions.setAll([[1, 'edited']]); + rerender(); + + expect(result.current[0].get(1)).toBe('edited'); + expect(result.current[0].get(2)).toBeUndefined(); + expect(result.current[0].size).toBe(1); + }); + + it('should remove an existing value from the Map', async () => { + const initialMap = new Map([[1, 'initial']]); + const { result, rerender } = await renderHookSSR(() => useMap(initialMap)); + const [, actions] = result.current; + + actions.remove(1); + rerender(); + + expect(result.current[0].get(1)).toBeUndefined(); + expect(result.current[0].size).toBe(0); + }); + + it('should reset the Map to its initial state', async () => { + const initialMap = new Map([[1, 'initial']]); + const { result, rerender } = await renderHookSSR(() => useMap(initialMap)); + const [, actions] = result.current; + + // First modify the map + actions.set(2, 'added'); + actions.set(1, 'modified'); + rerender(); + + expect(result.current[0].get(1)).toBe('modified'); + expect(result.current[0].get(2)).toBe('added'); + expect(result.current[0].size).toBe(2); + + // Then reset to initial state + actions.reset(); + rerender(); + + // Should be back to initial state + expect(result.current[0].get(1)).toBe('initial'); + expect(result.current[0].get(2)).toBeUndefined(); + expect(result.current[0].size).toBe(1); + }); + + it('should reset to empty Map when initialized with empty Map', async () => { + const { result, rerender } = await renderHookSSR(() => useMap()); + const [, actions] = result.current; + + // Add some items + actions.set(1, 'one'); + actions.set(2, 'two'); + rerender(); + + expect(result.current[0].size).toBe(2); + + // Reset should restore to empty state + actions.reset(); + rerender(); + + expect(result.current[0].size).toBe(0); + }); + + it('should create a new Map reference when values change', async () => { + const initialMap = new Map(); + const { result, rerender } = await renderHookSSR(() => useMap(initialMap)); + const [originalMapRef, actions] = result.current; + + actions.set(1, 1); + rerender(); + + expect(originalMapRef).not.toBe(result.current[0]); + expect(originalMapRef.get(1)).toBeUndefined(); + expect(result.current[0].get(1)).toBe(1); + }); + + it('should maintain stable actions reference after Map changes', async () => { + const initialMap = new Map(); + const { result, rerender } = await renderHookSSR(() => useMap(initialMap)); + const [, originalActionsRef] = result.current; + + expect(result.current[1]).toBe(originalActionsRef); + + originalActionsRef.set(1, 1); + rerender(); + + expect(result.current[1]).toBe(originalActionsRef); + }); + + it('is safe in server-side rendering', () => { + const initialMap = new Map([[1, 'initial']]); + renderHookSSR.serverOnly(() => useMap(initialMap)); + // This shouldn't throw any errors + }); +}); From 4d0a401d5e58a378babc6cc5fd86a3055d0e3123 Mon Sep 17 00:00:00 2001 From: seungrodotlee Date: Sun, 1 Jun 2025 17:54:32 +0900 Subject: [PATCH 3/5] Update src/hooks/useMap/useMap.ts --- src/hooks/useMap/useMap.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/hooks/useMap/useMap.ts b/src/hooks/useMap/useMap.ts index 52d53fb3..435efcf9 100644 --- a/src/hooks/useMap/useMap.ts +++ b/src/hooks/useMap/useMap.ts @@ -28,6 +28,7 @@ type MapActions = { type UseMapReturn = [Omit, 'set' | 'clear' | 'delete'>, MapActions]; /** + * @description * A React hook that manages a key-value Map as state. * Provides efficient state management and stable action functions. * From 4a69a8bb7e4bb7a8ddef89aa130d7ba070d59521 Mon Sep 17 00:00:00 2001 From: seungrodotlee Date: Sun, 1 Jun 2025 17:56:54 +0900 Subject: [PATCH 4/5] Update src/hooks/useMap/useMap.ts --- src/hooks/useMap/useMap.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/useMap/useMap.ts b/src/hooks/useMap/useMap.ts index 435efcf9..189c043f 100644 --- a/src/hooks/useMap/useMap.ts +++ b/src/hooks/useMap/useMap.ts @@ -32,7 +32,7 @@ type UseMapReturn = [Omit, 'set' | 'clear' | 'delete'>, MapActio * A React hook that manages a key-value Map as state. * Provides efficient state management and stable action functions. * - * @param initialState - Initial Map state (Map object or array of key-value pairs) + * @param {MapOrEntries} initialState - Initial Map state (Map object or array of key-value pairs) * @returns A tuple containing the Map state and actions to manipulate it * * @example From ef8fa76d40f624cf7324ad7883100c85f9cb5092 Mon Sep 17 00:00:00 2001 From: seungrodotlee Date: Sun, 1 Jun 2025 18:01:13 +0900 Subject: [PATCH 5/5] Update src/hooks/useMap/useMap.ts --- src/hooks/useMap/useMap.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/useMap/useMap.ts b/src/hooks/useMap/useMap.ts index 189c043f..08854ad8 100644 --- a/src/hooks/useMap/useMap.ts +++ b/src/hooks/useMap/useMap.ts @@ -33,7 +33,7 @@ type UseMapReturn = [Omit, 'set' | 'clear' | 'delete'>, MapActio * Provides efficient state management and stable action functions. * * @param {MapOrEntries} initialState - Initial Map state (Map object or array of key-value pairs) - * @returns A tuple containing the Map state and actions to manipulate it + * @returns {UseMapReturn} A tuple containing the Map state and actions to manipulate it * * @example * ```tsx