Sprite Mobile Redux Store — Slices, Actions, Persistence & State Shape
Complete deep-dive into the Knocklace Collection Redux Toolkit store: 3 slices (application, bluetoothAdapter, permissions), all actions/reducers, AsyncStorage persistence, state shape, and component connections.
Sprite Mobile Redux Store — Slices, Actions, Persistence & State Shape
Source: Reverse-engineering of
/Users/alefita/workdir/sprite-mobile/Version: 4.24.0 (JS)
1. Store Configuration
1.1 Root Store Setup
The store uses Redux Toolkit (@reduxjs/toolkit) with redux-persist for AsyncStorage persistence. The root configuration:
import { configureStore } from '@reduxjs/toolkit';
import { persistStore, persistReducer, FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER } from 'redux-persist';
import AsyncStorage from '@react-native-async-storage/async-storage';
const store = configureStore({
reducer: persistedReducer,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: {
ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
},
}),
});
const PersistentStore = persistStore(store);
The serializable check middleware ignores all redux-persist lifecycle actions, which is standard practice since redux-persist dispatches non-serializable actions internally.
1.2 Provider Architecture
The app wraps the component tree in App.tsx:
<Provider store={store}>
<PersistGate persistor={PersistentStore}>
<NavigationContainer theme={DarkTheme}>
<RootStack />
</NavigationContainer>
</PersistGate>
</Provider>
PersistGate blocks rendering until redux-persist rehydrates the store from AsyncStorage, ensuring the app never renders with empty state on subsequent launches.
2. Slice 1: Application Slice
2.1 State Shape
interface ApplicationState {
notificationAlreadyShownForRange: number[]; // which range thresholds have fired
isOnboardingComplete: boolean; // first-run pairing completed
isAlertActive: boolean; // master alert toggle
isAlertAlreadyShown: boolean; // prevents re-firing same alert
defaultEmergencyUrl: string; // from remote config
emergencyRange: number; // distance threshold (3-7m)
emergencyUrl: string; // user-configurable URL
remoteConfig?: RemoteConfig; // full remote config object
isApplicationReady: boolean; // initialization complete
}
2.2 RemoteConfig Type
interface RemoteConfig {
defaultVideo: string; // emergency YouTube URL
enableDot: boolean; // UI feature flag
radarSteps: number; // Kalman filter variation (3-7)
timeoutSecs: number; // RSSI debounce timeout (ms)
knownTags: string[]; // device name whitelist
rangesConfig: RangeConfig[]; // notification thresholds
customConfigPerDevice: DeviceCustomConfig[]; // per-device BLE calibration
}
interface DeviceCustomConfig {
customConfigEnabled: boolean;
customName: string; // display name (e.g., "OVAL 2")
jewelModel: number; // 0=circle, 1=square, 2=oval
knownAs: string; // BLE identifier
rssiAt1Meter: number; // calibration value
rssiPow: number; // path loss exponent
}
interface RangeConfig {
range: number; // distance in meters
notificationTitle: string;
notificationBody: string;
}
2.3 Actions & Reducers
| Action | Payload | Reducer Logic | Persisted |
|---|---|---|---|
setApplicationReady() | none | Sets isApplicationReady = true | No (blacklisted) |
updateRemoteConfig(RemoteConfig) | RemoteConfig object | Stores config, extracts defaultVideo to defaultEmergencyUrl | Yes |
resetNotificationAlreadyShownForRange() | none | Clears notificationAlreadyShownForRange to [] | Yes |
setNotificationAlreadyShownForRange(number) | range value | Pushes value to notificationAlreadyShownForRange array | Yes |
setAlertState(boolean) | boolean | Sets isAlertActive, resets isAlertAlreadyShown = false when activating | Yes |
setDefaultEmergencyUrl(string) | URL string | Sets defaultEmergencyUrl | Yes |
setEmergencyRange(number) | meters (3-7) | Sets emergencyRange | Yes |
setEmergencyUrl(string) | URL string | Sets emergencyUrl | Yes |
setAlertAlreadyShown() | none | Sets isAlertAlreadyShown = true | No (blacklisted) |
resetAlertShownState() | none | Sets isAlertAlreadyShown = false | No (blacklisted) |
setOnboardingState(boolean) | boolean | Sets isOnboardingComplete | Yes |
2.4 Persistence Strategy
const applicationPersistConfig = {
key: 'application',
storage: AsyncStorage,
blacklist: [
'isAlertAlreadyShown', // transient runtime flag
'isAlertActive', // re-evaluated on app start
'notificationAlreadyShownForRange', // reset per session
'isApplicationReady', // always false on fresh start
],
};
Whitelisted (persisted): isOnboardingComplete, emergencyRange, emergencyUrl, defaultEmergencyUrl, remoteConfig
Blacklisted (transient): isAlertAlreadyShown, isAlertActive, notificationAlreadyShownForRange, isApplicationReady
This is a thoughtful persistence strategy. The blacklist prevents stale runtime flags from surviving app restarts, while preserving user configuration and onboarding state.
3. Slice 2: Bluetooth Adapter Slice
3.1 State Shape
interface BluetoothAdapterState {
isBluetoothEnabled: boolean;
connectionState: 'scanning' | 'connecting' | 'connected' | 'disconnected' | 'terminated';
pairedDevice?: DeviceReference;
}
interface DeviceReference {
id: string; // BLE device ID (MAC on Android, UUID on iOS)
name: string | null; // device advertisement name
localName: string | null; // local name from advertisement
rssi: number | null; // last raw RSSI reading
rssiAt1Meter: number | null; // calibration value for distance
rssiPow: number | null; // path loss exponent
customName: string | null; // display name from remote config
jewelModel: number | null; // 0=circle, 1=square, 2=oval
approximateDistance: number | null | undefined; // live distance in meters
}
3.2 Actions & Reducers
| Action | Payload | Reducer Logic |
|---|---|---|
setBluetoothEnabled(boolean) | boolean | Sets isBluetoothEnabled |
setConnectionState(state) | ConnectionState union | Sets connectionState |
setPairedDevice(DeviceReference) | DeviceReference or undefined | Sets pairedDevice |
updatePairedDeviceApproximatedDistance(number) | meters | Updates pairedDevice.approximateDistance |
3.3 Persistence Strategy
const bluetoothPersistConfig = {
key: 'bluetoothAdapter',
storage: AsyncStorage,
whitelist: ['pairedDevice', 'isBluetoothEnabled'],
};
Only pairedDevice and isBluetoothEnabled survive restarts. connectionState is transient — the app reconnects on launch. approximateDistance is always recalculated from live RSSI.
3.4 Connection State Machine
The connectionState field forms an implicit state machine:
stateDiagram-v2
[*] --> scanning: startDeviceScan()
scanning --> connecting: known device found
connecting --> connected: GATT connected (not used in current app)
connected --> disconnected: signal lost / timeout
disconnected --> scanning: resume scan
scanning --> terminated: app background / BLE off
terminated --> scanning: BLE on / app foreground
Note: The current Knocklace app never actually enters connecting or connected states because it only reads RSSI from advertisements (no GATT connection). The state machine exists in the code but the connected state is unused.
4. Slice 3: Permissions Slice
4.1 State Shape
interface PermissionsState {
location: Permission;
bluetooth: Permission;
notification: Permission;
}
interface Permission {
granted: boolean;
canAskAgain: boolean;
}
4.2 Actions (Thunks)
The permissions slice uses async thunks rather than synchronous reducers:
| Thunk | Platform Behavior |
|---|---|
requestLocationPermission | Android: ACCESS_FINE_LOCATION; iOS: auto-granted |
requestBlePermission | Android: BLUETOOTH_SCAN + BLUETOOTH_CONNECT + ACCESS_BACKGROUND_LOCATION; iOS: auto-granted |
requestNotificationsPermission | Both: @notifee/react-native permission request |
requestPermissions | Orchestrator: runs all three with 1-second delays, then emits OnPermissionsGranted |
4.3 Known Bug
The setNotificationPermission reducer has a confirmed bug:
// BUG: writes to state.bluetooth instead of state.notification
setNotificationPermission: (state, action) => {
state.bluetooth.granted = action.payload.granted;
state.bluetooth.canAskAgain = action.payload.canAskAgain;
}
See sprite-mobile-code-review Bug 1 for full analysis.
4.4 Persistence Strategy
const permissionsPersistConfig = {
key: 'permissions',
storage: AsyncStorage,
// No blacklist or whitelist = full persist
};
All permission state persists. This means the app remembers that permissions were granted and does not re-request them on subsequent launches.
5. Store Subscription Pattern
5.1 The ApplicationService Mirror
The ApplicationService singleton subscribes to the Redux store and mirrors key values into its own instance variables:
class ApplicationService {
private isAlertActive: boolean = false;
private isAlertAlreadyShown: boolean = false;
private emergencyRange: number = 3;
private emergencyUrl: string = '';
constructor() {
store.subscribe(() => {
const state = store.getState();
this.isAlertActive = state.application.isAlertActive;
this.isAlertAlreadyShown = state.application.isAlertAlreadyShown;
this.emergencyRange = state.application.emergencyRange;
this.emergencyUrl = state.application.emergencyUrl;
});
}
}
5.2 Why This Exists
The ApplicationService processes RSSI updates at BLE scan frequency (potentially dozens of times per second). Reading from Redux via store.getState() on every RSSI callback would:
- Trigger the subscription notification chain (performance overhead)
- Create garbage collection pressure from selector re-evaluation
- Block the BLE callback thread on store access
By mirroring values into instance variables, the service reads from plain JavaScript properties — O(1) property access with no Redux overhead.
5.3 Trade-offs
| Advantage | Disadvantage |
|---|---|
| Zero-overhead reads in hot path | Two sources of truth |
| No React re-renders for BLE updates | Stale reads possible (subscription is async) |
| Decoupled from React lifecycle | Debugging difficulty (Redux DevTools won't show reads) |
5.4 Alternative Approaches
A more idiomatic approach would use store.getState() directly in the hot path (which is synchronous and does not trigger subscriptions):
// This is actually what the subscribe callback does internally
const state = store.getState();
const range = state.application.emergencyRange;
The subscription pattern adds no benefit over direct getState() calls since both are synchronous reads.
6. State Flow Diagram
flowchart TD
subgraph Redux Store
APP[application slice]
BLE[bluetoothAdapter slice]
PERM[permissions slice]
end
subgraph ApplicationService
MIRROR[Mirrored State]
CALC[Distance Calculator]
ALERT[Alert Manager]
CONFIG[Remote Config]
end
subgraph UI Components
HOME[HomeScreen]
RADAR[RadarScreen]
CONFIG_UI[ConfigScreen]
ONBOARD[OnboardingScreen]
end
subgraph Native Modules
BLE_SCAN[BLE Scan Callback]
FILTER[AppModule.filter]
end
BLE_SCAN -->|RSSI| CALC
CALC -->|raw distance| FILTER
FILTER -->|filtered distance| MIRROR
MIRROR -->|distance < range| ALERT
ALERT -->|notification| NATIVE[Notifee / Linking]
APP -->|subscribe| MIRROR
BLE -->|subscribe| MIRROR
HOME -->|dispatch: setAlertState| APP
RADAR -->|read: approximateDistance| BLE
CONFIG_UI -->|dispatch: setEmergencyRange, setEmergencyUrl| APP
ONBOARD -->|dispatch: setPairedDevice, setOnboardingState| BLE, APP
CONFIG -->|fetch| API[Google Cloud Run]
CONFIG -->|dispatch: updateRemoteConfig| APP
PERM -->|dispatch: requestPermissions| NATIVE_PERM[Native Permission APIs]
7. Component-Store Connections
7.1 ActivateApplicationSwitch
// Reads
const isAlertActive = useSelector((state) => state.application.isAlertActive);
// Dispatches
dispatch(setAlertState(!isAlertActive));
// Also calls: AppModule.resetNotificationAlreadyShownForRange()
7.2 SliderCustom
// Reads
const emergencyRange = useSelector((state) => state.application.emergencyRange);
// Dispatches (on slide complete)
dispatch(setEmergencyRange(value));
7.3 UrlButton
// Reads
const emergencyUrl = useSelector((state) => state.application.emergencyUrl);
const defaultEmergencyUrl = useSelector((state) => state.application.defaultEmergencyUrl);
// Dispatches
dispatch(setEmergencyUrl(text));
7.4 RadarComponent
// Reads
const approximateDistance = useSelector(
(state) => state.bluetoothAdapter.pairedDevice?.approximateDistance
);
7.5 ConnectionStateManagerView
// Reads
const pairedDevice = useSelector((state) => state.bluetoothAdapter.pairedDevice);
// Dispatches (on unpair)
dispatch(setPairedDevice(undefined));
dispatch(setOnboardingState(false));
7.6 SearchingDeviceIndicator
// Reads
const pairedDevice = useSelector((state) => state.bluetoothAdapter.pairedDevice);
// Shows green pill if pairedDevice exists, spinner otherwise
7.7 RootStack (Navigation)
// Reads
const isOnboardingComplete = useSelector((state) => state.application.isOnboardingComplete);
const isBluetoothEnabled = useSelector((state) => state.bluetoothAdapter.isBluetoothEnabled);
// Determines navigation state (which screen to show)
8. AsyncStorage Key Layout
After persistence, the following keys exist in AsyncStorage:
| Key | Content |
|---|---|
persist:application | { isOnboardingComplete, emergencyRange, emergencyUrl, defaultEmergencyUrl, remoteConfig } |
persist:bluetoothAdapter | { pairedDevice, isBluetoothEnabled } |
persist:permissions | { location: { granted, canAskAgain }, bluetooth: {...}, notification: {...} } |
Each key is a JSON-serialized string. redux-persist handles serialization/deserialization automatically.
8.1 Rehydration Behavior
On app launch:
redux-persistreads all three keys from AsyncStorage- Parses JSON and merges into initial Redux state
PersistGatewaits for rehydration to complete- React tree renders with persisted state
ApplicationServicesubscribes and mirrors values- Remote config fetch may update
remoteConfiganddefaultEmergencyUrl
If AsyncStorage is empty (first launch), all slices start with their initial state:
isOnboardingComplete: falseemergencyRange: 3isBluetoothEnabled: false- All permissions:
{ granted: false, canAskAgain: true }
9. Improvement Opportunities
9.1 Type Safety
The store uses TypeScript but does not export typed hooks. Adding typed selectors and dispatches would prevent the Bug 1 class of errors:
// Recommended: typed hooks
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
export const useAppSelector = useSelector.withTypes<RootState>();
9.2 RTK Query for Remote Config
The remote config fetch is handled imperatively in ApplicationService. Migrating to RTK Query would provide:
- Automatic caching and refetching
- Loading/error states in Redux
- Normalized cache invalidation
- Built-in polling support
9.3 State Machine for Alert Lifecycle
The alert system uses multiple boolean flags (isAlertActive, isAlertAlreadyShown, notificationAlreadyShownForRange) to manage what is fundamentally a state machine. XState or a manual state machine would make the logic explicit:
type AlertState = 'idle' | 'monitoring' | 'in_range' | 'alert_fired' | 'cooldown';
9.4 Persist Migration
The current persistence configuration has no version or migration strategy. If the RemoteConfig type changes between app versions, rehydration will silently drop unknown fields or corrupt the shape. Adding redux-persist-migrate or a manual migration function would prevent this.
Cross-References
- sprite-mobile-knocklace — How the BLE beacon protocol feeds into the bluetoothAdapter slice
- sprite-mobile-code-review — Bug 1 (permissions reducer) and Bug 3 (BLE toggle) live in this store architecture
- preferencias-tecnicas — Alefita's technical preferences (state management, TypeScript)