mixpanel-react-native
Version:
Official React Native Tracking Library for Mixpanel Analytics
456 lines (371 loc) โข 12.5 kB
Markdown
# AsyncStorage Integration Patterns
## Overview
The mixpanel-react-native library demonstrates sophisticated AsyncStorage integration patterns, including storage abstraction, graceful fallbacks, and efficient data serialization strategies for persistent analytics state.
## ๐๏ธ Storage Architecture
### Storage Abstraction Layer
```javascript
// javascript/mixpanel-storage.js
export class AsyncStorageAdapter {
constructor(storage) {
if (!storage) {
try {
// Dynamic import to handle missing dependency
const storageModule = require("@react-native-async-storage/async-storage");
if (storageModule.default) {
this.storage = storageModule.default
} else {
this.storage = storageModule
}
} catch {
console.error(
"[@RNC/AsyncStorage]: NativeModule: AsyncStorage is null. " +
"Please run 'npm install @react-native-async-storage/async-storage'"
);
console.error("[Mixpanel] Falling back to in-memory storage");
this.storage = new InMemoryStorage();
}
} else {
this.storage = storage;
}
}
}
```
**Abstraction Benefits**:
- **Graceful degradation**: Falls back to in-memory storage
- **Dependency injection**: Supports custom storage implementations
- **Module compatibility**: Handles different import patterns
- **Development flexibility**: Works without AsyncStorage in tests
### In-Memory Fallback Implementation
```javascript
// javascript/mixpanel-storage.js
class InMemoryStorage {
constructor() {
this.store = {};
}
async getItem(key) {
return this.store.hasOwnProperty(key) ? this.store[key] : null;
}
async setItem(key, value) {
this.store[key] = value;
}
async removeItem(key) {
delete this.store[key];
}
}
```
**Fallback Strategy**:
- **API consistency**: Same interface as AsyncStorage
- **Session persistence**: Data survives within app session
- **Test compatibility**: Perfect for unit testing
- **Development workflow**: Works in environments without native storage
## ๐ง Error Handling Patterns
### Defensive Storage Operations
```javascript
// javascript/mixpanel-storage.js
export class AsyncStorageAdapter {
async getItem(key) {
try {
return await this.storage.getItem(key);
} catch {
MixpanelLogger.error("error getting item from storage");
return null; // Always return null on failure
}
}
async setItem(key, value) {
try {
await this.storage.setItem(key, value);
} catch {
MixpanelLogger.error("error setting item in storage");
// Silent failure - continue operation
}
}
async removeItem(key) {
try {
await this.storage.removeItem(key);
} catch {
MixpanelLogger.error("error removing item from storage");
// Silent failure - continue operation
}
}
}
```
**Error Handling Philosophy**:
- **Silent failures**: Storage errors don't crash the app
- **Logging**: All errors logged for debugging
- **Fallback values**: Always return usable defaults
- **Continue operation**: App functionality maintained despite storage issues
## ๐๏ธ Key Management Strategy
### Token-Scoped Key Generation
```javascript
// javascript/mixpanel-constants.js
export const getQueueKey = (token, type) => `MIXPANEL_${token}_${type}_QUEUE`;
export const getDeviceIdKey = (token) => `MIXPANEL_${token}_DEVICE_ID`;
export const getDistinctIdKey = (token) => `MIXPANEL_${token}_DISTINCT_ID`;
export const getUserIdKey = (token) => `MIXPANEL_${token}_USER_ID`;
export const getOptedOutKey = (token) => `MIXPANEL_${token}_OPT_OUT`;
export const getSuperPropertiesKey = (token) => `MIXPANEL_${token}_SUPER_PROPERTIES`;
export const getTimeEventsKey = (token) => `MIXPANEL_${token}_TIME_EVENTS`;
export const getAppHasOpenedBeforeKey = (token) => `MIXPANEL_${token}_APP_HAS_OPENED_BEFORE`;
```
**Key Strategy Benefits**:
- **Namespace isolation**: Prevents key collisions between projects
- **Predictable naming**: Easy to debug and inspect
- **Multi-tenant support**: Multiple Mixpanel projects per app
- **Clear organization**: Keys grouped by functionality
### Key Lifecycle Management
```javascript
// javascript/mixpanel-persistent.js
async reset(token) {
// Comprehensive cleanup of all token-related keys
await this.storageAdapter.removeItem(getDeviceIdKey(token));
await this.storageAdapter.removeItem(getDistinctIdKey(token));
await this.storageAdapter.removeItem(getUserIdKey(token));
await this.storageAdapter.removeItem(getSuperPropertiesKey(token));
await this.storageAdapter.removeItem(getTimeEventsKey(token));
// Reload fresh state
await this.loadIdentity(token);
await this.loadSuperProperties(token);
await this.loadTimeEvents(token);
}
```
## ๐พ Data Serialization Patterns
### JSON Serialization Strategy
```javascript
// javascript/mixpanel-persistent.js
// Serialization: Objects to strings
async persistSuperProperties(token) {
if (this._superProperties[token] === null) {
return;
}
await this.storageAdapter.setItem(
getSuperPropertiesKey(token),
JSON.stringify(this._superProperties[token]) // Object โ JSON string
);
}
// Deserialization: Strings to objects with fallbacks
async loadSuperProperties(token) {
const superPropertiesString = await this.storageAdapter.getItem(
getSuperPropertiesKey(token)
);
this._superProperties[token] = superPropertiesString
? JSON.parse(superPropertiesString) // JSON string โ Object
: {}; // Fallback to empty object
}
```
**Serialization Principles**:
- **JSON format**: Standard, debuggable, cross-platform
- **Null safety**: Handle missing/corrupted data gracefully
- **Default values**: Always provide sensible fallbacks
- **Type consistency**: Maintain expected data types
### Complex Data Structure Handling
```javascript
// javascript/mixpanel-persistent.js
// Queue persistence (arrays of objects)
async loadQueue(token, type) {
const queueString = await this.storageAdapter.getItem(
getQueueKey(token, type)
);
return queueString ? JSON.parse(queueString) : []; // Default to empty array
}
async saveQueue(token, type, queue) {
await this.storageAdapter.setItem(
getQueueKey(token, type),
JSON.stringify(queue)
);
}
// Boolean persistence (string conversion)
async persistOptedOut(token) {
if (this._optedOut[token] === null) {
return;
}
await this.storageAdapter.setItem(
getOptedOutKey(token),
this._optedOut[token].toString() // Boolean โ String
);
}
async loadOptOut(token) {
const optOutString = await this.storageAdapter.getItem(
getOptedOutKey(token)
);
this._optedOut[token] = optOutString === "true"; // String โ Boolean
}
```
## ๐ Performance Optimization Patterns
### Write-Through Caching
```javascript
// javascript/mixpanel-persistent.js
export class MixpanelPersistent {
constructor(storageAdapter) {
this.storageAdapter = storageAdapter;
// In-memory caches for fast access
this._superProperties = {};
this._timeEvents = {};
this._identity = {};
this._optedOut = {};
this._appHasOpenedBefore = {};
}
// Read from cache (fast)
getSuperProperties(token) {
return this._superProperties[token];
}
// Write to cache AND storage (write-through)
updateSuperProperties(token, superProperties) {
this._superProperties = {
...this._superProperties,
[token]: { ...superProperties }, // Update cache
};
// Note: persistSuperProperties() called separately for storage
}
}
```
**Caching Benefits**:
- **Fast reads**: Memory access vs. async storage I/O
- **Consistency**: Cache always reflects current state
- **Batched writes**: Can optimize storage writes
- **Reliability**: Storage failures don't affect memory state
### Lazy Loading Strategy
```javascript
// javascript/mixpanel-persistent.js
async loadDeviceId(token) {
if (!token) {
return; // Early exit for invalid token
}
const storageToken = await this.storageAdapter.getItem(
getDeviceIdKey(token)
);
if (!this._identity[token]) {
this._identity[token] = {}; // Initialize token state only when needed
}
this._identity[token].deviceId = storageToken;
if (!this._identity[token].deviceId) {
// Generate only when missing
try {
this._identity[token].deviceId = randomUUID();
} catch (e) {
this._identity[token].deviceId = uuid.v4();
}
await this.storageAdapter.setItem(
getDeviceIdKey(token),
this._identity[token].deviceId
);
}
}
```
**Lazy Loading Benefits**:
- **Memory efficiency**: Only load data when needed
- **Startup performance**: Avoid loading unused token data
- **Scalability**: Supports many tokens without memory bloat
## ๐ Data Migration & Versioning
### Identity Generation with Fallbacks
```javascript
// javascript/mixpanel-persistent.js
async loadDeviceId(token) {
// Load existing ID from storage
const storageToken = await this.storageAdapter.getItem(getDeviceIdKey(token));
if (!this._identity[token]) {
this._identity[token] = {};
}
this._identity[token].deviceId = storageToken;
if (!this._identity[token].deviceId) {
// Generate new ID with fallback strategy
try {
this._identity[token].deviceId = randomUUID(); // Expo crypto (preferred)
} catch (e) {
this._identity[token].deviceId = uuid.v4(); // uuid package (fallback)
}
// Persist the new ID
await this.storageAdapter.setItem(
getDeviceIdKey(token),
this._identity[token].deviceId
);
}
}
async loadDistinctId(token) {
const distinctId = await this.storageAdapter.getItem(getDistinctIdKey(token));
if (!this._identity[token]) {
this._identity[token] = {};
}
this._identity[token].distinctId = distinctId;
if (!this._identity[token].distinctId) {
// Generate device-based distinct ID
this._identity[token].distinctId = "$device:" + this._identity[token].deviceId;
await this.storageAdapter.setItem(
getDistinctIdKey(token),
this._identity[token].distinctId
);
}
}
```
## ๐งช Testing Patterns
### AsyncStorage Mocking for Tests
```javascript
// __tests__/jest_setup.js
jest.mock("@react-native-async-storage/async-storage", () => ({
getItem: jest.fn().mockResolvedValue(null),
setItem: jest.fn().mockResolvedValue(undefined),
removeItem: jest.fn().mockResolvedValue(undefined),
}));
// __mocks__/@react-native-async-storage/async-storage.js
export default {
getItem: jest.fn((key) => {
return new Promise((resolve) => {
resolve(null);
});
}),
setItem: jest.fn((key, value) => {
return new Promise((resolve) => {
resolve(null);
});
}),
removeItem: jest.fn((key) => {
return new Promise((resolve) => {
resolve(null);
});
}),
};
```
### Storage Adapter Testing
```javascript
// Example test pattern for storage adapter
describe('AsyncStorageAdapter', () => {
let adapter;
let mockStorage;
beforeEach(() => {
mockStorage = {
getItem: jest.fn(),
setItem: jest.fn(),
removeItem: jest.fn(),
};
adapter = new AsyncStorageAdapter(mockStorage);
});
it('should handle storage errors gracefully', async () => {
mockStorage.getItem.mockRejectedValue(new Error('Storage error'));
const result = await adapter.getItem('test-key');
expect(result).toBeNull();
expect(console.error).toHaveBeenCalledWith(
expect.stringContaining('error getting item from storage')
);
});
});
```
## ๐ AsyncStorage Best Practices Discovered
### 1. **Graceful Degradation**
Always provide fallbacks when AsyncStorage is unavailable
### 2. **Error Resilience**
Catch and handle all storage errors without crashing
### 3. **Key Namespacing**
Use consistent, predictable key naming patterns
### 4. **Data Type Safety**
Handle serialization/deserialization consistently
### 5. **Performance Optimization**
Use write-through caching for frequently accessed data
### 6. **Memory Management**
Implement lazy loading to avoid unnecessary memory usage
### 7. **Testing Strategy**
Mock AsyncStorage completely for reliable unit tests
### 8. **Dependency Injection**
Support custom storage implementations for flexibility
### 9. **Default Values**
Always provide sensible defaults for missing data
### 10. **Logging Strategy**
Log storage operations for debugging without exposing sensitive data