Skip to content

Commit

Permalink
feat: new hook useMap (#155)
Browse files Browse the repository at this point in the history
  • Loading branch information
xobotyi authored Jun 24, 2021
1 parent 95cf9c3 commit 523dd81
Show file tree
Hide file tree
Showing 7 changed files with 259 additions and 0 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ import { useMountEffect } from "@react-hookz/web/esnext";

- [**`useDebouncedState`**](https://react-hookz.github.io/web/?path=/docs/state-usedebouncedstate)
— Lise `useSafeState` but its state setter is debounced.
- [**`useMap`**](https://react-hookz.github.io/web/?path=/docs/state-usemap)
— Tracks the state of a `Map`.
- [**`useMediatedState`**](https://react-hookz.github.io/web/?path=/docs/state-usemediatedstate)
— Like `useState`, but every value set is passed through a mediator function.
- [**`usePrevious`**](https://react-hookz.github.io/web/?path=/docs/state-useprevious)
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export { useUpdateEffect } from './useUpdateEffect/useUpdateEffect';

// State
export { useDebouncedState } from './useDebouncedState/useDebouncedState';
export { useMap } from './useMap/useMap';
export { useMediatedState } from './useMediatedState/useMediatedState';
export { usePrevious } from './usePrevious/usePrevious';
export { useSafeState } from './useSafeState/useSafeState';
Expand Down
22 changes: 22 additions & 0 deletions src/useMap/__docs__/example.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/* eslint-disable react/no-unescaped-entities */
import * as React from 'react';
import { useMap } from '../..';

export const Example: React.FC = () => {
const map = useMap<string, string | Date>([['@react-hooks', 'is awesome']]);

return (
<div>
<button onClick={() => map.set('@react-hooks', 'is awesome')}>set '@react-hooks'</button>
<button onClick={() => map.delete('@react-hooks')} disabled={!map.has('@react-hooks')}>
remove '@react-hooks'
</button>
<button onClick={() => map.set('current date', new Date())}>set 'current date'</button>
<button onClick={() => map.delete('current date')} disabled={!map.has('current date')}>
remove 'current date'
</button>
<br />
<pre>{JSON.stringify(Array.from(map), null, 2)}</pre>
</div>
);
};
35 changes: 35 additions & 0 deletions src/useMap/__docs__/story.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { Canvas, Meta, Story } from '@storybook/addon-docs/blocks';
import { Example } from './example.stories';

<Meta title="State/useMap" component={Example} />

# useMap

Tracks the state of a `Map`.

- Returned map does not change between renders.
- 1-to-1 signature with common `Map` object, but it's 'changing' methods are wrapped, to cause
component rerender.
Otherwise, it is a regular `Map`.
- SSR-friendly.

#### Example

<Canvas>
<Story story={Example} inline />
</Canvas>

## Reference

```ts
export function useMap<K = any, V = any>(entries?: readonly (readonly [K, V])[] | null): Map<K, V>;
```

#### Arguments

- **entries** _`readonly (readonly [K, V])[] | null`_ - Initial entries iterator for underlying
`Map` constructor.

#### Return

- `Map` instance.
79 changes: 79 additions & 0 deletions src/useMap/__tests__/dom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { renderHook, act } from '@testing-library/react-hooks/dom';
import { useMap } from '../..';

describe('useMap', () => {
it('should be defined', () => {
expect(useMap).toBeDefined();
});

it('should render', () => {
const { result } = renderHook(() => useMap());
expect(result.error).toBeUndefined();
});

it('should return a Map instance with altered add, clear and delete methods', () => {
const { result } = renderHook(() => useMap());
expect(result.current).toBeInstanceOf(Map);
expect(result.current.set).not.toBe(Map.prototype.set);
expect(result.current.clear).not.toBe(Map.prototype.clear);
expect(result.current.delete).not.toBe(Map.prototype.delete);
});

it('should accept initial values', () => {
const { result } = renderHook(() =>
useMap([
['foo', 1],
['bar', 2],
['baz', 3],
])
);
expect(result.current.get('foo')).toBe(1);
expect(result.current.get('bar')).toBe(2);
expect(result.current.get('baz')).toBe(3);
expect(result.current.size).toBe(3);
});

it('`set` should invoke original method and rerender component', () => {
const spy = jest.spyOn(Map.prototype, 'set');
let i = 0;
const { result } = renderHook(() => [++i, useMap()] as const);

act(() => {
expect(result.current[1].set('foo', 'bar')).toBe(result.current[1]);
expect(spy).toBeCalledWith('foo', 'bar');
});

expect(result.current[0]).toBe(2);

spy.mockRestore();
});

it('`clear` should invoke original method and rerender component', () => {
const spy = jest.spyOn(Map.prototype, 'clear');
let i = 0;
const { result } = renderHook(() => [++i, useMap()] as const);

act(() => {
expect(result.current[1].clear()).toBe(undefined);
});

expect(result.current[0]).toBe(2);

spy.mockRestore();
});

it('`delete` should invoke original method and rerender component', () => {
const spy = jest.spyOn(Map.prototype, 'delete');
let i = 0;
const { result } = renderHook(() => [++i, useMap([['foo', 1]])] as const);

act(() => {
expect(result.current[1].delete('foo')).toBe(true);
expect(spy).toBeCalledWith('foo');
});

expect(result.current[0]).toBe(2);

spy.mockRestore();
});
});
79 changes: 79 additions & 0 deletions src/useMap/__tests__/ssr.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { renderHook, act } from '@testing-library/react-hooks/server';
import { useMap } from '../..';

describe('useMap', () => {
it('should be defined', () => {
expect(useMap).toBeDefined();
});

it('should render', () => {
const { result } = renderHook(() => useMap());
expect(result.error).toBeUndefined();
});

it('should return a Map instance with altered set, clear and delete methods', () => {
const { result } = renderHook(() => useMap());
expect(result.current).toBeInstanceOf(Map);
expect(result.current.set).not.toBe(Map.prototype.set);
expect(result.current.clear).not.toBe(Map.prototype.clear);
expect(result.current.delete).not.toBe(Map.prototype.delete);
});

it('should accept initial values', () => {
const { result } = renderHook(() =>
useMap([
['foo', 1],
['bar', 2],
['baz', 3],
])
);
expect(result.current.get('foo')).toBe(1);
expect(result.current.get('bar')).toBe(2);
expect(result.current.get('baz')).toBe(3);
expect(result.current.size).toBe(3);
});

it('`set` should invoke original method and not rerender component', () => {
const spy = jest.spyOn(Map.prototype, 'set');
let i = 0;
const { result } = renderHook(() => [++i, useMap()] as const);

act(() => {
expect(result.current[1].set('foo', 'bar')).toBe(result.current[1]);
expect(spy).toBeCalledWith('foo', 'bar');
});

expect(result.current[0]).toBe(1);

spy.mockRestore();
});

it('`clear` should invoke original method and not rerender component', () => {
const spy = jest.spyOn(Map.prototype, 'clear');
let i = 0;
const { result } = renderHook(() => [++i, useMap()] as const);

act(() => {
expect(result.current[1].clear()).toBe(undefined);
});

expect(result.current[0]).toBe(1);

spy.mockRestore();
});

it('`delete` should invoke original method and not rerender component', () => {
const spy = jest.spyOn(Map.prototype, 'delete');
let i = 0;
const { result } = renderHook(() => [++i, useMap([['foo', 1]])] as const);

act(() => {
expect(result.current[1].delete('foo')).toBe(true);
expect(spy).toBeCalledWith('foo');
});

expect(result.current[0]).toBe(1);

spy.mockRestore();
});
});
41 changes: 41 additions & 0 deletions src/useMap/useMap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { useRef } from 'react';
import { useRerender } from '..';

const proto = Map.prototype;

/**
* Tracks the state of a `Map`.
*
* @param entries Initial entries iterator for underlying `Map` constructor.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function useMap<K = any, V = any>(entries?: readonly (readonly [K, V])[] | null): Map<K, V> {
const mapRef = useRef<Map<K, V>>();
const rerender = useRerender();

if (!mapRef.current) {
const map = new Map<K, V>(entries);

mapRef.current = map;

map.set = (...args) => {
proto.set.apply(map, args);
rerender();
return map;
};

map.clear = (...args) => {
proto.clear.apply(map, args);
rerender();
};

map.delete = (...args) => {
const res = proto.delete.apply(map, args);
rerender();

return res;
};
}

return mapRef.current as Map<K, V>;
}

0 comments on commit 523dd81

Please sign in to comment.