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.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 + }); +}); diff --git a/src/hooks/useMap/useMap.ts b/src/hooks/useMap/useMap.ts new file mode 100644 index 00000000..08854ad8 --- /dev/null +++ b/src/hooks/useMap/useMap.ts @@ -0,0 +1,87 @@ +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]; + +/** + * @description + * A React hook that manages a key-value Map as state. + * Provides efficient state management and stable action functions. + * + * @param {MapOrEntries} initialState - Initial Map state (Map object or array of key-value pairs) + * @returns {UseMapReturn} 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';