Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: new hook useMap #155

Merged
merged 1 commit into from
Jun 24, 2021
Merged
Changes from all 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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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)
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
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>;
}