Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/hooks/useMap/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { useMap } from './useMap.ts';
155 changes: 155 additions & 0 deletions src/hooks/useMap/useMap.spec.ts
Original file line number Diff line number Diff line change
@@ -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<number, string>());
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<number, string>());
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<number, number>();
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<number, number>();
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
});
});
87 changes: 87 additions & 0 deletions src/hooks/useMap/useMap.ts
Original file line number Diff line number Diff line change
@@ -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<K, V> = Map<K, V> | [K, V][];

/**
* Actions to manipulate the Map state.
*/
type MapActions<K, V> = {
/** 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<K, V>) => 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<K, V> = [Omit<Map<K, V>, 'set' | 'clear' | 'delete'>, MapActions<K, V>];

/**
* @description
* A React hook that manages a key-value Map as state.
Comment thread
seungrodotlee marked this conversation as resolved.
* Provides efficient state management and stable action functions.
*
* @param {MapOrEntries<K, V>} initialState - Initial Map state (Map object or array of key-value pairs)
* @returns A tuple containing the Map state and actions to manipulate it
Comment thread
seungrodotlee marked this conversation as resolved.
Outdated
*
* @example
* ```tsx
* const [userMap, actions] = useMap<string, User>([
* ['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<K, V>(initialState: MapOrEntries<K, V> = new Map()): UseMapReturn<K, V> {
// 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<K, V>) => {
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<MapActions<K, V>>(() => {
return { set, setAll, remove, reset };
}, [set, setAll, remove, reset]);

return [map, actions];
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading