Skip to content

Commit 2f2ed68

Browse files
committed
feat(brownie): add selector and equality fn support to useBrownieStore
1 parent b93ef12 commit 2f2ed68

10 files changed

Lines changed: 318 additions & 24 deletions

File tree

apps/TesterIntegrated/App.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useEffect } from 'react';
22
import { StyleSheet, Text, View, Button, TextInput } from 'react-native';
3-
import { useBrownieStore } from '@callstack/brownie';
3+
import { useBrownieStore, shallow } from '@callstack/brownie';
44
import {
55
createNativeStackNavigator,
66
type NativeStackScreenProps,
@@ -34,7 +34,11 @@ const theme = getRandomTheme();
3434

3535
function HomeScreen({ navigation, route }: HomeScreenProps) {
3636
const colors = route.params?.theme || theme;
37-
const [state, setState] = useBrownieStore('BrownfieldStore');
37+
const [counter, setState] = useBrownieStore(
38+
'BrownfieldStore',
39+
(s) => s.counter
40+
);
41+
const [user] = useBrownieStore('BrownfieldStore', (s) => s.user, shallow);
3842

3943
useEffect(() => {
4044
const unsubscribe = navigation.addListener('focus', () => {
@@ -51,12 +55,12 @@ function HomeScreen({ navigation, route }: HomeScreenProps) {
5155
</Text>
5256

5357
<Text style={[styles.text, { color: colors.secondary }]}>
54-
Count: {state.counter}
58+
Count: {counter}
5559
</Text>
5660

5761
<TextInput
5862
style={styles.input}
59-
value={state.user.name}
63+
value={user.name}
6064
onChangeText={(text) =>
6165
setState((prev) => ({ user: { ...prev.user, name: text } }))
6266
}

packages/brownie/ArchitectureOverview.md

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -308,7 +308,7 @@ packages/brownie/
308308
### React Hook
309309

310310
```ts
311-
// useState-like API for store access
311+
// Full state (useState-like API)
312312
const [state, setState] = useBrownieStore('BrownfieldStore');
313313

314314
// Read state
@@ -321,6 +321,47 @@ setState({ counter: state.counter + 1 });
321321
setState((prev) => ({ counter: prev.counter + 1 }));
322322
```
323323

324+
### Selectors
325+
326+
Selectors allow subscribing to a slice of state, reducing unnecessary re-renders:
327+
328+
```ts
329+
import { useBrownieStore, shallow } from '@callstack/brownie';
330+
331+
// Select primitive - re-renders only when counter changes
332+
const [counter, setState] = useBrownieStore(
333+
'BrownfieldStore',
334+
(s) => s.counter
335+
);
336+
337+
// Select object with shallow equality
338+
const [user, setState] = useBrownieStore(
339+
'BrownfieldStore',
340+
(s) => s.user,
341+
shallow
342+
);
343+
```
344+
345+
**How it works:**
346+
347+
1. Native side notifies JS on every state change (full state)
348+
2. Each `useBrownieStore` subscribes to full state but extracts only what it needs via selector
349+
3. `useSyncExternalStoreWithSelector` compares previous vs new selected value - skips re-render if equal
350+
351+
**Equality functions:**
352+
353+
| Equality | Use case |
354+
| ----------- | ------------------------------------------------ |
355+
| `Object.is` | Default. Works for primitives (`s => s.counter`) |
356+
| `shallow` | Objects/arrays - compares top-level props only |
357+
| Custom `fn` | Deep nesting or complex comparison logic |
358+
359+
**Why `shallow` is needed:**
360+
361+
Without it, selecting an object (`s => s.user`) returns a new reference every time (JS creates new object on each native→JS crossing), causing re-renders even if contents are identical. `shallow` compares top-level properties to detect actual changes.
362+
363+
**Limitation:** `shallow` only goes one level deep. For nested objects like `{ user: { profile: { name } } }`, if `profile` object changes identity but has same contents, shallow won't catch it. Use more granular selectors or a custom `equalityFn` for deep nesting.
364+
324365
### Core Functions
325366

326367
```ts

packages/brownie/jest.config.js

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,41 @@
11
/** @type {import('jest').Config} */
22
module.exports = {
3-
preset: 'ts-jest',
4-
testEnvironment: 'node',
5-
testMatch: ['<rootDir>/scripts/**/*.test.ts'],
6-
moduleFileExtensions: ['ts', 'js'],
7-
clearMocks: true,
8-
transform: {
9-
'^.+\\.ts$': [
10-
'ts-jest',
11-
{
12-
tsconfig: '<rootDir>/scripts/__tests__/tsconfig.json',
3+
projects: [
4+
{
5+
displayName: 'scripts',
6+
preset: 'ts-jest',
7+
testEnvironment: 'node',
8+
testMatch: ['<rootDir>/scripts/**/*.test.ts'],
9+
moduleFileExtensions: ['ts', 'js'],
10+
clearMocks: true,
11+
transform: {
12+
'^.+\\.ts$': [
13+
'ts-jest',
14+
{
15+
tsconfig: '<rootDir>/scripts/__tests__/tsconfig.json',
16+
},
17+
],
1318
},
14-
],
15-
},
19+
},
20+
{
21+
displayName: 'src',
22+
preset: 'ts-jest',
23+
testEnvironment: 'node',
24+
testMatch: ['<rootDir>/src/**/*.test.ts'],
25+
moduleFileExtensions: ['ts', 'js'],
26+
clearMocks: true,
27+
moduleNameMapper: {
28+
'^./NativeBrownieModule$':
29+
'<rootDir>/src/__tests__/__mocks__/NativeBrownieModule.ts',
30+
},
31+
transform: {
32+
'^.+\\.ts$': [
33+
'ts-jest',
34+
{
35+
tsconfig: '<rootDir>/src/__tests__/tsconfig.json',
36+
},
37+
],
38+
},
39+
},
40+
],
1641
};

packages/brownie/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,8 @@
6363
"dependencies": {
6464
"quicktype-core": "^23.0.170",
6565
"quicktype-typescript-input": "^23.0.170",
66-
"ts-morph": "^25.0.0"
66+
"ts-morph": "^25.0.0",
67+
"use-sync-external-store": "^1.4.0"
6768
},
6869
"devDependencies": {
6970
"@babel/core": "^7.25.2",
@@ -74,6 +75,7 @@
7475
"@types/jest": "^29.5.14",
7576
"@types/node": "^22.0.0",
7677
"@types/react": "^19.1.1",
78+
"@types/use-sync-external-store": "^0.0.6",
7779
"jest": "^29.7.0",
7880
"react": "19.1.1",
7981
"react-native": "0.82.1",
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default {
2+
nativeStoreDidChange: jest.fn(),
3+
};
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { shallow } from '../shallow';
2+
3+
describe('shallow', () => {
4+
it('returns true for identical primitives', () => {
5+
expect(shallow(1, 1)).toBe(true);
6+
expect(shallow('a', 'a')).toBe(true);
7+
expect(shallow(true, true)).toBe(true);
8+
expect(shallow(null, null)).toBe(true);
9+
expect(shallow(undefined, undefined)).toBe(true);
10+
});
11+
12+
it('returns false for different primitives', () => {
13+
expect(shallow(1, 2)).toBe(false);
14+
expect(shallow('a', 'b')).toBe(false);
15+
expect(shallow(true, false)).toBe(false);
16+
});
17+
18+
it('returns true for same object reference', () => {
19+
const obj = { a: 1 };
20+
expect(shallow(obj, obj)).toBe(true);
21+
});
22+
23+
it('returns true for objects with same top-level values', () => {
24+
expect(shallow({ a: 1, b: 2 }, { a: 1, b: 2 })).toBe(true);
25+
});
26+
27+
it('returns false for objects with different top-level values', () => {
28+
expect(shallow({ a: 1 }, { a: 2 })).toBe(false);
29+
expect(shallow({ a: 1 }, { a: 1, b: 2 })).toBe(false);
30+
});
31+
32+
it('does not deeply compare nested objects', () => {
33+
const nested1 = { a: { b: 1 } };
34+
const nested2 = { a: { b: 1 } };
35+
expect(shallow(nested1, nested2)).toBe(false);
36+
});
37+
38+
it('returns true for same nested object reference', () => {
39+
const inner = { b: 1 };
40+
expect(shallow({ a: inner }, { a: inner })).toBe(true);
41+
});
42+
43+
it('returns true for equal arrays', () => {
44+
expect(shallow([1, 2, 3], [1, 2, 3])).toBe(true);
45+
});
46+
47+
it('returns false for arrays with different values', () => {
48+
expect(shallow([1, 2], [1, 3])).toBe(false);
49+
expect(shallow([1, 2], [1, 2, 3])).toBe(false);
50+
});
51+
52+
it('returns true for equal Maps', () => {
53+
const mapA = new Map([
54+
['a', 1],
55+
['b', 2],
56+
]);
57+
const mapB = new Map([
58+
['a', 1],
59+
['b', 2],
60+
]);
61+
expect(shallow(mapA, mapB)).toBe(true);
62+
});
63+
64+
it('returns false for Maps with different values', () => {
65+
const mapA = new Map([['a', 1]]);
66+
const mapB = new Map([['a', 2]]);
67+
expect(shallow(mapA, mapB)).toBe(false);
68+
});
69+
70+
it('returns true for equal Sets', () => {
71+
const setA = new Set([1, 2, 3]);
72+
const setB = new Set([1, 2, 3]);
73+
expect(shallow(setA, setB)).toBe(true);
74+
});
75+
76+
it('returns false for Sets with different values', () => {
77+
const setA = new Set([1, 2]);
78+
const setB = new Set([1, 3]);
79+
expect(shallow(setA, setB)).toBe(false);
80+
});
81+
82+
it('returns false for different prototypes', () => {
83+
class A {
84+
x = 1;
85+
}
86+
class B {
87+
x = 1;
88+
}
89+
expect(shallow(new A(), new B())).toBe(false);
90+
});
91+
92+
it('returns false when comparing object to null', () => {
93+
expect(shallow({ a: 1 }, null)).toBe(false);
94+
expect(shallow(null, { a: 1 })).toBe(false);
95+
});
96+
});
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"extends": "../../../../tsconfig",
3+
"compilerOptions": {
4+
"verbatimModuleSyntax": false
5+
}
6+
}

packages/brownie/src/index.ts

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { useCallback, useSyncExternalStore } from 'react';
1+
import { useCallback, useDebugValue } from 'react';
2+
import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/shim/with-selector';
23
import BrownieModule from './NativeBrownieModule';
34

45
/**
@@ -95,23 +96,57 @@ export function setState<K extends keyof BrownieStores>(
9596
}
9697
}
9798

99+
const identity = <T>(x: T): T => x;
100+
101+
export { shallow } from './shallow';
102+
98103
/**
99-
* React hook for subscribing to a native store.
104+
* React hook for subscribing to a native store with optional selector.
100105
* @param key Store key registered in StoreManager
101106
* @returns Tuple of [state, setState] for the store
102107
*/
103108
export function useBrownieStore<K extends keyof BrownieStores>(
104109
key: K
105-
): [BrownieStores[K], (action: SetStateAction<BrownieStores[K]>) => void] {
110+
): [BrownieStores[K], (action: SetStateAction<BrownieStores[K]>) => void];
111+
112+
/**
113+
* React hook for subscribing to a native store with selector.
114+
* @param key Store key registered in StoreManager
115+
* @param selector Function to select a slice of state
116+
* @param equalityFn Optional equality function for comparing selected values
117+
* @returns Tuple of [selectedState, setState] for the store
118+
*/
119+
export function useBrownieStore<K extends keyof BrownieStores, U>(
120+
key: K,
121+
selector: (state: BrownieStores[K]) => U,
122+
equalityFn?: (a: U, b: U) => boolean
123+
): [U, (action: SetStateAction<BrownieStores[K]>) => void];
124+
125+
export function useBrownieStore<K extends keyof BrownieStores, U>(
126+
key: K,
127+
selector?: (state: BrownieStores[K]) => U,
128+
equalityFn?: (a: U, b: U) => boolean
129+
): [U | BrownieStores[K], (action: SetStateAction<BrownieStores[K]>) => void] {
106130
const sub = useCallback(
107131
(listener: () => void) => subscribe(key, listener),
108132
[key]
109133
);
110134
const snap = useCallback(() => getSnapshot(key), [key]);
111-
const state = useSyncExternalStore(sub, snap, snap);
135+
136+
const slice = useSyncExternalStoreWithSelector(
137+
sub,
138+
snap,
139+
snap,
140+
selector ?? (identity as (state: BrownieStores[K]) => U),
141+
equalityFn
142+
);
143+
144+
useDebugValue(slice);
145+
112146
const boundSetState = useCallback(
113147
(action: SetStateAction<BrownieStores[K]>) => setState(key, action),
114148
[key]
115149
);
116-
return [state, boundSetState];
150+
151+
return [slice, boundSetState];
117152
}

0 commit comments

Comments
 (0)