Skip to content

Commit 1bf3225

Browse files
authored
Merge pull request #20 from yf-yang/selector
Selector
2 parents 6a02f3e + 3be119f commit 1bf3225

File tree

7 files changed

+504
-35
lines changed

7 files changed

+504
-35
lines changed

.changeset/silent-bugs-breathe.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'jotai-x': minor
3+
---
4+
5+
Add alternative selector and equalityFn support to `useValue`

README.md

+43-2
Original file line numberDiff line numberDiff line change
@@ -59,20 +59,36 @@ The **`options`** object can include several properties to customize the behavio
5959
- **`delay`**: If you need to introduce a delay in state updates, you can specify it here. Optional.
6060
- **`effect`**: A React component that can be used to run effects inside the provider. Optional.
6161
- **`extend`**: Extend the store with derived atoms based on the store state. Optional.
62+
- **`infiniteRenderDetectionLimit`**: In non production mode, it will throw an error if the number of `useValue` hook calls exceeds this limit during the same render. Optional.
6263
6364
#### Return Value
6465
6566
The **`createAtomStore`** function returns an object (**`AtomStoreApi`**) containing the following properties and methods for interacting with the store:
6667
6768
- **`use<Name>Store`**:
6869
- A function that returns the following objects: **`useValue`**, **`useSet`**, **`useState`**, where values are hooks for each state defined in the store, and **`get`**, **`set`**, **`subscribe`**, **`store`**, where values are direct get/set accessors to modify each state.
69-
- **`useValue`**: Hooks for accessing a state within a component, ensuring re-rendering when the state changes. See [useAtomValue](https://jotai.org/docs/core/use-atom#useatomvalue).
70+
- **`useValue`**: Hooks for accessing a state within a component, ensuring re-rendering when the state changes. See [useAtomValue](https://jotai.org/docs/core/use-atom#useatomvalue).
7071
``` js
7172
const store = useElementStore();
7273
const element = store.useElementValue();
7374
// alternative
7475
const element = useElementStore().useValue('element');
7576
```
77+
- Advanced: `useValue` supports parameters `selector`, which is a function that takes the current value and returns a new value and parameter `equalityFn`, which is a function that compares the previous and new values and only re-renders if they are not equal. Internally, it uses [selectAtom](https://jotai.org/docs/utilities/select#selectatom). You must memoize `selector`/`equalityFn` adequately.
78+
``` js
79+
const store = useElementStore();
80+
81+
// Memoize the selector yourself
82+
const toUpperCase = useCallback((element) => element.toUpperCase(), []);
83+
// Now it will only re-render if the uppercase value changes
84+
const element = store.useElementValue(toUpperCase);
85+
// alternative
86+
const element = useElementStore().useValue('element', toUpperCase);
87+
88+
// Pass an dependency array to prevent re-renders
89+
const [n, setN] = useState(0); // n may change during re-renders
90+
const numNthCharacter = useCallback((element) => element[n], [n]);
91+
```
7692
- **`useSet`**: Hooks for setting a state within a component. See [useSetAtom](https://jotai.org/docs/core/use-atom#usesetatom).
7793
``` js
7894
const store = useElementStore();
@@ -287,7 +303,32 @@ const Component = () => {
287303
};
288304
```
289305

290-
## Migrate from v1 to v2
306+
## **Troubleshooting**
307+
### Error: use<Key>Value/useValue has rendered `num` times in the same render
308+
When calling `use<Key>Value` or `useValue` with `selector` and `equalityFn`, those two functions must be memoized. Otherwise, the component will re-render infinitely. In order to prevent developers from making this mistake, in non-production mode (`process.env.NODE_ENV !== 'production'`), we will throw an error if the number of `useValue` hook calls exceeds a certain limit.
309+
310+
Usually, this error is caused by not memoizing the `selector` or `equalityFn` functions. To fix this, you can use `useCallback` to memoize the functions, or pass a dependency array yourselves. We support multiple alternatives:
311+
312+
```tsx
313+
// No selector at all
314+
useValue('key')
315+
316+
// Memoize with useCallback yourself
317+
const memoizedSelector = useCallback(selector, [...]);
318+
const memoizedEqualityFn = useCallback(equalityFn, [...]);
319+
// memoizedEqualityFn is an optional parameter
320+
useValue('key', memoizedSelector, memoizedEqualityFn);
321+
322+
// Provide selector and its deps
323+
useValue('key', selector, [...]);
324+
325+
// Provide selector and equalityFn and all of their deps
326+
useValue('key', selector, equalityFn, [...]);
327+
```
328+
329+
The error could also be a false positive, since the internal counter is shared across all `useValue` calls of the same store. If your component tree is very deep and uses the same store's `useValue` multiple times, then the limit could be reached. To deal with that, `createAtomStore` supports an optional parameter `infiniteRenderDetectionLimit`. You can configure that with a higher limit.
330+
331+
## **Migrate from v1 to v2**
291332

292333
1. Return of `use<Name>Store`: `get` is renamed to `use<Key>Value`, `set` is renamed to `useSet<Key>`, `use` is renamed to `useState`.
293334
``` diff

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@
102102
"turbo": "^1.11.0",
103103
"turbowatch": "2.29.4",
104104
"typedoc": "^0.25.4",
105-
"typescript": "5.3.3"
105+
"typescript": "5.7.3"
106106
},
107107
"packageManager": "[email protected]",
108108
"engines": {

packages/jotai-x/README.md

+43-2
Original file line numberDiff line numberDiff line change
@@ -59,20 +59,36 @@ The **`options`** object can include several properties to customize the behavio
5959
- **`delay`**: If you need to introduce a delay in state updates, you can specify it here. Optional.
6060
- **`effect`**: A React component that can be used to run effects inside the provider. Optional.
6161
- **`extend`**: Extend the store with derived atoms based on the store state. Optional.
62+
- **`infiniteRenderDetectionLimit`**: In non production mode, it will throw an error if the number of `useValue` hook calls exceeds this limit during the same render. Optional.
6263
6364
#### Return Value
6465
6566
The **`createAtomStore`** function returns an object (**`AtomStoreApi`**) containing the following properties and methods for interacting with the store:
6667
6768
- **`use<Name>Store`**:
6869
- A function that returns the following objects: **`useValue`**, **`useSet`**, **`useState`**, where values are hooks for each state defined in the store, and **`get`**, **`set`**, **`subscribe`**, **`store`**, where values are direct get/set accessors to modify each state.
69-
- **`useValue`**: Hooks for accessing a state within a component, ensuring re-rendering when the state changes. See [useAtomValue](https://jotai.org/docs/core/use-atom#useatomvalue).
70+
- **`useValue`**: Hooks for accessing a state within a component, ensuring re-rendering when the state changes. See [useAtomValue](https://jotai.org/docs/core/use-atom#useatomvalue).
7071
``` js
7172
const store = useElementStore();
7273
const element = store.useElementValue();
7374
// alternative
7475
const element = useElementStore().useValue('element');
7576
```
77+
- Advanced: `useValue` supports parameters `selector`, which is a function that takes the current value and returns a new value and parameter `equalityFn`, which is a function that compares the previous and new values and only re-renders if they are not equal. Internally, it uses [selectAtom](https://jotai.org/docs/utilities/select#selectatom). You must memoize `selector`/`equalityFn` adequately.
78+
``` js
79+
const store = useElementStore();
80+
81+
// Memoize the selector yourself
82+
const toUpperCase = useCallback((element) => element.toUpperCase(), []);
83+
// Now it will only re-render if the uppercase value changes
84+
const element = store.useElementValue(toUpperCase);
85+
// alternative
86+
const element = useElementStore().useValue('element', toUpperCase);
87+
88+
// Pass an dependency array to prevent re-renders
89+
const [n, setN] = useState(0); // n may change during re-renders
90+
const numNthCharacter = useCallback((element) => element[n], [n]);
91+
```
7692
- **`useSet`**: Hooks for setting a state within a component. See [useSetAtom](https://jotai.org/docs/core/use-atom#usesetatom).
7793
``` js
7894
const store = useElementStore();
@@ -287,7 +303,32 @@ const Component = () => {
287303
};
288304
```
289305

290-
## Migrate from v1 to v2
306+
## **Troubleshooting**
307+
### Error: use<Key>Value/useValue has rendered `num` times in the same render
308+
When calling `use<Key>Value` or `useValue` with `selector` and `equalityFn`, those two functions must be memoized. Otherwise, the component will re-render infinitely. In order to prevent developers from making this mistake, in non-production mode (`process.env.NODE_ENV !== 'production'`), we will throw an error if the number of `useValue` hook calls exceeds a certain limit.
309+
310+
Usually, this error is caused by not memoizing the `selector` or `equalityFn` functions. To fix this, you can use `useCallback` to memoize the functions, or pass a dependency array yourselves. We support multiple alternatives:
311+
312+
```tsx
313+
// No selector at all
314+
useValue('key')
315+
316+
// Memoize with useCallback yourself
317+
const memoizedSelector = useCallback(selector, [...]);
318+
const memoizedEqualityFn = useCallback(equalityFn, [...]);
319+
// memoizedEqualityFn is an optional parameter
320+
useValue('key', memoizedSelector, memoizedEqualityFn);
321+
322+
// Provide selector and its deps
323+
useValue('key', selector, [...]);
324+
325+
// Provide selector and equalityFn and all of their deps
326+
useValue('key', selector, equalityFn, [...]);
327+
```
328+
329+
The error could also be a false positive, since the internal counter is shared across all `useValue` calls of the same store. If your component tree is very deep and uses the same store's `useValue` multiple times, then the limit could be reached. To deal with that, `createAtomStore` supports an optional parameter `infiniteRenderDetectionLimit`. You can configure that with a higher limit.
330+
331+
## **Migrate from v1 to v2**
291332

292333
1. Return of `use<Name>Store`: `get` is renamed to `use<Key>Value`, `set` is renamed to `useSet<Key>`, `use` is renamed to `useState`.
293334
``` diff

packages/jotai-x/src/createAtomStore.spec.tsx

+212-1
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,224 @@
11
import '@testing-library/jest-dom';
22

3-
import React from 'react';
3+
import React, { useCallback } from 'react';
44
import { act, queryByText, render, renderHook } from '@testing-library/react';
55
import { atom, PrimitiveAtom, useAtomValue } from 'jotai';
66
import { splitAtom } from 'jotai/utils';
77

88
import { createAtomStore } from './createAtomStore';
99

1010
describe('createAtomStore', () => {
11+
describe('no unnecessary rerender', () => {
12+
type MyTestStoreValue = {
13+
num: number;
14+
arr: string[];
15+
};
16+
17+
const INITIAL_NUM = 0;
18+
const INITIAL_ARR = ['alice', 'bob'];
19+
20+
const initialTestStoreValue: MyTestStoreValue = {
21+
num: INITIAL_NUM,
22+
arr: INITIAL_ARR,
23+
};
24+
25+
const { useMyTestStoreStore, MyTestStoreProvider } = createAtomStore(
26+
initialTestStoreValue,
27+
{ name: 'myTestStore' as const }
28+
);
29+
30+
let numRenderCount = 0;
31+
const NumRenderer = () => {
32+
numRenderCount += 1;
33+
const num = useMyTestStoreStore().useNumValue();
34+
return <div>{num}</div>;
35+
};
36+
37+
let arrRenderCount = 0;
38+
const ArrRenderer = () => {
39+
arrRenderCount += 1;
40+
const arr = useMyTestStoreStore().useArrValue();
41+
return <div>{`[${arr.join(', ')}]`}</div>;
42+
};
43+
44+
let arrRendererWithShallowRenderCount = 0;
45+
const ArrRendererWithShallow = () => {
46+
arrRendererWithShallowRenderCount += 1;
47+
const equalityFn = useCallback((a: string[], b: string[]) => {
48+
if (a.length !== b.length) return false;
49+
for (let i = 0; i < a.length; i += 1) {
50+
if (a[i] !== b[i]) return false;
51+
}
52+
return true;
53+
}, []);
54+
const arr = useMyTestStoreStore().useArrValue(undefined, equalityFn);
55+
return <div>{`[${arr.join(', ')}]`}</div>;
56+
};
57+
58+
let arr0RenderCount = 0;
59+
const Arr0Renderer = () => {
60+
arr0RenderCount += 1;
61+
const select0 = useCallback((v: string[]) => v[0], []);
62+
const arr0 = useMyTestStoreStore().useArrValue(select0);
63+
return <div>{arr0}</div>;
64+
};
65+
66+
let arr1RenderCount = 0;
67+
const Arr1Renderer = () => {
68+
arr1RenderCount += 1;
69+
const select1 = useCallback((v: string[]) => v[1], []);
70+
const arr1 = useMyTestStoreStore().useArrValue(select1);
71+
return <div>{arr1}</div>;
72+
};
73+
74+
let arrNumRenderCount = 0;
75+
const ArrNumRenderer = () => {
76+
arrNumRenderCount += 1;
77+
const store = useMyTestStoreStore();
78+
const num = store.useNumValue();
79+
const selectArrNum = useCallback((v: string[]) => v[num], [num]);
80+
const arrNum = store.useArrValue(selectArrNum);
81+
return (
82+
<div>
83+
<div>arrNum: {arrNum}</div>
84+
</div>
85+
);
86+
};
87+
88+
let arrNumRenderWithDepsCount = 0;
89+
const ArrNumRendererWithDeps = () => {
90+
arrNumRenderWithDepsCount += 1;
91+
const store = useMyTestStoreStore();
92+
const num = store.useNumValue();
93+
const arrNum = store.useArrValue((v) => v[num], [num]);
94+
return (
95+
<div>
96+
<div>arrNumWithDeps: {arrNum}</div>
97+
</div>
98+
);
99+
};
100+
101+
const BadSelectorRenderer = () => {
102+
const arr0 = useMyTestStoreStore().useArrValue((v) => v[0]);
103+
return <div>{arr0}</div>;
104+
};
105+
106+
const Buttons = () => {
107+
const store = useMyTestStoreStore();
108+
return (
109+
<div>
110+
<button
111+
type="button"
112+
onClick={() => store.setNum(store.getNum() + 1)}
113+
>
114+
increment
115+
</button>
116+
<button
117+
type="button"
118+
onClick={() => store.setArr([...store.getArr(), 'charlie'])}
119+
>
120+
add one name
121+
</button>
122+
<button
123+
type="button"
124+
onClick={() => store.setArr([...store.getArr()])}
125+
>
126+
copy array
127+
</button>
128+
<button
129+
type="button"
130+
onClick={() => {
131+
store.setArr(['ava', ...store.getArr().slice(1)]);
132+
store.setNum(0);
133+
}}
134+
>
135+
modify arr0
136+
</button>
137+
</div>
138+
);
139+
};
140+
141+
it('does not rerender when unrelated state changes', () => {
142+
const { getByText } = render(
143+
<MyTestStoreProvider>
144+
<NumRenderer />
145+
<ArrRenderer />
146+
<ArrRendererWithShallow />
147+
<Arr0Renderer />
148+
<Arr1Renderer />
149+
<ArrNumRenderer />
150+
<ArrNumRendererWithDeps />
151+
<Buttons />
152+
</MyTestStoreProvider>
153+
);
154+
155+
// Why it's 2, not 1? Is React StrictMode causing this?
156+
expect(numRenderCount).toBe(2);
157+
expect(arrRenderCount).toBe(2);
158+
expect(arrRendererWithShallowRenderCount).toBe(2);
159+
expect(arr0RenderCount).toBe(2);
160+
expect(arr1RenderCount).toBe(2);
161+
expect(arrNumRenderCount).toBe(2);
162+
expect(arrNumRenderWithDepsCount).toBe(2);
163+
expect(getByText('arrNum: alice')).toBeInTheDocument();
164+
expect(getByText('arrNumWithDeps: alice')).toBeInTheDocument();
165+
166+
act(() => getByText('increment').click());
167+
expect(numRenderCount).toBe(3);
168+
expect(arrRenderCount).toBe(2);
169+
expect(arrRendererWithShallowRenderCount).toBe(2);
170+
expect(arr0RenderCount).toBe(2);
171+
expect(arr1RenderCount).toBe(2);
172+
expect(arrNumRenderCount).toBe(5);
173+
expect(arrNumRenderWithDepsCount).toBe(5);
174+
expect(getByText('arrNum: bob')).toBeInTheDocument();
175+
expect(getByText('arrNumWithDeps: bob')).toBeInTheDocument();
176+
177+
act(() => getByText('add one name').click());
178+
expect(numRenderCount).toBe(3);
179+
expect(arrRenderCount).toBe(3);
180+
expect(arrRendererWithShallowRenderCount).toBe(3);
181+
expect(arr0RenderCount).toBe(2);
182+
expect(arr1RenderCount).toBe(2);
183+
expect(arrNumRenderCount).toBe(5);
184+
expect(arrNumRenderWithDepsCount).toBe(5);
185+
expect(getByText('arrNum: bob')).toBeInTheDocument();
186+
expect(getByText('arrNumWithDeps: bob')).toBeInTheDocument();
187+
188+
act(() => getByText('copy array').click());
189+
expect(numRenderCount).toBe(3);
190+
expect(arrRenderCount).toBe(4);
191+
expect(arrRendererWithShallowRenderCount).toBe(3);
192+
expect(arr0RenderCount).toBe(2);
193+
expect(arr1RenderCount).toBe(2);
194+
expect(arrNumRenderCount).toBe(5);
195+
expect(arrNumRenderWithDepsCount).toBe(5);
196+
expect(getByText('arrNum: bob')).toBeInTheDocument();
197+
expect(getByText('arrNumWithDeps: bob')).toBeInTheDocument();
198+
199+
act(() => getByText('modify arr0').click());
200+
expect(numRenderCount).toBe(4);
201+
expect(arrRenderCount).toBe(5);
202+
expect(arrRendererWithShallowRenderCount).toBe(4);
203+
expect(arr0RenderCount).toBe(3);
204+
expect(arr1RenderCount).toBe(2);
205+
expect(arrNumRenderCount).toBe(8);
206+
expect(arrNumRenderWithDepsCount).toBe(8);
207+
expect(getByText('arrNum: ava')).toBeInTheDocument();
208+
expect(getByText('arrNumWithDeps: ava')).toBeInTheDocument();
209+
});
210+
211+
it('Throw error if user does not memoize selector', () => {
212+
expect(() =>
213+
render(
214+
<MyTestStoreProvider>
215+
<BadSelectorRenderer />
216+
</MyTestStoreProvider>
217+
)
218+
).toThrow();
219+
});
220+
});
221+
11222
describe('single provider', () => {
12223
type MyTestStoreValue = {
13224
name: string;

0 commit comments

Comments
 (0)