State Management Overview
Sovran is transitioning from Redux to Zustand for state management. The migration is ongoing, with new features using Zustand while legacy code still uses Redux.
Why Zustand?
Simpler API : Less boilerplate than Redux (no actions, reducers, or dispatch)
Better TypeScript : Full type inference without manual typing
Smaller bundle : ~1KB vs Redux’s ~10KB
React-first : Hooks-based API that feels native to React
Profile-scoped stores : Easy to create per-profile state isolation
Zustand Stores
All Zustand stores live in stores/ and follow a consistent pattern.
Store Architecture
stores/settingsStore.ts:144-267
Store pattern:
import { create } from 'zustand' ;
import { persist , createJSONStorage } from 'zustand/middleware' ;
import AsyncStorage from '@react-native-async-storage/async-storage' ;
interface StoreState {
// State properties
value : string ;
}
interface StoreActions {
// Actions
setValue : ( value : string ) => void ;
getValue : () => string ;
}
type Store = StoreState & StoreActions ;
export const useStore = create < Store >()(
persist (
( set , get ) => ({
// Initial state
value: '' ,
// Actions
setValue : ( value ) => set ({ value }),
getValue : () => get (). value ,
}),
{
name: 'store-name' ,
storage: createJSONStorage (() => AsyncStorage ),
partialize : ( state ) => ({ value: state . value })
}
)
);
Global Stores
These stores persist globally across all profiles:
settingsStore.ts
pricelistStore.ts
profileStore.ts
// Stores app-wide settings
export const useSettingsStore = create < SettingsStore >()( persist ( ... ));
// Usage
const theme = useSettingsStore ( s => s . theme );
const setTheme = useSettingsStore ( s => s . setTheme );
Profile-Scoped Stores
These stores are isolated per profile (wallet). Each profile has its own AsyncStorage key:
Account 0 : mint-store-profile-0
Account 1 : mint-store-profile-1
Account N : mint-store-profile-N
Profile-scoped stores:
Store Purpose Key Pattern mintStoreMint URLs, icons, metadata mint-store-profile-{N}mintDistributionStoreTarget distribution per mint (basis points) mint-distribution-store-profile-{N}swapTransactionsStoreGrouped swap/rebalance transactions swap-transactions-store-profile-{N}scanHistoryStoreQR/NFC/paste scan history scan-history-store-profile-{N}transactionLocationStoreGPS stamps for transactions transaction-location-store-profile-{N}routstrStoreRoutstr AI chat (API key, conversations) routstr-store-profile-{N}searchHistoryStoreNostr profile search history search-history-store-profile-{N}
Rehydration on profile switch:
When switching profiles, all profile-scoped stores are rehydrated from the new profile’s AsyncStorage keys:
// helper/profileScopedStorage.ts
export async function rehydrateProfileStores () : Promise < void > {
const accountIndex = useProfileStore . getState (). activeAccountIndex ;
// Each store has a rehydrate method that reads from profile-specific key
await Promise . all ([
useMintStore . persist . rehydrate (),
useMintDistributionStore . persist . rehydrate (),
useSwapTransactionsStore . persist . rehydrate (),
// ... etc
]);
}
This is called in the drawer layout profile switch handler:
app/(drawer)/_layout.tsx:105-108
Runtime-Only Stores
Some stores don’t persist to AsyncStorage (in-memory only):
// stores/popupStore.ts - UI state for popups/toasts
export const usePopupStore = create < PopupStore >()(( set , get ) => ({
popups: [],
addPopup : ( popup ) => set (( s ) => ({ popups: [ ... s . popups , popup ] })),
removePopup : ( id ) => set (( s ) => ({ popups: s . popups . filter ( p => p . id !== id ) }))
}));
No persist() middleware means state resets on app restart.
Redux (Legacy)
Redux is being phased out but still handles:
Cashu proof state (migrating to Coco SQLite)
Nostr contact state (migrating to Zustand)
Settings (already migrated to settingsStore)
redux/store/index.ts:1-728
Redux Structure
redux/
├── store/
│ ├── index.ts # Store creation, migrations, persist config
│ └── reducer.ts # Root reducer combining all slices
├── cashu/
│ ├── reducer.ts # Cashu state (proofs, transactions)
│ └── types.ts # Type definitions
├── nostr/
│ ├── reducer.ts # Nostr contacts, profiles
│ └── index.ts # Action creators
└── settings/
├── reducer.ts # App settings (DEPRECATED - use settingsStore)
└── actionTypes.ts # Action constants
Migrations
Redux uses redux-persist migrations to evolve the store schema:
redux/store/index.ts:17-587
Migration 151 moves the mnemonic from Redux to expo-secure-store:
redux/store/index.ts:506-559
Migration 152 migrates settings from Redux to Zustand:
redux/store/index.ts:560-586
Accessing Redux State
import { useSelector } from 'react-redux' ;
import type { RootState } from 'redux/store/reducer' ;
// In a component
function MyComponent () {
const proofs = useSelector (( state : RootState ) =>
state . cashu . profiles [ 0 ]?. proofs ?? {}
);
return < Text > Proofs: { Object . keys ( proofs ). length } </ Text > ;
}
Avoid adding new features to Redux. Use Zustand instead. Only modify Redux for bug fixes or migrations.
State Migration Strategy
The migration from Redux to Zustand follows this approach:
Phase 1: Settings ✅ Complete
Migrated settingsStore to Zustand
Migration runs automatically on app startup (see migration 152)
Old Redux settings slice is now ignored
Phase 2: Cashu State 🚧 In Progress
Moving from Redux to Coco SQLite (not Zustand)
Proofs, quotes, and transactions now live in coco.db
Redux cashu slice will be removed once migration is complete
Uses coco-cashu-expo-sqlite repository layer
Phase 3: Nostr State 📋 Planned
Migrate contacts/profiles to Zustand stores
Use NDK’s SQLite cache for event data
Keep Redux only for backward compatibility during migration
Phase 4: Complete Removal 🎯 Future
Remove Redux entirely
Delete redux/ directory
Remove redux, react-redux, redux-persist, redux-thunk dependencies
Estimated bundle size reduction: ~15KB
Store Clearing on Reset
When the user deletes all data (Settings → Delete Everything), all stores are cleared:
redux/store/index.ts:608-727
This clears:
Coco SQLite database (coco.db and all coco-N.db files)
All profile-scoped Zustand stores (current profile)
All global Zustand stores
All profile-scoped AsyncStorage keys (all profiles)
Redux persisted state
Secure storage (mnemonics, keys)
Best Practices
Use selectors for derived state
Don’t compute values in components - derive them in the store: // Good ✅
export const usePricelistStore = create < PricelistStore >()(
persist (
( set , get ) => ({
pricelist: null ,
getBtcPrice : ( currency = 'usd' ) => {
const { pricelist } = get ();
return pricelist ?.[ currency ]?. btc ?? null ;
}
}),
{ name: 'pricelist-store' , storage: createJSONStorage (() => AsyncStorage ) }
)
);
// Usage
const btcPrice = usePricelistStore ( s => s . getBtcPrice ( 'usd' ));
// Bad ❌
const pricelist = usePricelistStore ( s => s . pricelist );
const btcPrice = pricelist ?. usd ?. btc ?? null ; // Computation in component
Minimize re-renders with selectors
Only subscribe to the specific state you need: // Good ✅ - Only re-renders when theme changes
const theme = useSettingsStore ( s => s . theme );
// Bad ❌ - Re-renders on ANY settings change
const settings = useSettingsStore ();
const theme = settings . theme ;
Use partialize to control persistence
Don’t persist everything - only what’s needed: export const useStore = create < Store >()( persist (
( set , get ) => ({
value: '' ,
tempData: null , // Don't persist this
setValue : ( value ) => set ({ value }),
}),
{
name: 'store-name' ,
storage: createJSONStorage (() => AsyncStorage ),
partialize : ( state ) => ({ value: state . value }) // Only persist 'value'
}
));
Handle rehydration errors
Always provide error handling for persistence: export const useStore = create < Store >()( persist (
( set , get ) => ({ /* ... */ }),
{
name: 'store-name' ,
storage: createJSONStorage (() => AsyncStorage ),
onRehydrateStorage : () => ( state , error ) => {
if ( error ) {
console . warn ( 'Failed to rehydrate store:' , error );
// Reset to defaults or handle gracefully
}
}
}
));
Use profile-scoped stores for user data
Any data specific to a wallet/profile should use profile-scoped storage: // helper/profileScopedStorage.ts
export function createProfileScopedStore < T >(
storeName : string ,
initialState : T
) {
return create < T >()( persist (
( set , get ) => ({ ... initialState }),
{
name: ` ${ storeName } -profile- ${ useProfileStore . getState (). activeAccountIndex } ` ,
storage: createJSONStorage (() => AsyncStorage )
}
));
}
Subscription Patterns
// Fast - subscribes to single value
const theme = useSettingsStore ( s => s . theme );
// Medium - subscribes to object (shallow equality)
const settings = useSettingsStore ( s => ({
theme: s . theme ,
language: s . language
}));
// Slow - subscribes to entire store
const allSettings = useSettingsStore ();
Outside React Components
Access store state outside React:
// Get current value (not reactive)
const theme = useSettingsStore . getState (). theme ;
// Call actions
useSettingsStore . getState (). setTheme ( 'dark' );
// Subscribe to changes
const unsubscribe = useSettingsStore . subscribe (
( state ) => console . log ( 'Theme changed:' , state . theme )
);
// Clean up
unsubscribe ();
Architecture Overview See how stores fit into the overall architecture
Cashu Integration Learn about Coco SQLite storage for proofs and quotes