Skip to content

Commit 29f547d

Browse files
feat(hooks): add 'useMap' (#235)
Co-authored-by: seungrodotlee <seungrodotlee@gmail.com>
1 parent 7ce5cf7 commit 29f547d

4 files changed

Lines changed: 244 additions & 0 deletions

File tree

src/hooks/useMap/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { useMap } from './useMap.ts';

src/hooks/useMap/useMap.spec.ts

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { renderHookSSR } from '../../_internal/test-utils/renderHookSSR.tsx';
4+
5+
import { useMap } from './useMap.ts';
6+
7+
describe('useMap', () => {
8+
it('should initialize with a Map', async () => {
9+
const initialMap = new Map([[1, 'initial']]);
10+
const { result } = await renderHookSSR(() => useMap(initialMap));
11+
12+
expect(result.current[0].get(1)).toBe('initial');
13+
});
14+
15+
it('should initialize with an array of entries', async () => {
16+
const { result } = await renderHookSSR(() => useMap([[1, 'initial']]));
17+
18+
expect(result.current[0].get(1)).toBe('initial');
19+
});
20+
21+
it('should initialize with an empty Map when no arguments provided', async () => {
22+
const { result } = await renderHookSSR(() => useMap());
23+
24+
expect(result.current[0].size).toBe(0);
25+
});
26+
27+
it('should add a new value to the Map', async () => {
28+
const { result, rerender } = await renderHookSSR(() => useMap<number, string>());
29+
const [, actions] = result.current;
30+
31+
expect(result.current[0].get(1)).toBeUndefined();
32+
33+
actions.set(1, 'added');
34+
rerender();
35+
36+
expect(result.current[0].get(1)).toBe('added');
37+
});
38+
39+
it('should update an existing value in the Map', async () => {
40+
const initialMap = new Map([[1, 'initial']]);
41+
const { result, rerender } = await renderHookSSR(() => useMap(initialMap));
42+
const [, actions] = result.current;
43+
44+
actions.set(1, 'edited');
45+
rerender();
46+
47+
expect(result.current[0].get(1)).toBe('edited');
48+
});
49+
50+
it('should replace all values with setAll', async () => {
51+
const initialMap = new Map([
52+
[1, 'initial'],
53+
[2, 'example'],
54+
]);
55+
const { result, rerender } = await renderHookSSR(() => useMap(initialMap));
56+
const [, actions] = result.current;
57+
58+
expect(result.current[0].get(1)).toBe('initial');
59+
expect(result.current[0].get(2)).toBe('example');
60+
expect(result.current[0].size).toBe(2);
61+
62+
actions.setAll([[1, 'edited']]);
63+
rerender();
64+
65+
expect(result.current[0].get(1)).toBe('edited');
66+
expect(result.current[0].get(2)).toBeUndefined();
67+
expect(result.current[0].size).toBe(1);
68+
});
69+
70+
it('should remove an existing value from the Map', async () => {
71+
const initialMap = new Map([[1, 'initial']]);
72+
const { result, rerender } = await renderHookSSR(() => useMap(initialMap));
73+
const [, actions] = result.current;
74+
75+
actions.remove(1);
76+
rerender();
77+
78+
expect(result.current[0].get(1)).toBeUndefined();
79+
expect(result.current[0].size).toBe(0);
80+
});
81+
82+
it('should reset the Map to its initial state', async () => {
83+
const initialMap = new Map([[1, 'initial']]);
84+
const { result, rerender } = await renderHookSSR(() => useMap(initialMap));
85+
const [, actions] = result.current;
86+
87+
// First modify the map
88+
actions.set(2, 'added');
89+
actions.set(1, 'modified');
90+
rerender();
91+
92+
expect(result.current[0].get(1)).toBe('modified');
93+
expect(result.current[0].get(2)).toBe('added');
94+
expect(result.current[0].size).toBe(2);
95+
96+
// Then reset to initial state
97+
actions.reset();
98+
rerender();
99+
100+
// Should be back to initial state
101+
expect(result.current[0].get(1)).toBe('initial');
102+
expect(result.current[0].get(2)).toBeUndefined();
103+
expect(result.current[0].size).toBe(1);
104+
});
105+
106+
it('should reset to empty Map when initialized with empty Map', async () => {
107+
const { result, rerender } = await renderHookSSR(() => useMap<number, string>());
108+
const [, actions] = result.current;
109+
110+
// Add some items
111+
actions.set(1, 'one');
112+
actions.set(2, 'two');
113+
rerender();
114+
115+
expect(result.current[0].size).toBe(2);
116+
117+
// Reset should restore to empty state
118+
actions.reset();
119+
rerender();
120+
121+
expect(result.current[0].size).toBe(0);
122+
});
123+
124+
it('should create a new Map reference when values change', async () => {
125+
const initialMap = new Map<number, number>();
126+
const { result, rerender } = await renderHookSSR(() => useMap(initialMap));
127+
const [originalMapRef, actions] = result.current;
128+
129+
actions.set(1, 1);
130+
rerender();
131+
132+
expect(originalMapRef).not.toBe(result.current[0]);
133+
expect(originalMapRef.get(1)).toBeUndefined();
134+
expect(result.current[0].get(1)).toBe(1);
135+
});
136+
137+
it('should maintain stable actions reference after Map changes', async () => {
138+
const initialMap = new Map<number, number>();
139+
const { result, rerender } = await renderHookSSR(() => useMap(initialMap));
140+
const [, originalActionsRef] = result.current;
141+
142+
expect(result.current[1]).toBe(originalActionsRef);
143+
144+
originalActionsRef.set(1, 1);
145+
rerender();
146+
147+
expect(result.current[1]).toBe(originalActionsRef);
148+
});
149+
150+
it('is safe in server-side rendering', () => {
151+
const initialMap = new Map([[1, 'initial']]);
152+
renderHookSSR.serverOnly(() => useMap(initialMap));
153+
// This shouldn't throw any errors
154+
});
155+
});

src/hooks/useMap/useMap.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { useCallback, useMemo, useState } from 'react';
2+
3+
import { usePreservedReference } from '../usePreservedReference/usePreservedReference.ts';
4+
5+
/**
6+
* Defines the type for either a Map or an array of key-value pairs.
7+
*/
8+
type MapOrEntries<K, V> = Map<K, V> | [K, V][];
9+
10+
/**
11+
* Actions to manipulate the Map state.
12+
*/
13+
type MapActions<K, V> = {
14+
/** Sets a key-value pair in the map. */
15+
set: (key: K, value: V) => void;
16+
/** Sets multiple key-value pairs in the map at once. */
17+
setAll: (entries: MapOrEntries<K, V>) => void;
18+
/** Removes a key from the map. */
19+
remove: (key: K) => void;
20+
/** Resets the map to its initial state. */
21+
reset: () => void;
22+
};
23+
24+
/**
25+
* Return type of the useMap hook.
26+
* Hides certain methods to prevent direct mutations.
27+
*/
28+
type UseMapReturn<K, V> = [Omit<Map<K, V>, 'set' | 'clear' | 'delete'>, MapActions<K, V>];
29+
30+
/**
31+
* @description
32+
* A React hook that manages a key-value Map as state.
33+
* Provides efficient state management and stable action functions.
34+
*
35+
* @param {MapOrEntries<K, V>} initialState - Initial Map state (Map object or array of key-value pairs)
36+
* @returns {UseMapReturn<K, V>} A tuple containing the Map state and actions to manipulate it
37+
*
38+
* @example
39+
* ```tsx
40+
* const [userMap, actions] = useMap<string, User>([
41+
* ['user1', { name: 'John', age: 30 }]
42+
* ]);
43+
*
44+
* // Using values from the Map
45+
* const user1 = userMap.get('user1');
46+
*
47+
* // Updating the Map
48+
* actions.set('user2', { name: 'Jane', age: 25 });
49+
* ```
50+
*/
51+
export function useMap<K, V>(initialState: MapOrEntries<K, V> = new Map()): UseMapReturn<K, V> {
52+
// Initialize Map state
53+
const [map, setMap] = useState(() => new Map(initialState));
54+
55+
// Use usePreservedReference to maintain stable reference to initialState
56+
const preservedInitialState = usePreservedReference(initialState);
57+
58+
const set = useCallback((key: K, value: V) => {
59+
setMap(prev => {
60+
const nextMap = new Map(prev);
61+
nextMap.set(key, value);
62+
return nextMap;
63+
});
64+
}, []);
65+
66+
const setAll = useCallback((entries: MapOrEntries<K, V>) => {
67+
setMap(() => new Map(entries));
68+
}, []);
69+
70+
const remove = useCallback((key: K) => {
71+
setMap(prev => {
72+
const nextMap = new Map(prev);
73+
nextMap.delete(key);
74+
return nextMap;
75+
});
76+
}, []);
77+
78+
const reset = useCallback(() => {
79+
setMap(() => new Map(preservedInitialState));
80+
}, [preservedInitialState]);
81+
82+
const actions = useMemo<MapActions<K, V>>(() => {
83+
return { set, setAll, remove, reset };
84+
}, [set, setAll, remove, reset]);
85+
86+
return [map, actions];
87+
}

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export { useIntersectionObserver } from './hooks/useIntersectionObserver/index.t
1515
export { useInterval } from './hooks/useInterval/index.ts';
1616
export { useIsomorphicLayoutEffect } from './hooks/useIsomorphicLayoutEffect/index.ts';
1717
export { useLoading } from './hooks/useLoading/index.ts';
18+
export { useMap } from './hooks/useMap/index.ts';
1819
export { useOutsideClickEffect } from './hooks/useOutsideClickEffect/index.ts';
1920
export { usePreservedCallback } from './hooks/usePreservedCallback/index.ts';
2021
export { usePreservedReference } from './hooks/usePreservedReference/index.ts';

0 commit comments

Comments
 (0)