State Management
State Management
Section titled “State Management”Problem and Rationale
Section titled “Problem and Rationale”Zelta Chat requires predictable, centralized state management to handle:
- Complex conversation and message data structures
- Real-time updates from WebSocket connections
- Offline-first capabilities with state persistence
- Optimistic updates for better UX
- Cross-screen data sharing and synchronization
Why Redux Toolkit:
- Reduces Redux boilerplate with
createSliceandcreateAsyncThunk - Built-in Immer for immutable updates
- Excellent TypeScript support
- DevTools integration via Reactotron
- Mature ecosystem with Redux Persist
Design
Section titled “Design”Store Architecture
Section titled “Store Architecture”The Redux store is organized into domain-specific slices:
RootState { auth: AuthState // User session and authentication settings: SettingsState // App configuration conversations: ConversationState // All conversations conversationFilter: ConversationFilterState // Filter criteria selectedConversation: ConversationSelectedState conversationHeader: ConversationHeaderState conversationAction: ConversationActionState conversationTyping: ConversationTypingState sendMessage: SendMessageState audioPlayer: AudioPlayerState localRecordedAudioCache: LocalRecordedAudioCacheState conversationParticipants: ConversationParticipantState contacts: ContactState // Contact management contactLabels: ContactLabelState contactConversations: ContactConversationState labels: LabelState // Tags and labels inboxes: InboxState // Inbox configuration assignableAgents: AssignableAgentState // Available agents notifications: NotificationState // Push notifications notificationFilter: NotificationFilterState teams: TeamState // Team management macros: MacroState // Automated actions dashboardApps: DashboardAppState // Dashboard widgets customAttributes: CustomAttributeState // Custom fields cannedResponses: CannedResponseState // Quick replies}Key Types and Interfaces
Section titled “Key Types and Interfaces”Store Configuration (src/store/index.ts):
interface PersistConfig { key: string; // 'Root' version: number; // Current: 2 storage: Storage; // AsyncStorage migrate: MigrateFn; // State migration logic}Example Slice State (src/store/auth/authSlice.ts):
interface AuthState { user: User | null; accessToken: string | null; headers: AuthHeaders | null; // API authentication headers uiFlags: { isLoggingIn: boolean; isResettingPassword: boolean; }; error: string | null;}Typed Selectors and Hooks:
export type RootState = ReturnType<typeof store.getState>;export type AppDispatch = typeof store.dispatch;
// Usage in componentsconst user = useAppSelector(selectUser);const dispatch = useAppDispatch();Design Invariants
Section titled “Design Invariants”- Single Source of Truth: All application state lives in Redux store
- Read-Only State: Components never mutate state directly
- Pure Reducers: All state changes through reducer functions
- Serializable State: No functions, promises, or class instances in state
- Normalized Data: Related entities stored by ID for efficient updates
Lifecycle and Data Flow
Section titled “Lifecycle and Data Flow”State Initialization Flow
Section titled “State Initialization Flow”App Launch ↓Redux Store Created ↓Redux Persist Rehydration ← AsyncStorage ↓User Logged In? ↓ Yes ↓ NoInitialize User Data Show Auth Screens ↓Fetch Remote Data (Profile, Inboxes, Labels) ↓Subscribe to WebSocket Events ↓App ReadyAction Dispatch Flow
Section titled “Action Dispatch Flow”User Interaction ↓Component Dispatches Action ↓Middleware Processing (Thunk, Listener) ↓Reducer Updates State (Immer) ↓Store Notifies Subscribers ↓Components Re-render (if state changed) ↓Redux Persist Saves State → AsyncStorageReal-time Update Flow
Section titled “Real-time Update Flow”WebSocket Event Received ↓ActionCable Connector Processes Event ↓Dispatch Redux Action (addOrUpdateMessage, updateConversation) ↓Reducer Merges/Updates State ↓UI Updates AutomaticallyImplementation Notes
Section titled “Implementation Notes”Creating a New Slice
Section titled “Creating a New Slice”1. Define State and Types (/src/store/{domain}/{domain}Types.ts):
export interface ExampleState { items: Record<number, ExampleItem>; allIds: number[]; loading: boolean; error: string | null;}
export interface ExampleItem { id: number; name: string; createdAt: string;}2. Create Slice (/src/store/{domain}/{domain}Slice.ts):
import { createSlice, PayloadAction } from '@reduxjs/toolkit';import { exampleActions } from './exampleActions';
const initialState: ExampleState = { items: {}, allIds: [], loading: false, error: null,};
const exampleSlice = createSlice({ name: 'example', initialState, reducers: { addItem: (state, action: PayloadAction<ExampleItem>) => { const item = action.payload; state.items[item.id] = item; if (!state.allIds.includes(item.id)) { state.allIds.push(item.id); } }, removeItem: (state, action: PayloadAction<number>) => { delete state.items[action.payload]; state.allIds = state.allIds.filter(id => id !== action.payload); }, }, extraReducers: builder => { builder .addCase(exampleActions.fetchItems.pending, state => { state.loading = true; state.error = null; }) .addCase(exampleActions.fetchItems.fulfilled, (state, action) => { state.loading = false; action.payload.forEach(item => { state.items[item.id] = item; if (!state.allIds.includes(item.id)) { state.allIds.push(item.id); } }); }) .addCase(exampleActions.fetchItems.rejected, (state, action) => { state.loading = false; state.error = action.error.message || 'Failed to fetch items'; }); },});
export const { addItem, removeItem } = exampleSlice.actions;export default exampleSlice.reducer;3. Create Async Actions (/src/store/{domain}/{domain}Actions.ts):
import { createAsyncThunk } from '@reduxjs/toolkit';import { exampleService } from './exampleService';
export const exampleActions = { fetchItems: createAsyncThunk( 'example/fetchItems', async (_, { rejectWithValue }) => { try { return await exampleService.fetchItems(); } catch (error) { return rejectWithValue(error); } } ),};4. Create Service Layer (/src/store/{domain}/{domain}Service.ts):
import { apiService } from '@/services/APIService';
export const exampleService = { async fetchItems() { const response = await apiService.get<ExampleItem[]>('items'); return response.data; },};5. Create Selectors (/src/store/{domain}/{domain}Selectors.ts):
import { RootState } from '@/store';import { createSelector } from '@reduxjs/toolkit';
export const selectExampleState = (state: RootState) => state.example;
export const selectAllItems = createSelector( selectExampleState, state => state.allIds.map(id => state.items[id]));
export const selectItemById = (id: number) => createSelector(selectExampleState, state => state.items[id]);6. Register in Root Reducer (/src/store/reducers.ts):
import exampleSlice from '@/store/example/exampleSlice';
export const appReducer = combineReducers({ // ... existing reducers example: exampleSlice,});Patterns and Anti-Patterns
Section titled “Patterns and Anti-Patterns”✅ Good Patterns:
// Normalize data by ID for efficient lookupsinterface ConversationState { byId: Record<number, Conversation>; allIds: number[];}
// Use memoized selectors to prevent unnecessary re-rendersexport const selectSortedConversations = createSelector( selectAllConversations, conversations => [...conversations].sort((a, b) => b.timestamp - a.timestamp));
// Handle loading and error states consistentlyinterface SliceState<T> { data: T; loading: boolean; error: string | null;}❌ Anti-Patterns:
// DON'T: Store derived data (compute it in selectors)interface BadState { conversations: Conversation[]; sortedConversations: Conversation[]; // ❌ Duplicated data}
// DON'T: Store non-serializable datainterface BadState { callback: () => void; // ❌ Functions not serializable date: Date; // ❌ Use ISO strings instead}
// DON'T: Deeply nested state structuresinterface BadState { accounts: { [accountId: number]: { conversations: { [id: number]: { messages: Message[]; // ❌ Hard to update }; }; }; };}Middleware and Side Effects
Section titled “Middleware and Side Effects”Redux Persist Middleware:
Configured to:
- Persist entire store to AsyncStorage
- Handle state migrations on version changes
- Ignore Redux Persist actions in serialization checks
Contact Listener Middleware (src/store/contact/contactListener.ts):
Custom middleware for contact-related side effects:
export const contactListenerMiddleware = createListenerMiddleware();
contactListenerMiddleware.startListening({ actionCreator: addConversation, effect: async (action, listenerApi) => { // Extract and cache contact from conversation const contact = extractContactFromConversation(action.payload); listenerApi.dispatch(addContact(contact)); },});Testing Strategy
Section titled “Testing Strategy”Unit Tests for Reducers
Section titled “Unit Tests for Reducers”describe('exampleSlice', () => { it('should handle addItem', () => { const previousState: ExampleState = { items: {}, allIds: [], loading: false, error: null, };
const item = { id: 1, name: 'Test', createdAt: '2024-01-01' }; const nextState = exampleSlice.reducer( previousState, addItem(item) );
expect(nextState.items[1]).toEqual(item); expect(nextState.allIds).toContain(1); });});Integration Tests for Thunks
Section titled “Integration Tests for Thunks”describe('exampleActions', () => { it('should fetch items successfully', async () => { const store = createTestStore(); const items = [{ id: 1, name: 'Test' }];
mockApiService.get.mockResolvedValue({ data: items });
await store.dispatch(exampleActions.fetchItems());
const state = store.getState(); expect(state.example.items[1]).toEqual(items[0]); expect(state.example.loading).toBe(false); });});Selector Tests
Section titled “Selector Tests”describe('selectors', () => { it('should select all items sorted', () => { const state = createMockRootState({ example: { items: { 1: itemA, 2: itemB }, allIds: [1, 2], }, });
const result = selectSortedItems(state); expect(result).toEqual([itemB, itemA]); });});Performance Considerations
Section titled “Performance Considerations”Memoization
Section titled “Memoization”Use createSelector from Reselect to memoize expensive computations:
export const selectFilteredConversations = createSelector( [selectAllConversations, selectCurrentFilter], (conversations, filter) => { // This expensive operation only runs when inputs change return conversations.filter(conv => matchesFilter(conv, filter)); });Normalized State Structure
Section titled “Normalized State Structure”Normalize relational data to prevent deep nesting and improve update performance:
// ✅ Good: Normalizedinterface ConversationState { byId: Record<number, Conversation>; allIds: number[];}
interface MessageState { byId: Record<number, Message>; byConversationId: Record<number, number[]>; // conversationId -> messageIds}Selective Subscriptions
Section titled “Selective Subscriptions”Use specific selectors to prevent unnecessary re-renders:
// ❌ Bad: Re-renders on any state changeconst entireState = useAppSelector(state => state);
// ✅ Good: Only re-renders when specific data changesconst conversations = useAppSelector(selectAllConversations);Extending This Concept
Section titled “Extending This Concept”Adding Custom Middleware
Section titled “Adding Custom Middleware”Create custom middleware for cross-cutting concerns:
import { createListenerMiddleware } from '@reduxjs/toolkit';
export const analyticsMiddleware = createListenerMiddleware();
analyticsMiddleware.startListening({ predicate: (action) => action.type.startsWith('conversation/'), effect: (action, listenerApi) => { AnalyticsHelper.track(action.type, action.payload); },});
// Register in storeconst middlewares: Middleware[] = [ contactListenerMiddleware.middleware, analyticsMiddleware.middleware,];State Migration
Section titled “State Migration”Handle breaking state changes between app versions:
const persistConfig = { key: 'Root', version: 3, // Increment on breaking changes storage: AsyncStorage, migrate: async (state: any) => { if (!state?._persist?.version || state._persist.version < 3) { // Migration logic for v2 -> v3 return { ...initialState, settings: state.settings, // Preserve settings }; } return state; },};Global State Reset
Section titled “Global State Reset”Handle logout and data clearing:
// In src/store/index.tsconst rootReducer = (state: ReturnType<typeof appReducer>, action: AnyAction) => { if (action.type === 'auth/logout') { const initialState = appReducer(undefined, { type: 'INIT' }); return { ...initialState, settings: state.settings }; // Preserve settings } return appReducer(state, action);};Further Reading
Section titled “Further Reading”- Authentication Flow - User session management
- Real-time Communication - WebSocket integration with Redux
- API Integration - API service layer
- Conversations Feature - Conversation state management
- Redux Toolkit Documentation