The WhatsApp Chat application uses Zustand for state management, providing a simple yet powerful solution for managing chat state, messages, contacts, and user interactions.
ChatStore overview
The ChatStore is the single source of truth for all application state:
// lib/chat/state.ts
import { create } from "zustand"
import { createJSONStorage, persist } from "zustand/middleware"
export interface ChatStore extends ChatStateSnapshot {
profile: Profile
searchQuery: string
showArchived: boolean
// Core actions
setActiveChat: (chatId?: string) => void
setSearchQuery: (value: string) => void
pushSearchHistory: (value: string) => void
// Draft management
updateDraft: (chatId: string, values: DraftUpdate) => void
clearDraft: (chatId: string) => void
// Message operations
sendComposerPayload: (chatId: string, payload: ComposerPayload) => string | undefined
receiveMessage: (chatId: string, message: Message) => void
markChatAsRead: (chatId: string) => void
// Presence
setTypingIndicator: (chatId: string, authorId: string, isTyping: boolean) => void
// Chat management
toggleArchive: (chatId: string) => void
resetStore: () => void
}
State organization
The state is organized into normalized dictionaries for efficient lookups:
Core state structure
export interface ChatStateSnapshot {
chats: Record<string, Chat> // Chat metadata indexed by chat ID
messages: Record<string, Message> // All messages indexed by message ID
contacts: Record<string, Contact> // Contact profiles indexed by contact ID
drafts: Record<string, DraftMessage> // Unsent drafts indexed by chat ID
typingIndicators: TypingIndicator[] // Array of active typing indicators
searchHistory: ChatSearchHistoryItem[] // Recent search queries
activeChatId?: string // Currently selected chat
}
Using dictionaries (objects with ID keys) instead of arrays enables O(1) lookups and efficient updates without array iterations.
Data types
Message
Chat
Contact
DraftMessage
// lib/chat/types.ts
export interface Message {
id: string
chatId: string
authorId: string
contentType: "text" | "image" | "audio"
text?: string
media?: MediaAttachment
status: MessageStatus // "queued" | "sending" | "sent" | "delivered" | "read"
createdAt: string
updatedAt?: string
replyToId?: string
isForwarded?: boolean
reactions?: Reaction[]
}
export interface Chat {
id: string
contactId: string
messageIds: string[] // References to messages in state.messages
unreadCount: number
archived?: boolean
muted?: boolean
lastActivityAt: string
lastMessagePreview?: string
}
export interface Contact {
id: string
name: string
phoneNumber: string
about: string
avatarUrl: string
isOnline: boolean
lastSeenAt: string
favorite?: boolean
pinned?: boolean
}
export interface DraftMessage {
chatId: string
text: string
attachments: MediaAttachment[]
}
Store creation and persistence
The store is created with Zustand’s persist middleware for automatic localStorage synchronization:
export const useChatStore = create<ChatStore>()(
persist(
(set, get) => ({
...initialState,
// Actions defined here
}),
{
name: "chat-store",
version: 1,
storage: createJSONStorage(() =>
typeof window === "undefined" ? noopStorage() : localStorage
),
partialize: (state) => ({
chats: state.chats,
messages: state.messages,
contacts: state.contacts,
drafts: state.drafts,
searchHistory: state.searchHistory,
profile: state.profile,
activeChatId: state.activeChatId,
showArchived: state.showArchived,
}),
}
)
)
Server-side safety
The noopStorage fallback prevents localStorage access during server-side rendering:const noopStorage = () => ({
getItem: () => null,
setItem: () => undefined,
removeItem: () => undefined,
})
Selective persistence
The partialize function controls which state properties are persisted. Temporary UI state like searchQuery is excluded.
Version control
The version field enables migration logic if the state schema changes in future updates.
Core actions
Selecting a chat
When a user clicks on a conversation, setActiveChat updates the active chat and clears unread count:
setActiveChat: (chatId) => {
set((state) => {
if (!chatId) {
return { ...state, activeChatId: undefined }
}
if (!state.chats[chatId]) {
return state // Chat doesn't exist
}
const nextChats: Record<string, Chat> = {
...state.chats,
[chatId]: {
...state.chats[chatId],
unreadCount: 0, // Mark as read
},
}
return {
...state,
chats: nextChats,
activeChatId: chatId,
}
})
}
Sending a message
The sendComposerPayload action creates a new message, adds it to the chat, and schedules status updates:
sendComposerPayload: (chatId, payload) => {
const chat = get().chats[chatId]
if (!chat) return undefined
const id = generateMessageId()
const timestamp = new Date().toISOString()
const message: Message = {
id,
chatId,
authorId: get().profile.id,
contentType: payload.contentType ?? (payload.media ? payload.media.type : "text"),
text: payload.text,
media: payload.media,
status: "queued",
createdAt: timestamp,
updatedAt: timestamp,
}
set((state) => {
const nextMessages = { ...state.messages, [id]: message }
const nextChat: Chat = {
...state.chats[chatId],
messageIds: [...state.chats[chatId].messageIds, id],
unreadCount: 0,
lastActivityAt: timestamp,
lastMessagePreview: derivePreview(message),
}
return {
...state,
messages: nextMessages,
chats: { ...state.chats, [chatId]: nextChat },
drafts: { ...state.drafts, [chatId]: { chatId, text: "", attachments: [] } },
}
})
scheduleLifecycle(id, get, set) // Start status progression
return id
}
The message is immediately added to state with queued status, providing instant feedback. The scheduleLifecycle function then handles automatic progression through message statuses.
Managing drafts
Drafts preserve unsent message text and attachments:
updateDraft: (chatId, values) => {
set((state) => {
const prev = state.drafts[chatId] ?? { chatId, text: "", attachments: [] }
return {
...state,
drafts: {
...state.drafts,
[chatId]: {
...prev,
...values,
attachments: values.attachments ?? prev.attachments ?? [],
},
},
}
})
},
clearDraft: (chatId) => {
set((state) => {
const nextDrafts = { ...state.drafts }
delete nextDrafts[chatId]
return { ...state, drafts: nextDrafts }
})
}
Receiving messages
Incoming messages from other users are handled by receiveMessage:
receiveMessage: (chatId, message) => {
set((state) => {
const nextMessages = { ...state.messages, [message.id]: message }
const chat = state.chats[chatId]
if (!chat) return state
const nextChat: Chat = {
...chat,
messageIds: [...chat.messageIds, message.id],
unreadCount:
state.activeChatId === chatId ? 0 : Math.min(chat.unreadCount + 1, 99),
lastActivityAt: message.createdAt,
lastMessagePreview: derivePreview(message),
}
return {
...state,
messages: nextMessages,
chats: { ...state.chats, [chatId]: nextChat },
}
})
}
If the message arrives in the currently active chat, unreadCount stays at 0. Otherwise, it increments (capped at 99 like WhatsApp).
Typing indicators
The setTypingIndicator action manages real-time typing status:
setTypingIndicator: (chatId, authorId, isTyping) => {
set((state) => {
const exists = state.typingIndicators.find(
(indicator) => indicator.chatId === chatId && indicator.authorId === authorId
)
if (isTyping && !exists) {
const indicator: TypingIndicator = {
chatId,
authorId,
startedAt: new Date().toISOString(),
}
return { ...state, typingIndicators: [...state.typingIndicators, indicator] }
}
if (!isTyping && exists) {
return {
...state,
typingIndicators: state.typingIndicators.filter(
(indicator) => !(indicator.chatId === chatId && indicator.authorId === authorId)
),
}
}
return state
})
}
Selectors
Pre-built selectors provide convenient access to derived state:
export const chatSelectors = {
chatList: (state: ChatStore) => Object.values(state.chats),
activeChat: (state: ChatStore) =>
state.activeChatId ? state.chats[state.activeChatId] : undefined,
activeMessages: (state: ChatStore) => {
if (!state.activeChatId) return []
const chat = state.chats[state.activeChatId]
return chat.messageIds.map((id) => state.messages[id]).filter(Boolean)
},
contacts: (state: ChatStore) => Object.values(state.contacts),
}
Using selectors in components
import { useChatStore, chatSelectors } from "@/lib/chat/state"
function ChatList() {
// Subscribe to derived chat list
const chats = useChatStore(chatSelectors.chatList)
// Subscribe to specific state slice
const activeChatId = useChatStore((state) => state.activeChatId)
// Call actions
const setActiveChat = useChatStore((state) => state.setActiveChat)
return (
<div>
{chats.map((chat) => (
<ChatItem
key={chat.id}
chat={chat}
isActive={chat.id === activeChatId}
onClick={() => setActiveChat(chat.id)}
/>
))}
</div>
)
}
Best practices
Use selective subscriptions
Only subscribe to the state you need to minimize re-renders:// Good: Only re-renders when activeChatId changes
const activeChatId = useChatStore((state) => state.activeChatId)
// Avoid: Re-renders on any state change
const state = useChatStore()
Memoize derived state
Use useMemo for computations based on store state:const activeMessages = useMemo(() => {
const chat = chats[activeChatId]
return chat?.messageIds.map((id) => messages[id]) ?? []
}, [chats, activeChatId, messages])
Batch related updates
Update multiple state properties in a single set call:// Good: Single state update
set((state) => ({
messages: { ...state.messages, [id]: message },
chats: { ...state.chats, [chatId]: updatedChat },
}))
// Avoid: Multiple set calls
set((state) => ({ messages: { ...state.messages, [id]: message } }))
set((state) => ({ chats: { ...state.chats, [chatId]: updatedChat } }))
Use immutable updates
Always create new objects/arrays rather than mutating:// Good: Immutable
messageIds: [...chat.messageIds, newId]
// Bad: Mutation
chat.messageIds.push(newId)