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)