---
name: sprite-mobile-redux
type: reference
title: "Sprite Mobile Redux Store — Slices, Actions, Persistence & State Shape"
description: "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."
tags: [sprite, redux, redux-toolkit, async-storage, state-management, react-native, typescript]
timestamp: 2026-07-22
---

# 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:

```typescript
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`:

```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

```typescript
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

```typescript
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

```typescript
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

```typescript
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

```typescript
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:

```mermaid
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

```typescript
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:

```typescript
// 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

```typescript
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:

```typescript
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:

1. Trigger the subscription notification chain (performance overhead)
2. Create garbage collection pressure from selector re-evaluation
3. 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):

```typescript
// 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

```mermaid
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

```typescript
// Reads
const isAlertActive = useSelector((state) => state.application.isAlertActive);

// Dispatches
dispatch(setAlertState(!isAlertActive));
// Also calls: AppModule.resetNotificationAlreadyShownForRange()
```

### 7.2 SliderCustom

```typescript
// Reads
const emergencyRange = useSelector((state) => state.application.emergencyRange);

// Dispatches (on slide complete)
dispatch(setEmergencyRange(value));
```

### 7.3 UrlButton

```typescript
// Reads
const emergencyUrl = useSelector((state) => state.application.emergencyUrl);
const defaultEmergencyUrl = useSelector((state) => state.application.defaultEmergencyUrl);

// Dispatches
dispatch(setEmergencyUrl(text));
```

### 7.4 RadarComponent

```typescript
// Reads
const approximateDistance = useSelector(
  (state) => state.bluetoothAdapter.pairedDevice?.approximateDistance
);
```

### 7.5 ConnectionStateManagerView

```typescript
// Reads
const pairedDevice = useSelector((state) => state.bluetoothAdapter.pairedDevice);

// Dispatches (on unpair)
dispatch(setPairedDevice(undefined));
dispatch(setOnboardingState(false));
```

### 7.6 SearchingDeviceIndicator

```typescript
// Reads
const pairedDevice = useSelector((state) => state.bluetoothAdapter.pairedDevice);
// Shows green pill if pairedDevice exists, spinner otherwise
```

### 7.7 RootStack (Navigation)

```typescript
// 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:

1. `redux-persist` reads all three keys from AsyncStorage
2. Parses JSON and merges into initial Redux state
3. `PersistGate` waits for rehydration to complete
4. React tree renders with persisted state
5. `ApplicationService` subscribes and mirrors values
6. Remote config fetch may update `remoteConfig` and `defaultEmergencyUrl`

If AsyncStorage is empty (first launch), all slices start with their initial state:
- `isOnboardingComplete: false`
- `emergencyRange: 3`
- `isBluetoothEnabled: 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:

```typescript
// 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:

```typescript
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)
