Канон и альтернатива. Канон стейт-менеджмента на платформе — 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 (кэш, инвалидация, ретраи).
- Клиентский (активная комната, статус 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:
tsximport { 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 }) }
При отправке сообщения — инвалидируем кэш истории:
tsqueryClient.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 после проверки токена:
tsximport { 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_id | WS at-least-once не даёт дублей в UI | Доп. память (Set id), нужен cleanup |
staleTime/cacheTime настройки | Баланс свежести данных и нагрузки | Некорректные значения → stale-данные |
🧩 Проверь себя
🔗 Мостик к Модулю 5
Состояние управляемо. Превратим приложение в PWA.