Skip to content

Commit e5f6cac

Browse files
sukvvonkimyouknow
andauthored
feat(core/hooks): add 'useSet' hook (toss#339)
Co-authored-by: 김윤호 yunho <kimyouknow@naver.com>
1 parent 278b117 commit e5f6cac

7 files changed

Lines changed: 483 additions & 0 deletions

File tree

.changeset/bright-lions-dance.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'react-simplikit': patch
3+
---
4+
5+
feat(core/hooks): add 'useSet' hook
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { useSet } from './useSet.ts';
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# useSet
2+
3+
리액트 훅으로, 상태로 Set을 관리해요. 효율적인 상태 관리를 제공하고 안정적인 액션 함수를 제공해요.
4+
5+
## 인터페이스
6+
7+
```ts
8+
function useSet<T>(
9+
initialState?: SetOrValues<T>
10+
): [Omit<Set<T>, 'add' | 'clear' | 'delete'>, SetActions<T>];
11+
```
12+
13+
### 파라미터
14+
15+
<Interface
16+
name="initialState"
17+
type="Set<T> | T[]"
18+
description="초기 Set 상태 (Set 객체 또는 값의 배열). 기본값은 빈 Set이에요."
19+
/>
20+
21+
### 반환 값
22+
23+
<Interface
24+
name=""
25+
type="[Set, SetActions]"
26+
description="Set 상태와 이를 조작하는 액션을 포함한 튜플이에요."
27+
:nested="[
28+
{
29+
name: '[0]',
30+
type: 'Omit<Set<T>, \"add\" | \"clear\" | \"delete\">',
31+
required: false,
32+
description: '변경 메서드가 숨겨진 현재 Set 상태예요.',
33+
},
34+
{
35+
name: '[1].add',
36+
type: '(value: T) => void',
37+
required: false,
38+
description: 'Set에 값을 추가해요.',
39+
},
40+
{
41+
name: '[1].remove',
42+
type: '(value: T) => void',
43+
required: false,
44+
description: 'Set에서 값을 제거해요.',
45+
},
46+
{
47+
name: '[1].toggle',
48+
type: '(value: T) => void',
49+
required: false,
50+
description: '값이 없으면 추가하고, 있으면 제거해요.',
51+
},
52+
{
53+
name: '[1].setAll',
54+
type: '(values: Set<T> | T[]) => void',
55+
required: false,
56+
description: 'Set의 모든 값을 교체해요.',
57+
},
58+
{
59+
name: '[1].reset',
60+
type: '() => void',
61+
required: false,
62+
description: 'Set을 초기 상태로 리셋해요.',
63+
},
64+
]"
65+
/>
66+
67+
## 예시
68+
69+
```tsx
70+
function TagSelector() {
71+
const [selectedTags, { add, remove, toggle }] = useSet<string>(['react']);
72+
73+
return (
74+
<div>
75+
{['react', 'vue', 'svelte'].map(tag => (
76+
<button key={tag} onClick={() => toggle(tag)}>
77+
{selectedTags.has(tag) ? '' : ''} {tag}
78+
</button>
79+
))}
80+
</div>
81+
);
82+
}
83+
```
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# useSet
2+
3+
A React hook that manages a Set as state. Provides efficient state management and stable action functions.
4+
5+
## Interface
6+
7+
```ts
8+
function useSet<T>(
9+
initialState?: SetOrValues<T>
10+
): [Omit<Set<T>, 'add' | 'clear' | 'delete'>, SetActions<T>];
11+
```
12+
13+
### Parameters
14+
15+
<Interface
16+
name="initialState"
17+
type="Set<T> | T[]"
18+
description="Initial Set state (Set object or array of values). Defaults to an empty Set."
19+
/>
20+
21+
### Return Value
22+
23+
<Interface
24+
name=""
25+
type="[Set, SetActions]"
26+
description="A tuple containing the Set state and actions to manipulate it."
27+
:nested="[
28+
{
29+
name: '[0]',
30+
type: 'Omit<Set<T>, \"add\" | \"clear\" | \"delete\">',
31+
required: false,
32+
description: 'The current Set state with mutation methods hidden.',
33+
},
34+
{
35+
name: '[1].add',
36+
type: '(value: T) => void',
37+
required: false,
38+
description: 'Adds a value to the set.',
39+
},
40+
{
41+
name: '[1].remove',
42+
type: '(value: T) => void',
43+
required: false,
44+
description: 'Removes a value from the set.',
45+
},
46+
{
47+
name: '[1].toggle',
48+
type: '(value: T) => void',
49+
required: false,
50+
description: 'Adds the value if absent, removes it if present.',
51+
},
52+
{
53+
name: '[1].setAll',
54+
type: '(values: Set<T> | T[]) => void',
55+
required: false,
56+
description: 'Replaces all values in the set.',
57+
},
58+
{
59+
name: '[1].reset',
60+
type: '() => void',
61+
required: false,
62+
description: 'Resets the set to its initial state.',
63+
},
64+
]"
65+
/>
66+
67+
## Example
68+
69+
```tsx
70+
function TagSelector() {
71+
const [selectedTags, { add, remove, toggle }] = useSet<string>(['react']);
72+
73+
return (
74+
<div>
75+
{['react', 'vue', 'svelte'].map(tag => (
76+
<button key={tag} onClick={() => toggle(tag)}>
77+
{selectedTags.has(tag) ? '' : ''} {tag}
78+
</button>
79+
))}
80+
</div>
81+
);
82+
}
83+
```
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
import { act } from '@testing-library/react';
2+
import { describe, expect, it } from 'vitest';
3+
4+
import { renderHookSSR } from '../../_internal/test-utils/renderHookSSR.tsx';
5+
6+
import { useSet } from './useSet.ts';
7+
8+
describe('useSet', () => {
9+
it('is safe on server side rendering', () => {
10+
const initialSet = new Set([1, 2, 3]);
11+
renderHookSSR.serverOnly(() => useSet(initialSet));
12+
});
13+
14+
it('should initialize with a Set', async () => {
15+
const initialSet = new Set(['a', 'b']);
16+
const { result } = await renderHookSSR(() => useSet(initialSet));
17+
18+
expect(result.current[0].has('a')).toBe(true);
19+
expect(result.current[0].has('b')).toBe(true);
20+
expect(result.current[0].size).toBe(2);
21+
});
22+
23+
it('should initialize with an array of values', async () => {
24+
const { result } = await renderHookSSR(() => useSet([1, 2, 3]));
25+
26+
expect(result.current[0].has(1)).toBe(true);
27+
expect(result.current[0].has(2)).toBe(true);
28+
expect(result.current[0].has(3)).toBe(true);
29+
expect(result.current[0].size).toBe(3);
30+
});
31+
32+
it('should initialize with an empty Set when no arguments provided', async () => {
33+
const { result } = await renderHookSSR(() => useSet());
34+
35+
expect(result.current[0].size).toBe(0);
36+
});
37+
38+
it('should add a value to the Set', async () => {
39+
const { result, rerender } = await renderHookSSR(() => useSet<string>());
40+
const [, actions] = result.current;
41+
42+
expect(result.current[0].has('a')).toBe(false);
43+
44+
await act(async () => {
45+
actions.add('a');
46+
rerender();
47+
});
48+
49+
expect(result.current[0].has('a')).toBe(true);
50+
expect(result.current[0].size).toBe(1);
51+
});
52+
53+
it('should not duplicate values when adding an existing value', async () => {
54+
const { result, rerender } = await renderHookSSR(() => useSet(['a']));
55+
const [, actions] = result.current;
56+
57+
await act(async () => {
58+
actions.add('a');
59+
rerender();
60+
});
61+
62+
expect(result.current[0].size).toBe(1);
63+
});
64+
65+
it('should remove a value from the Set', async () => {
66+
const { result, rerender } = await renderHookSSR(() => useSet(['a', 'b']));
67+
const [, actions] = result.current;
68+
69+
await act(async () => {
70+
actions.remove('a');
71+
rerender();
72+
});
73+
74+
expect(result.current[0].has('a')).toBe(false);
75+
expect(result.current[0].has('b')).toBe(true);
76+
expect(result.current[0].size).toBe(1);
77+
});
78+
79+
it('should do nothing when removing a value not in the Set', async () => {
80+
const { result, rerender } = await renderHookSSR(() => useSet(['a']));
81+
const [, actions] = result.current;
82+
83+
await act(async () => {
84+
actions.remove('z');
85+
rerender();
86+
});
87+
88+
expect(result.current[0].has('a')).toBe(true);
89+
expect(result.current[0].size).toBe(1);
90+
});
91+
92+
it('should toggle a value - add if absent', async () => {
93+
const { result, rerender } = await renderHookSSR(() => useSet<string>());
94+
const [, actions] = result.current;
95+
96+
await act(async () => {
97+
actions.toggle('a');
98+
rerender();
99+
});
100+
101+
expect(result.current[0].has('a')).toBe(true);
102+
});
103+
104+
it('should toggle a value - remove if present', async () => {
105+
const { result, rerender } = await renderHookSSR(() => useSet(['a']));
106+
const [, actions] = result.current;
107+
108+
await act(async () => {
109+
actions.toggle('a');
110+
rerender();
111+
});
112+
113+
expect(result.current[0].has('a')).toBe(false);
114+
});
115+
116+
it('should replace all values with setAll', async () => {
117+
const { result, rerender } = await renderHookSSR(() => useSet([1, 2, 3]));
118+
const [, actions] = result.current;
119+
120+
await act(async () => {
121+
actions.setAll([4, 5]);
122+
rerender();
123+
});
124+
125+
expect(result.current[0].has(1)).toBe(false);
126+
expect(result.current[0].has(4)).toBe(true);
127+
expect(result.current[0].has(5)).toBe(true);
128+
expect(result.current[0].size).toBe(2);
129+
});
130+
131+
it('should replace all values with setAll using a Set', async () => {
132+
const { result, rerender } = await renderHookSSR(() => useSet([1, 2, 3]));
133+
const [, actions] = result.current;
134+
135+
await act(async () => {
136+
actions.setAll(new Set([4, 5]));
137+
rerender();
138+
});
139+
140+
expect(result.current[0].has(1)).toBe(false);
141+
expect(result.current[0].has(4)).toBe(true);
142+
expect(result.current[0].has(5)).toBe(true);
143+
expect(result.current[0].size).toBe(2);
144+
});
145+
146+
it('should reset the Set to its initial state', async () => {
147+
const initialSet = new Set([1, 2]);
148+
const { result, rerender } = await renderHookSSR(() => useSet(initialSet));
149+
const [, actions] = result.current;
150+
151+
await act(async () => {
152+
actions.add(3);
153+
actions.remove(1);
154+
rerender();
155+
});
156+
157+
expect(result.current[0].has(1)).toBe(false);
158+
expect(result.current[0].has(3)).toBe(true);
159+
160+
await act(async () => {
161+
actions.reset();
162+
rerender();
163+
});
164+
165+
expect(result.current[0].has(1)).toBe(true);
166+
expect(result.current[0].has(2)).toBe(true);
167+
expect(result.current[0].has(3)).toBe(false);
168+
expect(result.current[0].size).toBe(2);
169+
});
170+
171+
it('should reset to empty Set when initialized with empty Set', async () => {
172+
const { result, rerender } = await renderHookSSR(() => useSet<number>());
173+
const [, actions] = result.current;
174+
175+
await act(async () => {
176+
actions.add(1);
177+
actions.add(2);
178+
rerender();
179+
});
180+
181+
expect(result.current[0].size).toBe(2);
182+
183+
await act(async () => {
184+
actions.reset();
185+
rerender();
186+
});
187+
188+
expect(result.current[0].size).toBe(0);
189+
});
190+
191+
it('should create a new Set reference when values change', async () => {
192+
const { result, rerender } = await renderHookSSR(() => useSet<number>());
193+
const [originalSetRef, actions] = result.current;
194+
195+
await act(async () => {
196+
actions.add(1);
197+
rerender();
198+
});
199+
200+
expect(originalSetRef).not.toBe(result.current[0]);
201+
expect(originalSetRef.has(1)).toBe(false);
202+
expect(result.current[0].has(1)).toBe(true);
203+
});
204+
205+
it('should maintain stable actions reference after Set changes', async () => {
206+
const { result, rerender } = await renderHookSSR(() => useSet<number>());
207+
const [, originalActionsRef] = result.current;
208+
209+
expect(result.current[1]).toBe(originalActionsRef);
210+
211+
await act(async () => {
212+
originalActionsRef.add(1);
213+
rerender();
214+
});
215+
216+
expect(result.current[1]).toBe(originalActionsRef);
217+
});
218+
});

0 commit comments

Comments
 (0)