DEV Community

Weverton Rodrigues
Weverton Rodrigues

Posted on

Zustand x Context API: Quem Rerenderiza Menos?

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;
}

Enter fullscreen mode Exit fullscreen mode

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>
  );
});
Enter fullscreen mode Exit fullscreen mode

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).

First case with api context

⚛️ 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 }),
}));

Enter fullscreen mode Exit fullscreen mode

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>
  );
});
Enter fullscreen mode Exit fullscreen mode

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.

Second case with Zustand

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)