Skip to content

State Management

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 createSlice and createAsyncThunk
  • Built-in Immer for immutable updates
  • Excellent TypeScript support
  • DevTools integration via Reactotron
  • Mature ecosystem with Redux Persist

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
}

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 components
const user = useAppSelector(selectUser);
const dispatch = useAppDispatch();
  1. Single Source of Truth: All application state lives in Redux store
  2. Read-Only State: Components never mutate state directly
  3. Pure Reducers: All state changes through reducer functions
  4. Serializable State: No functions, promises, or class instances in state
  5. Normalized Data: Related entities stored by ID for efficient updates
App Launch
Redux Store Created
Redux Persist Rehydration ← AsyncStorage
User Logged In?
↓ Yes ↓ No
Initialize User Data Show Auth Screens
Fetch Remote Data (Profile, Inboxes, Labels)
Subscribe to WebSocket Events
App Ready
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 → AsyncStorage
WebSocket Event Received
ActionCable Connector Processes Event
Dispatch Redux Action (addOrUpdateMessage, updateConversation)
Reducer Merges/Updates State
UI Updates Automatically

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,
});

✅ Good Patterns:

// Normalize data by ID for efficient lookups
interface ConversationState {
byId: Record<number, Conversation>;
allIds: number[];
}
// Use memoized selectors to prevent unnecessary re-renders
export const selectSortedConversations = createSelector(
selectAllConversations,
conversations => [...conversations].sort((a, b) => b.timestamp - a.timestamp)
);
// Handle loading and error states consistently
interface 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 data
interface BadState {
callback: () => void; // ❌ Functions not serializable
date: Date; // ❌ Use ISO strings instead
}
// DON'T: Deeply nested state structures
interface BadState {
accounts: {
[accountId: number]: {
conversations: {
[id: number]: {
messages: Message[]; // ❌ Hard to update
};
};
};
};
}

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));
},
});
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);
});
});
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);
});
});
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]);
});
});

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));
}
);

Normalize relational data to prevent deep nesting and improve update performance:

// ✅ Good: Normalized
interface ConversationState {
byId: Record<number, Conversation>;
allIds: number[];
}
interface MessageState {
byId: Record<number, Message>;
byConversationId: Record<number, number[]>; // conversationId -> messageIds
}

Use specific selectors to prevent unnecessary re-renders:

// ❌ Bad: Re-renders on any state change
const entireState = useAppSelector(state => state);
// ✅ Good: Only re-renders when specific data changes
const conversations = useAppSelector(selectAllConversations);

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 store
const middlewares: Middleware[] = [
contactListenerMiddleware.middleware,
analyticsMiddleware.middleware,
];

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;
},
};

Handle logout and data clearing:

// In src/store/index.ts
const 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);
};