Skip to content

Commit f8aeecb

Browse files
authored
feat(brownie): add selector and equality fn support to useStore (#175)
* feat(brownie): add selector and equality fn support to useBrownieStore * refactor(brownie): simplify useStore, drop external deps - rename useBrownieStore -> useStore - use React's useSyncExternalStore instead of shim - remove shallow equality fn (zustand-style API) - replace ts-jest with babel-jest
1 parent b93ef12 commit f8aeecb

8 files changed

Lines changed: 121 additions & 94 deletions

File tree

apps/TesterIntegrated/App.tsx

Lines changed: 5 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 { useStore } from '@callstack/brownie';
44
import {
55
createNativeStackNavigator,
66
type NativeStackScreenProps,
@@ -34,7 +34,8 @@ 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] = useStore('BrownfieldStore', (s) => s.counter);
38+
const [user] = useStore('BrownfieldStore', (s) => s.user);
3839

3940
useEffect(() => {
4041
const unsubscribe = navigation.addListener('focus', () => {
@@ -51,12 +52,12 @@ function HomeScreen({ navigation, route }: HomeScreenProps) {
5152
</Text>
5253

5354
<Text style={[styles.text, { color: colors.secondary }]}>
54-
Count: {state.counter}
55+
Count: {counter}
5556
</Text>
5657

5758
<TextInput
5859
style={styles.input}
59-
value={state.user.name}
60+
value={user.name}
6061
onChangeText={(text) =>
6162
setState((prev) => ({ user: { ...prev.user, name: text } }))
6263
}

packages/brownie/ArchitectureOverview.md

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -308,8 +308,8 @@ packages/brownie/
308308
### React Hook
309309

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

314314
// Read state
315315
console.log(state.counter);
@@ -321,6 +321,26 @@ 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 { useStore } from '@callstack/brownie';
330+
331+
// Select primitive - re-renders only when counter changes
332+
const [counter, setState] = useStore('BrownfieldStore', (s) => s.counter);
333+
334+
// Select object
335+
const [user, setState] = useStore('BrownfieldStore', (s) => s.user);
336+
```
337+
338+
**How it works:**
339+
340+
1. Native side notifies JS on every state change (full state)
341+
2. Each `useStore` subscribes to full state but extracts only what it needs via selector
342+
3. React's `useSyncExternalStore` compares previous vs new selected value - skips re-render if equal
343+
324344
### Core Functions
325345

326346
```ts

packages/brownie/jest.config.js

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,45 @@
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+
testEnvironment: 'node',
7+
testMatch: ['<rootDir>/scripts/**/*.test.ts'],
8+
moduleFileExtensions: ['ts', 'js'],
9+
clearMocks: true,
10+
transform: {
11+
'^.+\\.ts$': [
12+
'babel-jest',
13+
{
14+
presets: [
15+
['@babel/preset-env', { targets: { node: 'current' } }],
16+
'@babel/preset-typescript',
17+
],
18+
},
19+
],
1320
},
14-
],
15-
},
21+
},
22+
{
23+
displayName: 'src',
24+
testEnvironment: 'node',
25+
testMatch: ['<rootDir>/src/**/*.test.ts'],
26+
moduleFileExtensions: ['ts', 'js'],
27+
clearMocks: true,
28+
moduleNameMapper: {
29+
'^./NativeBrownieModule$':
30+
'<rootDir>/src/__tests__/__mocks__/NativeBrownieModule.ts',
31+
},
32+
transform: {
33+
'^.+\\.ts$': [
34+
'babel-jest',
35+
{
36+
presets: [
37+
['@babel/preset-env', { targets: { node: 'current' } }],
38+
'@babel/preset-typescript',
39+
],
40+
},
41+
],
42+
},
43+
},
44+
],
1645
};

packages/brownie/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@
6868
"devDependencies": {
6969
"@babel/core": "^7.25.2",
7070
"@babel/preset-env": "^7.25.3",
71+
"@babel/preset-typescript": "^7.27.1",
7172
"@babel/runtime": "^7.25.0",
7273
"@react-native/babel-preset": "0.82.1",
7374
"@react-native/eslint-config": "0.82.1",
@@ -78,7 +79,6 @@
7879
"react": "19.1.1",
7980
"react-native": "0.82.1",
8081
"react-native-builder-bob": "^0.40.14",
81-
"ts-jest": "^29.2.5",
8282
"typescript": "5.8.3"
8383
},
8484
"codegenConfig": {
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: 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: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback, useSyncExternalStore } from 'react';
1+
import { useCallback, useDebugValue, useSyncExternalStore } from 'react';
22
import BrownieModule from './NativeBrownieModule';
33

44
/**
@@ -95,23 +95,54 @@ export function setState<K extends keyof BrownieStores>(
9595
}
9696
}
9797

98+
const identity = <T>(x: T): T => x;
99+
98100
/**
99-
* React hook for subscribing to a native store.
101+
* React hook for subscribing to a native store with optional selector.
102+
* Inspired by Zustand's useStore implementation.
100103
* @param key Store key registered in StoreManager
101104
* @returns Tuple of [state, setState] for the store
102105
*/
103-
export function useBrownieStore<K extends keyof BrownieStores>(
106+
export function useStore<K extends keyof BrownieStores>(
104107
key: K
105-
): [BrownieStores[K], (action: SetStateAction<BrownieStores[K]>) => void] {
108+
): [BrownieStores[K], (action: SetStateAction<BrownieStores[K]>) => void];
109+
110+
/**
111+
* React hook for subscribing to a native store with selector.
112+
* Inspired by Zustand's useStore implementation.
113+
* @param key Store key registered in StoreManager
114+
* @param selector Function to select a slice of state
115+
* @returns Tuple of [selectedState, setState] for the store
116+
*/
117+
export function useStore<K extends keyof BrownieStores, U>(
118+
key: K,
119+
selector: (state: BrownieStores[K]) => U
120+
): [U, (action: SetStateAction<BrownieStores[K]>) => void];
121+
122+
export function useStore<K extends keyof BrownieStores, U>(
123+
key: K,
124+
selector?: (state: BrownieStores[K]) => U
125+
): [U | BrownieStores[K], (action: SetStateAction<BrownieStores[K]>) => void] {
106126
const sub = useCallback(
107127
(listener: () => void) => subscribe(key, listener),
108128
[key]
109129
);
110-
const snap = useCallback(() => getSnapshot(key), [key]);
111-
const state = useSyncExternalStore(sub, snap, snap);
130+
const snap = useCallback(
131+
() =>
132+
(selector ?? (identity as (state: BrownieStores[K]) => U))(
133+
getSnapshot(key)
134+
),
135+
[key, selector]
136+
);
137+
138+
const slice = useSyncExternalStore(sub, snap, snap);
139+
140+
useDebugValue(slice);
141+
112142
const boundSetState = useCallback(
113143
(action: SetStateAction<BrownieStores[K]>) => setState(key, action),
114144
[key]
115145
);
116-
return [state, boundSetState];
146+
147+
return [slice, boundSetState];
117148
}

yarn.lock

Lines changed: 5 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1458,7 +1458,7 @@ __metadata:
14581458
languageName: node
14591459
linkType: hard
14601460

1461-
"@babel/preset-typescript@npm:^7.24.7":
1461+
"@babel/preset-typescript@npm:^7.24.7, @babel/preset-typescript@npm:^7.27.1":
14621462
version: 7.28.5
14631463
resolution: "@babel/preset-typescript@npm:7.28.5"
14641464
dependencies:
@@ -1601,6 +1601,7 @@ __metadata:
16011601
dependencies:
16021602
"@babel/core": "npm:^7.25.2"
16031603
"@babel/preset-env": "npm:^7.25.3"
1604+
"@babel/preset-typescript": "npm:^7.27.1"
16041605
"@babel/runtime": "npm:^7.25.0"
16051606
"@react-native/babel-preset": "npm:0.82.1"
16061607
"@react-native/eslint-config": "npm:0.82.1"
@@ -1613,7 +1614,6 @@ __metadata:
16131614
react: "npm:19.1.1"
16141615
react-native: "npm:0.82.1"
16151616
react-native-builder-bob: "npm:^0.40.14"
1616-
ts-jest: "npm:^29.2.5"
16171617
ts-morph: "npm:^25.0.0"
16181618
typescript: "npm:5.8.3"
16191619
peerDependencies:
@@ -5388,15 +5388,6 @@ __metadata:
53885388
languageName: node
53895389
linkType: hard
53905390

5391-
"bs-logger@npm:^0.2.6":
5392-
version: 0.2.6
5393-
resolution: "bs-logger@npm:0.2.6"
5394-
dependencies:
5395-
fast-json-stable-stringify: "npm:2.x"
5396-
checksum: 10/e6d3ff82698bb3f20ce64fb85355c5716a3cf267f3977abe93bf9c32a2e46186b253f48a028ae5b96ab42bacd2c826766d9ae8cf6892f9b944656be9113cf212
5397-
languageName: node
5398-
linkType: hard
5399-
54005391
"bser@npm:2.1.1":
54015392
version: 2.1.1
54025393
resolution: "bser@npm:2.1.1"
@@ -7529,7 +7520,7 @@ __metadata:
75297520
languageName: node
75307521
linkType: hard
75317522

7532-
"fast-json-stable-stringify@npm:2.x, fast-json-stable-stringify@npm:^2.0.0, fast-json-stable-stringify@npm:^2.1.0":
7523+
"fast-json-stable-stringify@npm:^2.0.0, fast-json-stable-stringify@npm:^2.1.0":
75337524
version: 2.1.0
75347525
resolution: "fast-json-stable-stringify@npm:2.1.0"
75357526
checksum: 10/2c20055c1fa43c922428f16ca8bb29f2807de63e5c851f665f7ac9790176c01c3b40335257736b299764a8d383388dabc73c8083b8e1bc3d99f0a941444ec60e
@@ -8153,7 +8144,7 @@ __metadata:
81538144
languageName: node
81548145
linkType: hard
81558146

8156-
"handlebars@npm:^4.7.7, handlebars@npm:^4.7.8":
8147+
"handlebars@npm:^4.7.7":
81578148
version: 4.7.8
81588149
resolution: "handlebars@npm:4.7.8"
81598150
dependencies:
@@ -10358,13 +10349,6 @@ __metadata:
1035810349
languageName: node
1035910350
linkType: hard
1036010351

10361-
"lodash.memoize@npm:^4.1.2":
10362-
version: 4.1.2
10363-
resolution: "lodash.memoize@npm:4.1.2"
10364-
checksum: 10/192b2168f310c86f303580b53acf81ab029761b9bd9caa9506a019ffea5f3363ea98d7e39e7e11e6b9917066c9d36a09a11f6fe16f812326390d8f3a54a1a6da
10365-
languageName: node
10366-
linkType: hard
10367-
1036810352
"lodash.merge@npm:4.6.2, lodash.merge@npm:^4.6.2":
1036910353
version: 4.6.2
1037010354
resolution: "lodash.merge@npm:4.6.2"
@@ -10525,7 +10509,7 @@ __metadata:
1052510509
languageName: node
1052610510
linkType: hard
1052710511

10528-
"make-error@npm:^1.1.1, make-error@npm:^1.3.6":
10512+
"make-error@npm:^1.1.1":
1052910513
version: 1.3.6
1053010514
resolution: "make-error@npm:1.3.6"
1053110515
checksum: 10/b86e5e0e25f7f777b77fabd8e2cbf15737972869d852a22b7e73c17623928fccb826d8e46b9951501d3f20e51ad74ba8c59ed584f610526a48f8ccf88aaec402
@@ -14643,46 +14627,6 @@ __metadata:
1464314627
languageName: node
1464414628
linkType: hard
1464514629

14646-
"ts-jest@npm:^29.2.5":
14647-
version: 29.4.6
14648-
resolution: "ts-jest@npm:29.4.6"
14649-
dependencies:
14650-
bs-logger: "npm:^0.2.6"
14651-
fast-json-stable-stringify: "npm:^2.1.0"
14652-
handlebars: "npm:^4.7.8"
14653-
json5: "npm:^2.2.3"
14654-
lodash.memoize: "npm:^4.1.2"
14655-
make-error: "npm:^1.3.6"
14656-
semver: "npm:^7.7.3"
14657-
type-fest: "npm:^4.41.0"
14658-
yargs-parser: "npm:^21.1.1"
14659-
peerDependencies:
14660-
"@babel/core": ">=7.0.0-beta.0 <8"
14661-
"@jest/transform": ^29.0.0 || ^30.0.0
14662-
"@jest/types": ^29.0.0 || ^30.0.0
14663-
babel-jest: ^29.0.0 || ^30.0.0
14664-
jest: ^29.0.0 || ^30.0.0
14665-
jest-util: ^29.0.0 || ^30.0.0
14666-
typescript: ">=4.3 <6"
14667-
peerDependenciesMeta:
14668-
"@babel/core":
14669-
optional: true
14670-
"@jest/transform":
14671-
optional: true
14672-
"@jest/types":
14673-
optional: true
14674-
babel-jest:
14675-
optional: true
14676-
esbuild:
14677-
optional: true
14678-
jest-util:
14679-
optional: true
14680-
bin:
14681-
ts-jest: cli.js
14682-
checksum: 10/e0ff9e13f684166d5331808b288043b8054f49a1c2970480a92ba3caec8d0ff20edd092f2a4e7a3ad8fcb9ba4d674bee10ec7ee75046d8066bbe43a7d16cf72e
14683-
languageName: node
14684-
linkType: hard
14685-
1468614630
"ts-morph@npm:^25.0.0":
1468714631
version: 25.0.1
1468814632
resolution: "ts-morph@npm:25.0.1"
@@ -14853,13 +14797,6 @@ __metadata:
1485314797
languageName: node
1485414798
linkType: hard
1485514799

14856-
"type-fest@npm:^4.41.0":
14857-
version: 4.41.0
14858-
resolution: "type-fest@npm:4.41.0"
14859-
checksum: 10/617ace794ac0893c2986912d28b3065ad1afb484cad59297835a0807dc63286c39e8675d65f7de08fafa339afcb8fe06a36e9a188b9857756ae1e92ee8bda212
14860-
languageName: node
14861-
linkType: hard
14862-
1486314800
"type-is@npm:~1.6.18":
1486414801
version: 1.6.18
1486514802
resolution: "type-is@npm:1.6.18"

0 commit comments

Comments
 (0)