Skip to content

Commit

Permalink
feat: new hook useSet (#154)
Browse files Browse the repository at this point in the history
  • Loading branch information
xobotyi authored Jun 24, 2021
1 parent 3dece07 commit f1d781c
Show file tree
Hide file tree
Showing 7 changed files with 254 additions and 0 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ import { useMountEffect } from "@react-hookz/web/esnext";
— Returns the value passed to the hook on previous render.
- [**`useSafeState`**](https://react-hookz.github.io/web/?path=/docs/state-usesafestate)
— Like `useState`, but its state setter is guarded against sets on unmounted component.
- [**`useSet`**](https://react-hookz.github.io/web/?path=/docs/state-useset)
— Tracks the state of a `Set`.
- [**`useToggle`**](https://react-hookz.github.io/web/?path=/docs/state-usetoggle)
— Like `useState`, but can only become `true` or `false`.
- [**`useThrottledState`**](https://react-hookz.github.io/web/?path=/docs/state-usethrottledstate)
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export { useDebouncedState } from './useDebouncedState/useDebouncedState';
export { useMediatedState } from './useMediatedState/useMediatedState';
export { usePrevious } from './usePrevious/usePrevious';
export { useSafeState } from './useSafeState/useSafeState';
export { useSet } from './useSet/useSet';
export { useToggle } from './useToggle/useToggle';
export { useThrottledState } from './useThrottledState/useThrottledState';
export {
Expand Down
26 changes: 26 additions & 0 deletions src/useSet/__docs__/example.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/* eslint-disable react/no-unescaped-entities */
import * as React from 'react';
import { useSet } from '../..';

export const Example: React.FC = () => {
const set = useSet(['@react-hooks', 'is awesome']);

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

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

# useSet

Tracks the state of a `Set`.

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

#### Example

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

## Reference

```ts
export function useSet<T = any>(values?: readonly T[] | null): Set<T>;
```

#### Arguments

- **values** _`Iterable`_ - Initial values iterator for underlying `Set` constructor.

#### Return

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

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

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

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

it('should accept initial values', () => {
const { result } = renderHook(() => useSet([1, 2, 3]));
expect(result.current.has(1)).toBe(true);
expect(result.current.has(2)).toBe(true);
expect(result.current.has(3)).toBe(true);
expect(result.current.size).toBe(3);
});

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

await act(async () => {
expect(result.current[1].add(1)).toBe(result.current[1]);
expect(spy).toBeCalledWith(1);
await waitForNextUpdate();
});

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

spy.mockRestore();
});

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

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

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

spy.mockRestore();
});

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

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

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

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

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

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

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

it('should accept initial values', () => {
const { result } = renderHook(() => useSet([1, 2, 3]));
expect(result.current.has(1)).toBe(true);
expect(result.current.has(2)).toBe(true);
expect(result.current.has(3)).toBe(true);
expect(result.current.size).toBe(3);
});

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

act(() => {
expect(result.current[1].add(1)).toBe(result.current[1]);
expect(spy).toBeCalledWith(1);
});

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

spy.mockRestore();
});

it('`clear` should invoke original method and rerender component', () => {
const spy = jest.spyOn(Set.prototype, 'clear');
let i = 0;
const { result } = renderHook(() => [++i, useSet()] 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 rerender component', () => {
const spy = jest.spyOn(Set.prototype, 'delete');
let i = 0;
const { result } = renderHook(() => [++i, useSet([1])] as const);

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

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

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

const proto = Set.prototype;

/**
* Tracks the state of a `Set`.
*
* @param values Initial values iterator for underlying `Set` constructor.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function useSet<T = any>(values?: readonly T[] | null): Set<T> {
const setRef = useRef<Set<T>>();
const rerender = useRerender();

if (!setRef.current) {
const set = new Set<T>(values);

setRef.current = set;

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

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

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

return res;
};
}

return setRef.current as Set<T>;
}

0 comments on commit f1d781c

Please sign in to comment.