No desenvolvimento de aplicações React complexas, o gerenciamento de estado é crucial para garantir a performance e a manutenibilidade do código. Recentemente, realizei um comparativo entre duas abordagens populares: a Context API nativa do React e a biblioteca Zustand. Os resultados foram bastante reveladores, especialmente no que diz respeito ao número de re-renderizações desnecessárias. Para visualizar o impacto dessas re-renderizações, utilizei o React Scan, que destaca visualmente os componentes que foram re-renderizados e o motivo, com base nas props ou estados alterados.
O Cenário de Teste: Um Chat Simples 💬
Para o teste, implementei um chat simples utilizando ambas as abordagens. O objetivo era observar como diferentes partes da aplicação reagem a uma ação comum: o envio de uma nova mensagem e o recebimento de uma resposta.
🧱 Implementação com Context API
A implementação com Context API envolveu a criação de um ChatContext
para disponibilizar o estado e as funções de manipulação (como messages
, isTyping
, sendMessage
e titlePage
).
Código do Provider (Context):
import { createContext, useContext, useState, useEffect, type ReactNode, } from 'react'; import type { ChatMessage } from '../types/chat'; import { faker } from '@faker-js/faker'; interface ChatContextType { messages: ChatMessage[]; isTyping: boolean; sendMessage: (content: string) => void; titlePage: string; setTitlePage: React.Dispatch<React.SetStateAction<string>>; } const STORAGE_KEY = 'chat_messages'; const ChatContext = createContext<ChatContextType | undefined>(undefined); export function ChatProvider({ children }: { children: ReactNode }) { const [messages, setMessages] = useState<ChatMessage[]>([]); const [isTyping, setIsTyping] = useState(false); const [titlePage, setTitlePage] = useState('Tip: Lab Context API'); useEffect(() => { const saved = localStorage.getItem(STORAGE_KEY); if (saved) { try { const parsed: ChatMessage[] = JSON.parse(saved); parsed.forEach((msg) => (msg.dateTime = new Date(msg.dateTime))); setMessages(parsed); } catch (e) { console.error('Failed to parse chat from localStorage:', e); } } }, []); useEffect(() => { localStorage.setItem(STORAGE_KEY, JSON.stringify(messages)); }, [messages]); const sendMessage = (content: string) => { setTitlePage('Novo title após enviar mensagem - ctx'); const humanMessage: ChatMessage = { content, author: 'human', dateTime: new Date(), type: 'text', }; setMessages((prev) => [...prev, humanMessage]); setIsTyping(true); setTimeout(() => { const botMessage: ChatMessage = { content: faker.hacker.phrase(), author: 'bot', dateTime: new Date(), type: 'text', }; setMessages((prev) => [...prev, botMessage]); setIsTyping(false); }, 1500); }; return ( <ChatContext.Provider value={{ messages, sendMessage, isTyping, titlePage, setTitlePage }} > {children} </ChatContext.Provider> ); } export function useChat() { const ctx = useContext(ChatContext); if (!ctx) throw new Error('useChat must be used within a ChatProvider'); return ctx; }
Os componentes TitlePageCtx
, PromptCtx
e MessageListCtx
consumiam os dados do contexto utilizando o hook useChat
.
Código dos Consumidores do Contexto:
// TitlePageCtx.tsx const TitlePageCtx = memo(function TitlePageCtx() { const { titlePage } = useChat(); return ( <header className='px-4 py-3 border-b border-zinc-700'> <div className='text-center text-white text-lg font-semibold'> {titlePage} </div> </header> ); }); // PromptCtx.tsx const PromptCtx = memo(function PromptCtx() { const { sendMessage } = useChat(); const [input, setInput] = useState(''); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (!input.trim()) return; sendMessage(input); setInput(''); }; return ( <form onSubmit={handleSubmit} className='flex items-center gap-3 max-w-3xl mx-auto' > <input autoFocus type='text' value={input} onChange={(e) => setInput(e.target.value)} className='flex-1 rounded-lg bg-zinc-800 text-white border border-zinc-700 px-4 py-3 focus:outline-none focus:ring-2 focus:ring-blue-500' placeholder='Send a message...' /> <button type='submit' className='bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition' > Send </button> </form> ); }); // MessageListCtx.tsx const MessageListCtx = memo(function MessageListCtx() { const { messages, isTyping } = useChat(); const bottomRef = useRef<HTMLDivElement | null>(null); useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages, isTyping]); const renderedMessages = useMemo(() => { return messages.map((msg, i) => ( <div key={i} className={`whitespace-pre-wrap rounded-xl px-4 py-3 text-sm ${ msg.author === 'human' ? 'bg-blue-600 text-white self-end' : 'bg-zinc-800 border border-zinc-700 self-start' }`} > {msg.content} </div> )); }, [messages]); if (messages.length === 0) { return ( <div className='text-center text-zinc-500 mt-8'> No messages yet. Start the conversation below! 👇 </div> ); } return ( <div className='flex flex-col gap-4 max-w-3xl mx-auto'> {renderedMessages} {isTyping && ( <div className='italic text-sm text-zinc-400 animate-pulse'> Bot is typing... </div> )} <div ref={bottomRef} /> </div> ); });
Resultado com Context API: Conforme o print abaixo, toda vez que uma nova mensagem era enviada (e a resposta recebida), mesmo que apenas o estado de messages
fosse alterado, todos os componentes filhos do ChatProvider
eram re-renderizados. Isso incluía TitlePageCtx
e PromptCtx
, mesmo que suas props relevantes não tivessem mudado significativamente (no caso do TitlePageCtx
, o valor da titlePage
era atualizado a cada envio, mesmo que para o mesmo texto, forçando uma re-renderização).
⚛️ Implementação com Zustand
Na implementação com Zustand, utilizei duas stores distintas: useChatStore
para o estado das mensagens e useApiStore
para o estado de isTyping
.
Código das Stores Zustand:
// useChatStore.ts import { create } from 'zustand'; import type { ChatMessage } from '../types/chat'; import { faker } from '@faker-js/faker'; import { useApiStore } from './useApiStore'; const STORAGE_KEY = 'chat_messages'; interface ChatStore { messages: ChatMessage[]; sendMessage: (content: string) => void; loadMessagesFromStorage: () => void; titlePage: string; } export const useChatStore = create<ChatStore>((set, get) => ({ messages: [], loadMessagesFromStorage: () => { if (typeof window === 'undefined') return; const saved = localStorage.getItem(STORAGE_KEY); if (saved) { try { const parsed: ChatMessage[] = JSON.parse(saved); parsed.forEach((msg) => (msg.dateTime = new Date(msg.dateTime))); set({ messages: parsed }); } catch (e) { console.error('Failed to parse chat from localStorage:', e); } } }, sendMessage: (content: string) => { set({ titlePage: 'Novo title após enviar mensagem - ztd' }); const humanMessage: ChatMessage = { content, author: 'human', dateTime: new Date(), type: 'text', }; const newMessages = [...get().messages, humanMessage]; set({ messages: newMessages }); if (typeof window !== 'undefined') { localStorage.setItem(STORAGE_KEY, JSON.stringify(newMessages)); } useApiStore.getState().setIsTyping(true); setTimeout(() => { const botMessage: ChatMessage = { content: faker.hacker.phrase(), author: 'bot', dateTime: new Date(), type: 'text', }; const updatedMessages = [...get().messages, botMessage]; set({ messages: updatedMessages }); if (typeof window !== 'undefined') { localStorage.setItem(STORAGE_KEY, JSON.stringify(updatedMessages)); } useApiStore.getState().setIsTyping(false); }, 1500); }, titlePage: 'Tip: Lab Zustand', })); // useApiStore.ts import { create } from 'zustand'; interface ApiStore { isTyping: boolean; setIsTyping: (val: boolean) => void; } export const useApiStore = create<ApiStore>((set) => ({ isTyping: false, setIsTyping: (val) => set({ isTyping: val }), }));
Os componentes foram adaptados para selecionar apenas as partes específicas do estado que necessitavam de cada store, utilizando a função de seletor do Zustand.
Código dos consumidores das Stores Zustand:
// TitlePageZtd.tsx const TitlePageZtd = memo(function TitlePageZtd() { const titlePage = useChatStore((state) => state.titlePage); return ( <header className='px-4 py-3 border-b border-zinc-700'> <div className='text-center text-white text-lg font-semibold'> {titlePage} </div> </header> ); }); // PromptZtd.tsx const PromptZtd = memo(function PromptZtd() { const sendMessage = useChatStore((state) => state.sendMessage); const [input, setInput] = useState(''); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (!input.trim()) return; sendMessage(input); setInput(''); }; return ( <form onSubmit={handleSubmit} className='flex items-center gap-3 max-w-3xl mx-auto' > <input autoFocus type='text' value={input} onChange={(e) => setInput(e.target.value)} className='flex-1 rounded-lg bg-zinc-800 text-white border border-zinc-700 px-4 py-3 focus:outline-none focus:ring-2 focus:ring-blue-500' placeholder='Send a message...' /> <button type='submit' className='bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition' > Send </button> </form> ); }); // MessageListZtd.tsx const MessageListZtd = memo(function MessageListZtd() { const messages = useChatStore((state) => state.messages); const isTyping = useApiStore((state) => state.isTyping); const bottomRef = useRef<HTMLDivElement | null>(null); useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages, isTyping]); const renderedMessages = useMemo(() => { return messages.map((msg, i) => ( <div key={i} className={`whitespace-pre-wrap rounded-xl px-4 py-3 text-sm ${ msg.author === 'human' ? 'bg-blue-600 text-white self-end' : 'bg-zinc-800 border border-zinc-700 self-start' }`} > {msg.content} </div> )); }, [messages]); if (messages.length === 0) { return ( <div className='text-center text-zinc-500 mt-8'> No messages yet. Start the conversation below! 👇 </div> ); } return ( <div className='flex flex-col gap-4 max-w-3xl mx-auto'> {renderedMessages} {isTyping && ( <div className='italic text-sm text-zinc-400 animate-pulse'> Bot is typing... </div> )} <div ref={bottomRef} /> </div> ); });
Resultado com Zustand: A análise com React Scan demonstrou uma melhora significativa. Apenas os componentes que realmente dependiam do estado alterado foram re-renderizados. Por exemplo, ao enviar uma nova mensagem, somente MessageListZtd
foi re-renderizado para exibir a nova mensagem. O componente TitlePageZtd
só re-renderizou na primeira vez que o valor de titlePage
mudou, e não nas subsequentes, pois o valor atribuído era o mesmo. PromptZtd
, que apenas utiliza a função sendMessage
, não foi re-renderizado.
Conclusão: Zustand se Destaca na Performance 🏆
Os resultados deste comparativo deixam claro o ganho de performance que Zustand pode oferecer em relação à Context API do React, especialmente em aplicações com estados complexos e muitos componentes consumidores. A capacidade do Zustand de permitir que os componentes selecionem granularmente as partes do estado de que precisam evita re-renderizações desnecessárias, otimizando a performance da aplicação.
Embora a Context API seja uma ferramenta útil para compartilhar estados em árvores de componentes menores e mais simples, para aplicações maiores e com requisitos de performance mais rigorosos, Zustand se apresenta como uma alternativa poderosa e eficiente. A sintaxe concisa e a facilidade de uso tornam a adoção do Zustand uma consideração valiosa para qualquer projeto React.
Top comments (0)