Skip to main content
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

// 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[]
}

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

Server-side safety

The noopStorage fallback prevents localStorage access during server-side rendering:
const noopStorage = () => ({
  getItem: () => null,
  setItem: () => undefined,
  removeItem: () => undefined,
})
2

Selective persistence

The partialize function controls which state properties are persisted. Temporary UI state like searchQuery is excluded.
3

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

1

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()
2

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])
3

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

Use immutable updates

Always create new objects/arrays rather than mutating:
// Good: Immutable
messageIds: [...chat.messageIds, newId]

// Bad: Mutation
chat.messageIds.push(newId)

Build docs developers (and LLMs) love