diff --git a/apps/TesterIntegrated/App.tsx b/apps/TesterIntegrated/App.tsx
index c83b9b13..9f42ef60 100644
--- a/apps/TesterIntegrated/App.tsx
+++ b/apps/TesterIntegrated/App.tsx
@@ -1,6 +1,6 @@
import { useEffect } from 'react';
import { StyleSheet, Text, View, Button, TextInput } from 'react-native';
-import { useBrownieStore } from '@callstack/brownie';
+import { useStore } from '@callstack/brownie';
import {
createNativeStackNavigator,
type NativeStackScreenProps,
@@ -34,7 +34,8 @@ const theme = getRandomTheme();
function HomeScreen({ navigation, route }: HomeScreenProps) {
const colors = route.params?.theme || theme;
- const [state, setState] = useBrownieStore('BrownfieldStore');
+ const [counter, setState] = useStore('BrownfieldStore', (s) => s.counter);
+ const [user] = useStore('BrownfieldStore', (s) => s.user);
useEffect(() => {
const unsubscribe = navigation.addListener('focus', () => {
@@ -51,12 +52,12 @@ function HomeScreen({ navigation, route }: HomeScreenProps) {
- Count: {state.counter}
+ Count: {counter}
setState((prev) => ({ user: { ...prev.user, name: text } }))
}
diff --git a/packages/brownie/ArchitectureOverview.md b/packages/brownie/ArchitectureOverview.md
index 20c3c200..0e40ff75 100644
--- a/packages/brownie/ArchitectureOverview.md
+++ b/packages/brownie/ArchitectureOverview.md
@@ -308,8 +308,8 @@ packages/brownie/
### React Hook
```ts
-// useState-like API for store access
-const [state, setState] = useBrownieStore('BrownfieldStore');
+// Full state (useState-like API)
+const [state, setState] = useStore('BrownfieldStore');
// Read state
console.log(state.counter);
@@ -321,6 +321,26 @@ setState({ counter: state.counter + 1 });
setState((prev) => ({ counter: prev.counter + 1 }));
```
+### Selectors
+
+Selectors allow subscribing to a slice of state, reducing unnecessary re-renders:
+
+```ts
+import { useStore } from '@callstack/brownie';
+
+// Select primitive - re-renders only when counter changes
+const [counter, setState] = useStore('BrownfieldStore', (s) => s.counter);
+
+// Select object
+const [user, setState] = useStore('BrownfieldStore', (s) => s.user);
+```
+
+**How it works:**
+
+1. Native side notifies JS on every state change (full state)
+2. Each `useStore` subscribes to full state but extracts only what it needs via selector
+3. React's `useSyncExternalStore` compares previous vs new selected value - skips re-render if equal
+
### Core Functions
```ts
diff --git a/packages/brownie/jest.config.js b/packages/brownie/jest.config.js
index 1fe00600..fc17b2f0 100644
--- a/packages/brownie/jest.config.js
+++ b/packages/brownie/jest.config.js
@@ -1,16 +1,45 @@
/** @type {import('jest').Config} */
module.exports = {
- preset: 'ts-jest',
- testEnvironment: 'node',
- testMatch: ['/scripts/**/*.test.ts'],
- moduleFileExtensions: ['ts', 'js'],
- clearMocks: true,
- transform: {
- '^.+\\.ts$': [
- 'ts-jest',
- {
- tsconfig: '/scripts/__tests__/tsconfig.json',
+ projects: [
+ {
+ displayName: 'scripts',
+ testEnvironment: 'node',
+ testMatch: ['/scripts/**/*.test.ts'],
+ moduleFileExtensions: ['ts', 'js'],
+ clearMocks: true,
+ transform: {
+ '^.+\\.ts$': [
+ 'babel-jest',
+ {
+ presets: [
+ ['@babel/preset-env', { targets: { node: 'current' } }],
+ '@babel/preset-typescript',
+ ],
+ },
+ ],
},
- ],
- },
+ },
+ {
+ displayName: 'src',
+ testEnvironment: 'node',
+ testMatch: ['/src/**/*.test.ts'],
+ moduleFileExtensions: ['ts', 'js'],
+ clearMocks: true,
+ moduleNameMapper: {
+ '^./NativeBrownieModule$':
+ '/src/__tests__/__mocks__/NativeBrownieModule.ts',
+ },
+ transform: {
+ '^.+\\.ts$': [
+ 'babel-jest',
+ {
+ presets: [
+ ['@babel/preset-env', { targets: { node: 'current' } }],
+ '@babel/preset-typescript',
+ ],
+ },
+ ],
+ },
+ },
+ ],
};
diff --git a/packages/brownie/package.json b/packages/brownie/package.json
index 8bb5c953..794b01b8 100644
--- a/packages/brownie/package.json
+++ b/packages/brownie/package.json
@@ -68,6 +68,7 @@
"devDependencies": {
"@babel/core": "^7.25.2",
"@babel/preset-env": "^7.25.3",
+ "@babel/preset-typescript": "^7.27.1",
"@babel/runtime": "^7.25.0",
"@react-native/babel-preset": "0.82.1",
"@react-native/eslint-config": "0.82.1",
@@ -78,7 +79,6 @@
"react": "19.1.1",
"react-native": "0.82.1",
"react-native-builder-bob": "^0.40.14",
- "ts-jest": "^29.2.5",
"typescript": "5.8.3"
},
"codegenConfig": {
diff --git a/packages/brownie/src/__tests__/__mocks__/NativeBrownieModule.ts b/packages/brownie/src/__tests__/__mocks__/NativeBrownieModule.ts
new file mode 100644
index 00000000..6a4fba2a
--- /dev/null
+++ b/packages/brownie/src/__tests__/__mocks__/NativeBrownieModule.ts
@@ -0,0 +1,3 @@
+export default {
+ nativeStoreDidChange: jest.fn(),
+};
diff --git a/packages/brownie/src/__tests__/tsconfig.json b/packages/brownie/src/__tests__/tsconfig.json
new file mode 100644
index 00000000..b8b1c359
--- /dev/null
+++ b/packages/brownie/src/__tests__/tsconfig.json
@@ -0,0 +1,6 @@
+{
+ "extends": "../../../../tsconfig",
+ "compilerOptions": {
+ "verbatimModuleSyntax": false
+ }
+}
diff --git a/packages/brownie/src/index.ts b/packages/brownie/src/index.ts
index 6643e57d..726c1ecb 100644
--- a/packages/brownie/src/index.ts
+++ b/packages/brownie/src/index.ts
@@ -1,4 +1,4 @@
-import { useCallback, useSyncExternalStore } from 'react';
+import { useCallback, useDebugValue, useSyncExternalStore } from 'react';
import BrownieModule from './NativeBrownieModule';
/**
@@ -95,23 +95,54 @@ export function setState(
}
}
+const identity = (x: T): T => x;
+
/**
- * React hook for subscribing to a native store.
+ * React hook for subscribing to a native store with optional selector.
+ * Inspired by Zustand's useStore implementation.
* @param key Store key registered in StoreManager
* @returns Tuple of [state, setState] for the store
*/
-export function useBrownieStore(
+export function useStore(
key: K
-): [BrownieStores[K], (action: SetStateAction) => void] {
+): [BrownieStores[K], (action: SetStateAction) => void];
+
+/**
+ * React hook for subscribing to a native store with selector.
+ * Inspired by Zustand's useStore implementation.
+ * @param key Store key registered in StoreManager
+ * @param selector Function to select a slice of state
+ * @returns Tuple of [selectedState, setState] for the store
+ */
+export function useStore(
+ key: K,
+ selector: (state: BrownieStores[K]) => U
+): [U, (action: SetStateAction) => void];
+
+export function useStore(
+ key: K,
+ selector?: (state: BrownieStores[K]) => U
+): [U | BrownieStores[K], (action: SetStateAction) => void] {
const sub = useCallback(
(listener: () => void) => subscribe(key, listener),
[key]
);
- const snap = useCallback(() => getSnapshot(key), [key]);
- const state = useSyncExternalStore(sub, snap, snap);
+ const snap = useCallback(
+ () =>
+ (selector ?? (identity as (state: BrownieStores[K]) => U))(
+ getSnapshot(key)
+ ),
+ [key, selector]
+ );
+
+ const slice = useSyncExternalStore(sub, snap, snap);
+
+ useDebugValue(slice);
+
const boundSetState = useCallback(
(action: SetStateAction) => setState(key, action),
[key]
);
- return [state, boundSetState];
+
+ return [slice, boundSetState];
}
diff --git a/yarn.lock b/yarn.lock
index 70d67a06..23ae96ff 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1458,7 +1458,7 @@ __metadata:
languageName: node
linkType: hard
-"@babel/preset-typescript@npm:^7.24.7":
+"@babel/preset-typescript@npm:^7.24.7, @babel/preset-typescript@npm:^7.27.1":
version: 7.28.5
resolution: "@babel/preset-typescript@npm:7.28.5"
dependencies:
@@ -1601,6 +1601,7 @@ __metadata:
dependencies:
"@babel/core": "npm:^7.25.2"
"@babel/preset-env": "npm:^7.25.3"
+ "@babel/preset-typescript": "npm:^7.27.1"
"@babel/runtime": "npm:^7.25.0"
"@react-native/babel-preset": "npm:0.82.1"
"@react-native/eslint-config": "npm:0.82.1"
@@ -1613,7 +1614,6 @@ __metadata:
react: "npm:19.1.1"
react-native: "npm:0.82.1"
react-native-builder-bob: "npm:^0.40.14"
- ts-jest: "npm:^29.2.5"
ts-morph: "npm:^25.0.0"
typescript: "npm:5.8.3"
peerDependencies:
@@ -5388,15 +5388,6 @@ __metadata:
languageName: node
linkType: hard
-"bs-logger@npm:^0.2.6":
- version: 0.2.6
- resolution: "bs-logger@npm:0.2.6"
- dependencies:
- fast-json-stable-stringify: "npm:2.x"
- checksum: 10/e6d3ff82698bb3f20ce64fb85355c5716a3cf267f3977abe93bf9c32a2e46186b253f48a028ae5b96ab42bacd2c826766d9ae8cf6892f9b944656be9113cf212
- languageName: node
- linkType: hard
-
"bser@npm:2.1.1":
version: 2.1.1
resolution: "bser@npm:2.1.1"
@@ -7529,7 +7520,7 @@ __metadata:
languageName: node
linkType: hard
-"fast-json-stable-stringify@npm:2.x, fast-json-stable-stringify@npm:^2.0.0, fast-json-stable-stringify@npm:^2.1.0":
+"fast-json-stable-stringify@npm:^2.0.0, fast-json-stable-stringify@npm:^2.1.0":
version: 2.1.0
resolution: "fast-json-stable-stringify@npm:2.1.0"
checksum: 10/2c20055c1fa43c922428f16ca8bb29f2807de63e5c851f665f7ac9790176c01c3b40335257736b299764a8d383388dabc73c8083b8e1bc3d99f0a941444ec60e
@@ -8153,7 +8144,7 @@ __metadata:
languageName: node
linkType: hard
-"handlebars@npm:^4.7.7, handlebars@npm:^4.7.8":
+"handlebars@npm:^4.7.7":
version: 4.7.8
resolution: "handlebars@npm:4.7.8"
dependencies:
@@ -10358,13 +10349,6 @@ __metadata:
languageName: node
linkType: hard
-"lodash.memoize@npm:^4.1.2":
- version: 4.1.2
- resolution: "lodash.memoize@npm:4.1.2"
- checksum: 10/192b2168f310c86f303580b53acf81ab029761b9bd9caa9506a019ffea5f3363ea98d7e39e7e11e6b9917066c9d36a09a11f6fe16f812326390d8f3a54a1a6da
- languageName: node
- linkType: hard
-
"lodash.merge@npm:4.6.2, lodash.merge@npm:^4.6.2":
version: 4.6.2
resolution: "lodash.merge@npm:4.6.2"
@@ -10525,7 +10509,7 @@ __metadata:
languageName: node
linkType: hard
-"make-error@npm:^1.1.1, make-error@npm:^1.3.6":
+"make-error@npm:^1.1.1":
version: 1.3.6
resolution: "make-error@npm:1.3.6"
checksum: 10/b86e5e0e25f7f777b77fabd8e2cbf15737972869d852a22b7e73c17623928fccb826d8e46b9951501d3f20e51ad74ba8c59ed584f610526a48f8ccf88aaec402
@@ -14643,46 +14627,6 @@ __metadata:
languageName: node
linkType: hard
-"ts-jest@npm:^29.2.5":
- version: 29.4.6
- resolution: "ts-jest@npm:29.4.6"
- dependencies:
- bs-logger: "npm:^0.2.6"
- fast-json-stable-stringify: "npm:^2.1.0"
- handlebars: "npm:^4.7.8"
- json5: "npm:^2.2.3"
- lodash.memoize: "npm:^4.1.2"
- make-error: "npm:^1.3.6"
- semver: "npm:^7.7.3"
- type-fest: "npm:^4.41.0"
- yargs-parser: "npm:^21.1.1"
- peerDependencies:
- "@babel/core": ">=7.0.0-beta.0 <8"
- "@jest/transform": ^29.0.0 || ^30.0.0
- "@jest/types": ^29.0.0 || ^30.0.0
- babel-jest: ^29.0.0 || ^30.0.0
- jest: ^29.0.0 || ^30.0.0
- jest-util: ^29.0.0 || ^30.0.0
- typescript: ">=4.3 <6"
- peerDependenciesMeta:
- "@babel/core":
- optional: true
- "@jest/transform":
- optional: true
- "@jest/types":
- optional: true
- babel-jest:
- optional: true
- esbuild:
- optional: true
- jest-util:
- optional: true
- bin:
- ts-jest: cli.js
- checksum: 10/e0ff9e13f684166d5331808b288043b8054f49a1c2970480a92ba3caec8d0ff20edd092f2a4e7a3ad8fcb9ba4d674bee10ec7ee75046d8066bbe43a7d16cf72e
- languageName: node
- linkType: hard
-
"ts-morph@npm:^25.0.0":
version: 25.0.1
resolution: "ts-morph@npm:25.0.1"
@@ -14853,13 +14797,6 @@ __metadata:
languageName: node
linkType: hard
-"type-fest@npm:^4.41.0":
- version: 4.41.0
- resolution: "type-fest@npm:4.41.0"
- checksum: 10/617ace794ac0893c2986912d28b3065ad1afb484cad59297835a0807dc63286c39e8675d65f7de08fafa339afcb8fe06a36e9a188b9857756ae1e92ee8bda212
- languageName: node
- linkType: hard
-
"type-is@npm:~1.6.18":
version: 1.6.18
resolution: "type-is@npm:1.6.18"