Канон и альтернатива. Канон стейт-менеджмента на платформе — Effector (ui-track, модуль 3). Здесь основной стек — Zustand + TanStack Query: это осознанная альтернатива для serverless/PWA-приложения; когда на ней упираются и как уходят на Effector — модуль 9.

Состояние — Zustand + TanStack Query

Из M3: мы построили apiClient и wsClient. Теперь подключаем управление состоянием: TanStack Query для серверных данных и Zustand для клиентского стейта. Решение по стейту — в plans/state-libraries-decision.md.

Перед началом: Модули 1–3. Инструменты: Zustand, TanStack Query. Время: ~6 ч.


🔥 Инцидент. Сообщения хранятся в useState. При новом сообщении ре-рендерится весь чат (1000 сообщений). Профайлер: 800 мс на каждый render. Контекст провайдит слишком много — любой чих перерисовывает всё. Нужен Zustand: селекторы, точечные обновления, без Provider.


📖 Теория

Фокус: разделить серверный и клиентский стейт — это снимает половину боли чата.

Два рода состояния

Серверный стейт (TanStack Query) vs клиентский стейт (Zustand)
  • Серверный (список чатов, история, поиск) → TanStack Query (кэш, инвалидация, ретраи).
  • Клиентский (активная комната, статус WS, optimistic, outbox, ввод) → Zustand.

QueryClient setup

ts
// src/app/queryClient.ts
import { QueryClient } from "@tanstack/react-query"

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retry: 2,
      staleTime: 60_000,            // 1 мин — данные «свежие»
      cacheTime: 5 * 60_000,        // 5 мин в кэше после размонтирования
      refetchOnWindowFocus: false,  // не перезапрашивать при возврате на вкладку
    },
  },
})

Подключение в main.tsx:

tsx
import { QueryClientProvider } from "@tanstack/react-query"

createRoot(document.getElementById("root")!).render(
  <QueryClientProvider client={queryClient}>
    <App />
  </QueryClientProvider>
)

TanStack Query — серверный стейт

ts
// features/messages/service/useHistory.ts
export function useChatHistory(roomId: string) {
  return useInfiniteQuery({
    queryKey: ["messages", roomId],
    queryFn: ({ pageParam }) => chatApi.getHistory(roomId, pageParam),
    getNextPageParam: (last) => last.next_cursor ?? undefined,
    enabled: !!roomId,
  })
}

// features/chats/service/useChats.ts
export function useChats() {
  return useQuery({ queryKey: ["rooms"], queryFn: chatApi.getRooms })
}

При отправке сообщения — инвалидируем кэш истории:

ts
queryClient.invalidateQueries({ queryKey: ["messages", roomId] })

Zustand — клиентский стейт

ts
// src/store/chatStore.ts
interface ChatState {
  activeRoomId: string | null
  wsStatus: "connecting" | "connected" | "disconnected"
  messages: Message[]
  messageIds: Set<string>             // для дедупликации
  optimisticMessages: Record<string, Message>

  setActiveRoom: (id: string) => void
  setWsStatus: (s: ChatState["wsStatus"]) => void
  addOptimistic: (msg: Message) => void
  confirmSent: (optId: string, realId: string) => void
  sendFailed: (optId: string) => void
  receiveMessage: (msg: Message) => void  // из WebSocket
}

export const useChatStore = create<ChatState>((set) => ({
  activeRoomId: null,
  wsStatus: "disconnected",
  messages: [],
  messageIds: new Set(),
  optimisticMessages: {},

  setActiveRoom: (id) => set({ activeRoomId: id }),
  setWsStatus: (s) => set({ wsStatus: s }),

  addOptimistic: (msg) =>
    set((s) => ({ optimisticMessages: { ...s.optimisticMessages, [msg.id]: msg } })),

  confirmSent: (optId, realId) =>
    set((s) => {
      const msg = s.optimisticMessages[optId]
      if (!msg) return s
      return {
        optimisticMessages: { ...s.optimisticMessages, [optId]: { ...msg, id: realId, status: "sent" } },
      }
    }),

  sendFailed: (optId) =>
    set((s) => ({
      optimisticMessages: { ...s.optimisticMessages, [optId]: { ...s.optimisticMessages[optId], status: "failed" } },
    })),

  receiveMessage: (msg) =>
    set((s) => {
      if (s.messageIds.has(msg.id)) return s  // дедупликация
      return {
        messages: [...s.messages, msg],
        messageIds: new Set([...s.messageIds, msg.id]),
      }
    }),
}))

Optimistic UI — полный flow sendMessage

ts
// features/messages/service/sendMessage.ts
import { useChatStore } from "@/store/chatStore"
import { chatApi } from "@/infrastructure/api/chatApi"
import { queryClient } from "@/app/queryClient"

export async function sendMessage(roomId: string, text: string) {
  // 1. Guard
  if (!roomId) throw new Error("roomId is required")
  const trimmed = text.trim()
  if (!trimmed) throw new Error("Сообщение не может быть пустым")
  if (trimmed.length > 4000) throw new Error("Сообщение слишком длинное")

  const requestId = crypto.randomUUID()
  const optimisticId = "opt-" + requestId
  const store = useChatStore.getState()

  // 2. Optimistic update (UI мгновенно показывает сообщение)
  store.addOptimistic({
    id: optimisticId,
    roomId,
    senderId: "me",
    text: trimmed,
    createdAt: new Date().toISOString(),
    status: "sending",
  })

  try {
    // 3. API call
    const result = await chatApi.sendMessage(roomId, trimmed, requestId)

    // 4. Confirm (меняем статус на sent, заменяем opt ID на реальный)
    store.confirmSent(optimisticId, result.id)

    // 5. Инвалидируем кэш истории (TanStack Query перечитает)
    queryClient.invalidateQueries({ queryKey: ["messages", roomId] })

    return result
  } catch (err) {
    // 6. Fail (статус → failed, UI показывает кнопку «повторить»)
    store.sendFailed(optimisticId)
    throw err
  }
}

WS → store wiring

Связываем WebSocket-события с Zustand-стором при старте приложения:

ts
// src/app/initWs.ts
import { wsClient } from "@/infrastructure/ws/wsClient"
import { useChatStore } from "@/store/chatStore"

export function initWebSocket() {
  const token = localStorage.getItem("access_token")
  if (!token) return

  wsClient.setToken(token)
  wsClient.connect()

  // Входящее сообщение → дедупликация → стор → UI обновляется
  wsClient.on("message", (event) => {
    useChatStore.getState().receiveMessage(event.message as Message)
  })

  // Статус соединения
  wsClient.on("open", () => useChatStore.getState().setWsStatus("connected"))
  wsClient.on("close", () => useChatStore.getState().setWsStatus("disconnected"))
}

Вызывается в main.tsx после проверки токена:

tsx
import { initWebSocket } from "./app/initWs"
initWebSocket()

Дедупликация по message_id: бэкенд (at-least-once из YMQ/YDS) может доставить сообщение дважды. receiveMessage проверяет messageIds.has(msg.id) и игнорирует дубль. Это критично для UX — иначе на экране появятся повторяющиеся сообщения.

Auth: токены, формы входа и ProtectedRoute

Авторизация — это клиентский стейт (токен, пользователь). Хранится в Zustand + localStorage:

ts
// src/features/auth/model/authStore.ts
interface AuthState {
  user: User | null
  isAuthenticated: boolean
  login: (email: string, password: string) => Promise<void>
  logout: () => void
}

export const useAuthStore = create<AuthState>((set) => ({
  user: null,
  isAuthenticated: !!localStorage.getItem("access_token"),

  login: async (email, password) => {
    const { access_token, user } = await chatApi.login(email, password)
    localStorage.setItem("access_token", access_token)
    initWebSocket() // подключаем WS после получения токена
    set({ user, isAuthenticated: true })
  },

  logout: () => {
    localStorage.removeItem("access_token")
    wsClient.disconnect()
    set({ user: null, isAuthenticated: false })
  },
}))

ProtectedRoute — guard для защищённых страниц:

tsx
// src/app/ProtectedRoute.tsx
import { Navigate } from "react-router-dom"
import { useAuthStore } from "@/features/auth/model/authStore"

export function ProtectedRoute({ children }: { children: React.ReactNode }) {
  const isAuth = useAuthStore((s) => s.isAuthenticated)
  if (!isAuth) return <Navigate to="/login" replace />
  return <>{children}</>
>
}

Использование в роутинге:

tsx
<Route path="/login" element={<LoginForm />} />
<Route path="/chat/:roomId" element={
  <ProtectedRoute><ChatRoom /></ProtectedRoute>
} />

Форма входа:

tsx
// src/features/auth/ui/LoginForm.tsx
function LoginForm() {
  const login = useAuthStore((s) => s.login)
  const [email, setEmail] = useState("")
  const [password, setPassword] = useState("")
  const [error, setError] = useState("")

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    setError("")
    try { await login(email, password) }
    catch { setError("Неверный email или пароль") }
  }

  return (
    <form onSubmit={handleSubmit}>
      <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email" required />
      <input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="Пароль" required />
      {error && <span className="error">{error}</span>}
      <button type="submit">Войти</button>
    </form>
  )
}

Компоненты чата (сборка всего)

Теперь собираем ChatRoom — главный экран чата, использующий всё: Zustand store, TanStack Query, optimistic flow и WebSocket:

tsx
// src/features/chat-room/ui/ChatRoom.tsx
import { useChatStore } from "@/store/chatStore"
import { useChatHistory } from "@/features/messages/service/useHistory"
import { sendMessage } from "@/features/messages/service/sendMessage"

function ChatRoom({ roomId }: { roomId: string }) {
  // История из Query (серверный стейт)
  const { data, fetchNextPage, hasNextPage } = useChatHistory(roomId)
  const history = data?.pages.flatMap((p) => p.messages) ?? []

  // Клиентский стейт
  const wsStatus = useChatStore((s) => s.wsStatus)
  const optimistic = useChatStore((s) =>
    Object.values(s.optimisticMessages).filter((m) => m.roomId === roomId)
  )

  const [text, setText] = useState("")
  const messagesEndRef = useRef<HTMLDivElement>(null)

  // Автопрокрутка вниз
  useEffect(() => {
    messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
  }, [history.length, optimistic.length])

  const handleSend = async () => {
    if (!text.trim()) return
    try { await sendMessage(roomId, text); setText("") }
    catch { /* ошибка уже в optimisticMessages (status: failed) → UI покажет «повторить» */ }
  }

  return (
    <div className="chat-room">
      <header className="chat-header">
        Комната: {roomId} · WS: {wsStatus}
      </header>

      <div className="messages-list">
        {hasNextPage && <button onClick={() => fetchNextPage()}>Загрузить ещё</button>}
        {history.map((m) => <MessageBubble key={m.id} msg={m} />)}
        {optimistic.map((m) => <MessageBubble key={m.id} msg={m} optimistic />)}
        <div ref={messagesEndRef} />
      </div>

      <div className="message-input">
        <input
          value={text}
          onChange={(e) => setText(e.target.value)}
          onKeyDown={(e) => e.key === "Enter" && handleSend()}
          placeholder="Написать сообщение..."
        />
        <button onClick={handleSend} disabled={!text.trim()}>Отправить</button>
      </div>
    </div>
  )
}

Список чатов:

tsx
// src/features/chats/ui/ChatList.tsx
function ChatList() {
  const { data: rooms, isLoading } = useChats()
  const setActiveRoom = useChatStore((s) => s.setActiveRoom)

  if (isLoading) return <div>Загрузка...</div>

  return (
    <ul className="chat-list">
      {rooms?.map((room) => (
        <li key={room.id} onClick={() => setActiveRoom(room.id)}>
          <Link to={`/chat/${room.id}`}>{room.name}</Link>
        </li>
      ))}
    </ul>
  )
}

Как это работает вместе: ChatList получает данные из Query (серверный кэш). Клик → setActiveRoom в Zustand → навигация → ChatRoom открывается. История подгружается через useChatHistory (Query), новые сообщения приходят через WS → receiveMessage в Zustand → UI обновляется мгновенно. Отправка → sendMessage (optimistic + API + confirm).

Альтернативы (обзор)

  • MobX 6 — OOP-реактивность вложенных объектов; chat.messages.push(...) удобнее, но скрытые перерендеры и хуже поток-прозрачность для обучения.
  • Effector — путь масштабирования (команда 10+, медиа-фичи). См. модуль 9.

Практика

  • Реализовать sendMessage (optimistic → API → confirm/fail) и searchChats (Query + debounce).
  • Подключить WS-события к стору (initWebSocket) и проверить дедупликацию (отправить дубль вручную).
  • Настроить QueryClient и инвалидацию истории после отправки.

💱 Компромиссы (Trade-offs)

РешениеПлюсыМинусы
TanStack Query (серверный стейт)Кэш, инвалидация, ретраи, infinite scrollЛишний слой для простых запросов
Zustand (клиентский стейт)Селекторы, точечные ререндеры, без ProviderГлобальные сторы на масштабе (см. М9)
Optimistic UIМгновенный отклик, откат при ошибкеСложнее отладка, риск рассинхрона UI↔сервер
Дедупликация по message_idWS at-least-once не даёт дублей в UIДоп. память (Set id), нужен cleanup
staleTime/cacheTime настройкиБаланс свежести данных и нагрузкиНекорректные значения → stale-данные

🧩 Проверь себя

1. TanStack Query хранит серверное состояние; Zustand — ...
2. После мутации (отправка сообщения) обновить кэш через...
3. Zustand vs Redux для среднего приложения?
4. Оптимистичное обновление (useMutation onMutate) — для чего?
5. TanStack Query staleTime vs cacheTime — разница?

🔗 Мостик к Модулю 5

Состояние управляемо. Превратим приложение в PWA.