If you have ever embedded React Native inside an existing native app, you know the awkward part is not rendering the screens. It is sharing state. The native side already holds the user session, feature flags, the cart, the theme. Your React Native screens need the same values, and they need to react when the native side changes them, and the native side needs to react when React Native changes them back. Doing this by hand means a thicket of native modules, event emitters, and stringly-typed payloads that nobody can refactor with confidence.
@callstack/brownie is Callstack's answer to that specific pain. It is a shared-state library built for brownfield React Native, where "brownfield" means React Native living inside an existing iOS or Android app rather than running the whole show. Brownie keeps a single source of truth that is readable and writable from both your TypeScript code and your native Swift or Kotlin code. You describe the store shape once in TypeScript, run a codegen step, and Brownie hands you matching, fully typed native structs to work with. The ergonomics on the JavaScript side will feel instantly familiar if you have used Zustand, and the native side gets first-class SwiftUI and Kotlin integrations.
Why a Dedicated Brownfield State Library
Pure JavaScript state libraries like Zustand, Redux, or Jotai are excellent, but they all live entirely inside the JavaScript world. They have no idea your app also has a Swift view controller that wants to read the same isLoading flag. Storage tools like react-native-mmkv can be reached from both sides, but they give you a key-value store, not a reactive, typed model with generated structs and selector-based re-rendering.
Brownie sits in the gap between those. Its whole reason to exist is bidirectional, type-safe state that crosses the React Native and native boundary, generated from one schema. A few things fall out of that design:
- One source of truth. The state lives in a shared C++ core, so neither side keeps a competing copy that can drift.
- Type safety end to end. Your TypeScript schema is the spec, and the native Swift and Kotlin types are generated from it, so a renamed field is a compile error on both sides instead of a silent runtime mismatch.
- Familiar React ergonomics. A
useStorehook with selector support means components re-render only when the slice they care about changes. - Native integrations that feel native. SwiftUI gets a
@UseStoreproperty wrapper, UIKit gets a subscribe-based API, and Kotlin gets generated data classes. - Cross-platform. Brownie started life as iOS-only, and the 3.x line added Android and Kotlin support, so the same store now works on both platforms.
Getting Brownie Into Your Project
Brownie ships as a regular package with native code bundled in. Install it alongside its peers, react and react-native, and make sure you are on Node 20 or newer.
npm install @callstack/brownie
Or with Yarn:
yarn add @callstack/brownie
Brownie pulls in @callstack/brownfield-cli, which provides the brownfield command-line tool that does the codegen. On iOS you will run the usual pod install so the native module is linked; on Android the library is picked up through autolinking.
Describing Your Shared Store
Everything starts from a TypeScript description of what your store holds. Brownie auto-discovers these definitions from files ending in .brownie.ts, and you wire them up using TypeScript module augmentation. The pattern is to declare an interface that extends BrownieStore, then register it on the global BrownieStores map.
// BrownfieldStore.brownie.ts
import type { BrownieStore } from '@callstack/brownie';
interface BrownfieldStore extends BrownieStore {
counter: number;
user: string;
isLoading: boolean;
}
declare module '@callstack/brownie' {
interface BrownieStores {
BrownfieldStore: BrownfieldStore;
}
}
You are not limited to one store. Group several in a single file when it makes sense, and each one becomes its own independently addressable store.
// Stores.brownie.ts
import type { BrownieStore } from '@callstack/brownie';
interface UserStore extends BrownieStore {
name: string;
email: string;
}
interface SettingsStore extends BrownieStore {
theme: 'light' | 'dark';
notificationsEnabled: boolean;
}
declare module '@callstack/brownie' {
interface BrownieStores {
UserStore: UserStore;
SettingsStore: SettingsStore;
}
}
This declaration does double duty. It tells TypeScript exactly what each store looks like so your JavaScript code gets autocomplete and type checking, and it is the input the codegen step reads to produce the native types.
Generating the Native Types
With your schema in place, run the codegen command to materialize the matching native types. The brownfield binary that ships with Brownie handles this.
brownfield codegen # generate for all configured platforms
brownfield codegen -p swift # Swift only
brownfield codegen --platform kotlin # Kotlin only
Android output is configured in your app's package.json under a brownie key, where you point the generator at the directory and package name for the generated Kotlin files.
{
"brownie": {
"kotlin": "./android/app/src/main/java/com/example/",
"kotlinPackageName": "com.example"
}
}
Swift files are always written into node_modules/@callstack/brownie/ios/Generated/, a path that is auto-resolved and intentionally not configurable, so you do not have to think about it. The generated output mirrors your TypeScript exactly. A Swift Codable struct comes out the other end, with numbers mapped to Double:
public struct BrownfieldStore: Codable {
public var counter: Double
public var isLoading: Bool
public var user: String
}
And the Kotlin side gets the equivalent data class:
data class BrownfieldStore(
val counter: Double,
val isLoading: Boolean,
val user: String
)
Reading and Writing From React Native
The JavaScript API is deliberately small and Zustand-flavored. The headline is useStore, which always takes a selector so a component only subscribes to the slice it actually uses. It returns a tuple of the selected value and a setter, just like useState.
import { useStore } from '@callstack/brownie';
function Counter() {
// Re-renders only when `counter` changes, not on every store update.
const [counter, setState] = useStore('BrownfieldStore', (s) => s.counter);
return (
<Button
title={`Count: ${counter}`}
onPress={() => setState({ counter: counter + 1 })}
/>
);
}
The setter accepts either a partial object or a callback that receives the previous state, again mirroring useState.
// Partial update
setState({ isLoading: true });
// Callback form, handy when the next value depends on the current one
setState((prev) => ({ counter: prev.counter + 1 }));
Under the hood useStore is built on React's useSyncExternalStore, which is what makes the selector-based re-render skipping work. The native side pushes the full state on every change, each hook extracts only its selected slice, and React skips the re-render when that slice is unchanged.
When you need state outside the React tree, Brownie exposes the same store imperatively. subscribe returns an unsubscribe function, and getSnapshot gives you the current typed value on demand.
import { subscribe, getSnapshot, setState } from '@callstack/brownie';
const unsubscribe = subscribe('BrownfieldStore', () => {
const { counter } = getSnapshot('BrownfieldStore');
console.log('counter is now', counter);
});
setState('BrownfieldStore', { user: 'ada' });
// later
unsubscribe();
One detail worth knowing: a store has to be registered on the native side before the JavaScript side touches it. If you forget, Brownie throws a clear, actionable error that even includes the Swift and Kotlin snippets you need, rather than failing in some mysterious way deep in the bridge.
Wiring Up the Native Side
The native half is where Brownie shows off. On iOS, you register a store before React Native starts, then consume it from SwiftUI with the @UseStore property wrapper. It takes a WritableKeyPath, which forces you to select an explicit slice exactly like the JavaScript selector does.
@UseStore(\BrownfieldStore.counter) var counter
// `counter` is the read-only selected value (Double here)
// `$counter` is a standard SwiftUI Binding<Double>
// `$counter.set { $0 + 1 }` updates via a closure
Because the wrapper uses removeDuplicates() internally, a SwiftUI view only re-renders when its selected value actually changes, which means the selected type needs to conform to Equatable. UIKit is covered too, with a subscribe-based imperative API for views that update by hand. On Android, you register the store from Kotlin before React Native boots:
registerStoreIfNeeded(storeName = BrownfieldStore.NAME) {
BrownfieldStore(counter = 0.0, isLoading = false, user = "")
}
From there, both platforms work against the generated typed structs, and any update flows through the shared core to every subscriber on either side.
What Happens Under the Hood
It helps to know how the plumbing fits together, because the docs are refreshingly honest about it. The source of truth is a thread-safe C++ store (BrownieStore) holding state as a folly::dynamic, guarded by a mutex, with a singleton manager registering stores by key. JavaScript talks to it through a JSI host object that converts values across the boundary, which is why reads and writes feel synchronous rather than going through the old asynchronous bridge.
On iOS, an Objective-C++ bridge exposes that C++ store to Swift and posts a notification whenever the state changes, and the Swift Store type rebuilds its typed value in response. The trade-off Brownie is upfront about is that data is copied at each boundary rather than shared, so a Swift round-trip currently travels through Codable, JSON, an NSDictionary, and then folly::dynamic. The maintainers document these copy counts and list future optimizations like skipping the JSON step and syncing only dirty properties. For most brownfield state, which tends to be modestly sized session and UI data, this is a perfectly reasonable cost in exchange for a clean, typed model.
A Note on Maturity
Brownie is young and moving fast. The package first appeared in early 2026 and reached its 3.x line within months, versioned in lockstep with the rest of the react-native-brownfield monorepo, which Callstack has maintained since 2019. Releases land every couple of weeks, Android support is recent, and the API surface is small but still evolving. If you are running React Native inside a native app and have been hand-rolling state bridges, Brownie is well worth a serious look. Just keep an eye on the changelog, since a project this active will keep adding capabilities.
Wrapping Up
Brownie tackles a problem most general-purpose state libraries simply do not address: keeping React Native and the native app it lives inside in genuine agreement about shared state. You write one TypeScript schema, generate matching Swift and Kotlin types, and then read and write the same store from a Zustand-style useStore hook, a SwiftUI @UseStore wrapper, or Kotlin data classes, all backed by a single shared core. For brownfield teams who have been gluing the two worlds together by hand, that is a meaningful upgrade in both safety and sanity. Backed by Callstack and now spanning both iOS and Android, @callstack/brownie is a focused, pragmatic tool for one of the trickier corners of incremental React Native adoption.