Javascript

Estado global com Zustand e React Query Já leu

13 min de leitura

Estado global com Zustand e React Query
Nos artigos anteriores você aprendeu a gerenciar estado local com useState e useReducer, e estado compartilhado com useContext. Mas à medida que uma aplicação cresce, surgem dois problemas distintos que merecem ferrament

Nos artigos anteriores você aprendeu a gerenciar estado local com useState e useReducer, e estado compartilhado com useContext. Mas à medida que uma aplicação cresce, surgem dois problemas distintos que merecem ferramentas específicas:

Problema 1 — Estado de UI global: autenticação, carrinho de compras, tema, notificações. Estado que muitos componentes precisam ler e modificar.

Problema 2 — Estado de servidor: dados vindos de uma API. Esses dados têm necessidades únicas — cache, revalidação, sincronização, loading states, erros.

Zustand resolve o primeiro. React Query (TanStack Query) resolve o segundo. Juntos, são a combinação mais poderosa e elegante do ecossistema React em 2025.


Por que não Redux?

Redux (2015)             Zustand (2021)
─────────────────────    ──────────────────────────
Actions + Reducers       Estado + funções diretas
Boilerplate extenso      Mínimo de código
Middleware (Thunk/Saga)  Async nativo
~7KB                     ~1KB
Curva de aprendizado     Aprende em 10 minutos

Para a maioria dos projetos: Zustand é suficiente e muito mais simples.
Redux ainda faz sentido em aplicações muito grandes com equipes grandes.

Zustand — instalando e o primeiro store

npm install zustand
// src/stores/contadorStore.js — o mais simples possível
import { create } from 'zustand';

const useContadorStore = create((set) => ({
  // Estado
  contador: 0,

  // Ações — funções que modificam o estado
  incrementar: () => set((state) => ({ contador: state.contador + 1 })),
  decrementar: () => set((state) => ({ contador: state.contador - 1 })),
  resetar: () => set({ contador: 0 }),
  definir: (valor) => set({ contador: valor }),
}));

export default useContadorStore;
// Usando em qualquer componente — sem Provider!
import useContadorStore from './stores/contadorStore';

function Contador() {
  const contador = useContadorStore((state) => state.contador);
  const incrementar = useContadorStore((state) => state.incrementar);

  return (
    <div>
      <p>{contador}</p>
      <button onClick={incrementar}>+1</button>
    </div>
  );
}

function BotaoReset() {
  // Só este componente re-renderiza quando resetar for chamado
  const resetar = useContadorStore((state) => state.resetar);
  return <button onClick={resetar}>Resetar</button>;
}

// Sem Context, sem Provider, sem prop drilling
// Qualquer componente acessa o store diretamente

Store de autenticação — exemplo real

// src/stores/authStore.js
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

// persist — salva o estado no localStorage automaticamente
const useAuthStore = create(
  persist(
    (set, get) => ({
      // ── Estado ──────────────────────────────────
      usuario: null,
      token: null,
      carregando: false,
      erro: null,

      // ── Getters computados ───────────────────────
      get estaLogado() {
        return !!get().token && !!get().usuario;
      },

      get eAdmin() {
        return get().usuario?.papel === 'admin';
      },

      // ── Ações ────────────────────────────────────
      login: async (email, senha) => {
        set({ carregando: true, erro: null });

        try {
          const res = await fetch('/api/auth/login', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ email, senha }),
          });

          if (!res.ok) {
            const erro = await res.json();
            throw new Error(erro.erro || 'Credenciais inválidas.');
          }

          const { token, usuario } = await res.json();
          set({ token, usuario, carregando: false, erro: null });

          return { sucesso: true };
        } catch (erro) {
          set({ carregando: false, erro: erro.message });
          return { sucesso: false, erro: erro.message };
        }
      },

      logout: () => {
        set({ usuario: null, token: null, erro: null });
      },

      atualizarUsuario: (dados) => {
        set((state) => ({
          usuario: { ...state.usuario, ...dados },
        }));
      },

      limparErro: () => set({ erro: null }),
    }),
    {
      name: 'auth-storage',              // chave no localStorage
      partialize: (state) => ({          // persiste apenas token e usuario
        token: state.token,
        usuario: state.usuario,
      }),
    }
  )
);

export default useAuthStore;
// Usando o store de auth em qualquer lugar
import useAuthStore from '../stores/authStore';

function BarraNavegacao() {
  const usuario = useAuthStore((state) => state.usuario);
  const logout = useAuthStore((state) => state.logout);

  return (
    <nav>
      {usuario ? (
        <>
          <span>Olá, {usuario.nome}!</span>
          <button onClick={logout}>Sair</button>
        </>
      ) : (
        <a href="/login">Entrar</a>
      )}
    </nav>
  );
}

function PaginaLogin() {
  const { login, carregando, erro } = useAuthStore();
  const [email, setEmail] = useState('');
  const [senha, setSenha] = useState('');

  async function handleSubmit(e) {
    e.preventDefault();
    const resultado = await login(email, senha);
    if (resultado.sucesso) {
      window.location.href = '/dashboard';
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <input value={email} onChange={e => setEmail(e.target.value)} type="email" />
      <input value={senha} onChange={e => setSenha(e.target.value)} type="password" />
      {erro && <p className="erro">{erro}</p>}
      <button type="submit" disabled={carregando}>
        {carregando ? 'Entrando...' : 'Entrar'}
      </button>
    </form>
  );
}

Store de carrinho — estado complexo com Zustand

// src/stores/carrinhoStore.js
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

const useCarrinhoStore = create(
  persist(
    (set, get) => ({
      itens: [],

      // ── Getters ──────────────────────────────────
      get totalItens() {
        return get().itens.reduce((acc, item) => acc + item.quantidade, 0);
      },

      get totalPreco() {
        return get().itens.reduce(
          (acc, item) => acc + item.preco * item.quantidade,
          0
        );
      },

      get estaVazio() {
        return get().itens.length === 0;
      },

      // ── Ações ────────────────────────────────────
      adicionarItem: (produto) => {
        set((state) => {
          const existente = state.itens.find((i) => i.id === produto.id);

          if (existente) {
            return {
              itens: state.itens.map((i) =>
                i.id === produto.id
                  ? { ...i, quantidade: i.quantidade + 1 }
                  : i
              ),
            };
          }

          return {
            itens: [...state.itens, { ...produto, quantidade: 1 }],
          };
        });
      },

      removerItem: (id) => {
        set((state) => ({
          itens: state.itens.filter((i) => i.id !== id),
        }));
      },

      atualizarQuantidade: (id, quantidade) => {
        if (quantidade <= 0) {
          get().removerItem(id);
          return;
        }
        set((state) => ({
          itens: state.itens.map((i) =>
            i.id === id ? { ...i, quantidade } : i
          ),
        }));
      },

      limpar: () => set({ itens: [] }),
    }),
    { name: 'carrinho-storage' }
  )
);

export default useCarrinhoStore;

Seletores — otimizando re-renders com Zustand

import useCarrinhoStore from '../stores/carrinhoStore';

// ✅ Selector granular — re-renderiza APENAS quando itens mudar
function BadgeCarrinho() {
  const totalItens = useCarrinhoStore((state) => state.totalItens);
  return <span className="badge">{totalItens}</span>;
}

// ✅ Selector de ação — nunca causa re-render (funções não mudam)
function BotaoAdicionarAoCarrinho({ produto }) {
  const adicionarItem = useCarrinhoStore((state) => state.adicionarItem);
  return (
    <button onClick={() => adicionarItem(produto)}>
      Adicionar ao carrinho
    </button>
  );
}

// ❌ Selector amplo — re-renderiza sempre que QUALQUER coisa no store mudar
function Errado() {
  const store = useCarrinhoStore(); // pega tudo!
  return <span>{store.totalItens}</span>;
}

React Query — estado de servidor

npm install @tanstack/react-query
npm install -D @tanstack/react-query-devtools
// src/main.jsx — configurando o QueryClient
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5,      // dados "frescos" por 5 minutos
      gcTime: 1000 * 60 * 10,         // cache por 10 minutos
      retry: 2,                        // tenta 2x em caso de erro
      refetchOnWindowFocus: true,      // revalida ao focar a janela
    },
  },
});

ReactDOM.createRoot(document.getElementById('root')).render(
  <QueryClientProvider client={queryClient}>
    <App />
    <ReactQueryDevtools initialIsOpen={false} />
  </QueryClientProvider>
);

useQuery — buscando dados

import { useQuery } from '@tanstack/react-query';

// Função de busca — separada do componente
async function buscarProdutos(filtros) {
  const params = new URLSearchParams(filtros);
  const res = await fetch(`/api/produtos?${params}`);
  if (!res.ok) throw new Error(`Erro ${res.status}`);
  return res.json();
}

async function buscarProdutoPorId(id) {
  const res = await fetch(`/api/produtos/${id}`);
  if (!res.ok) {
    if (res.status === 404) throw new Error('Produto não encontrado.');
    throw new Error(`Erro ${res.status}`);
  }
  return res.json();
}

// ── Listagem ────────────────────────────────────────
function ListaProdutos() {
  const [filtros, setFiltros] = useState({ categoria: '', pagina: 1 });

  const {
    data,           // dados retornados
    isLoading,      // true na primeira busca (sem cache)
    isFetching,     // true em qualquer busca (inclusive revalidação)
    isError,        // true se houve erro
    error,          // objeto de erro
    refetch,        // função para refazer manualmente
  } = useQuery({
    queryKey: ['produtos', filtros],    // chave única — muda → nova busca
    queryFn: () => buscarProdutos(filtros),
    placeholderData: (dadosAnteriores) => dadosAnteriores, // mantém dados anteriores durante paginação
  });

  if (isLoading) return <Skeleton />;
  if (isError) return <ErroMensagem erro={error.message} onRetry={refetch} />;

  return (
    <div>
      {isFetching && <div className="indicador-atualizando">Atualizando...</div>}
      <ul>
        {data?.dados.map(p => (
          <li key={p._id}>{p.nome} — R$ {p.preco}</li>
        ))}
      </ul>
      <Paginacao
        total={data?.paginacao.total_paginas}
        atual={filtros.pagina}
        aoMudar={(p) => setFiltros(prev => ({ ...prev, pagina: p }))}
      />
    </div>
  );
}

// ── Detalhe com enabled ─────────────────────────────
function DetalheProduto({ id }) {
  const { data: produto, isLoading } = useQuery({
    queryKey: ['produtos', id],
    queryFn: () => buscarProdutoPorId(id),
    enabled: !!id,              // só busca se id existir
    staleTime: 1000 * 60 * 10, // cache de 10 min para detalhes
  });

  if (isLoading) return <p>Carregando...</p>;
  return <div><h1>{produto?.nome}</h1><p>R$ {produto?.preco}</p></div>;
}

useMutation — criando, atualizando e removendo

import { useMutation, useQueryClient } from '@tanstack/react-query';

async function criarProduto(dados) {
  const res = await fetch('/api/produtos', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(dados),
  });
  if (!res.ok) throw new Error('Erro ao criar produto.');
  return res.json();
}

async function atualizarProduto({ id, dados }) {
  const res = await fetch(`/api/produtos/${id}`, {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(dados),
  });
  if (!res.ok) throw new Error('Erro ao atualizar produto.');
  return res.json();
}

async function removerProduto(id) {
  const res = await fetch(`/api/produtos/${id}`, { method: 'DELETE' });
  if (!res.ok) throw new Error('Erro ao remover produto.');
  return res.json();
}

// ── Formulário de criação ───────────────────────────
function FormularioCriarProduto() {
  const queryClient = useQueryClient();
  const [form, setForm] = useState({ nome: '', preco: '', categoria: '' });

  const criacao = useMutation({
    mutationFn: criarProduto,

    // Chamado quando a mutation tem sucesso
    onSuccess: (novoProduto) => {
      // Invalida o cache de produtos — React Query revalida automaticamente
      queryClient.invalidateQueries({ queryKey: ['produtos'] });

      // Ou adiciona otimisticamente ao cache
      queryClient.setQueryData(['produtos', novoProduto._id], novoProduto);

      alert(`Produto "${novoProduto.nome}" criado com sucesso!`);
      setForm({ nome: '', preco: '', categoria: '' });
    },

    onError: (erro) => {
      alert(`Erro: ${erro.message}`);
    },
  });

  function handleSubmit(e) {
    e.preventDefault();
    criacao.mutate({ ...form, preco: Number(form.preco) });
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        placeholder="Nome"
        value={form.nome}
        onChange={e => setForm(p => ({ ...p, nome: e.target.value }))}
      />
      <input
        placeholder="Preço"
        type="number"
        value={form.preco}
        onChange={e => setForm(p => ({ ...p, preco: e.target.value }))}
      />
      <button type="submit" disabled={criacao.isPending}>
        {criacao.isPending ? 'Criando...' : 'Criar Produto'}
      </button>
      {criacao.isError && <p className="erro">{criacao.error.message}</p>}
    </form>
  );
}

// ── Update otimista ─────────────────────────────────
function ItemProduto({ produto }) {
  const queryClient = useQueryClient();

  const atualizacao = useMutation({
    mutationFn: atualizarProduto,

    // Atualiza o cache ANTES da resposta do servidor
    onMutate: async ({ id, dados }) => {
      // Cancela queries em andamento para evitar conflito
      await queryClient.cancelQueries({ queryKey: ['produtos'] });

      // Salva estado anterior para rollback
      const anterior = queryClient.getQueryData(['produtos']);

      // Atualiza otimisticamente
      queryClient.setQueryData(['produtos'], (old) => ({
        ...old,
        dados: old.dados.map(p =>
          p._id === id ? { ...p, ...dados } : p
        ),
      }));

      return { anterior }; // contexto para onError
    },

    // Se der erro, desfaz
    onError: (_erro, _vars, contexto) => {
      queryClient.setQueryData(['produtos'], contexto.anterior);
    },

    // Revalida após sucesso ou erro
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['produtos'] });
    },
  });

  return (
    <li>
      {produto.nome}
      <button
        onClick={() => atualizacao.mutate({
          id: produto._id,
          dados: { ativo: !produto.ativo },
        })}
      >
        {produto.ativo ? 'Desativar' : 'Ativar'}
      </button>
    </li>
  );
}

Combinando Zustand + React Query

A separação de responsabilidades fica clara:

// src/hooks/useProdutos.js — hook que combina os dois
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import useAuthStore from '../stores/authStore';

// Zustand fornece o token de autenticação
// React Query gerencia os dados do servidor

function useProdutos(filtros = {}) {
  const token = useAuthStore((state) => state.token);
  const queryClient = useQueryClient();

  // Headers autenticados — vêm do Zustand
  const headers = {
    'Content-Type': 'application/json',
    ...(token && { Authorization: `Bearer ${token}` }),
  };

  // Busca — React Query
  const listagem = useQuery({
    queryKey: ['produtos', filtros],
    queryFn: async () => {
      const params = new URLSearchParams(filtros);
      const res = await fetch(`/api/produtos?${params}`, { headers });
      if (!res.ok) throw new Error('Erro ao buscar produtos.');
      return res.json();
    },
    enabled: !!token, // só busca se estiver logado
  });

  // Criação — React Query Mutation
  const criar = useMutation({
    mutationFn: async (dados) => {
      const res = await fetch('/api/produtos', {
        method: 'POST',
        headers,
        body: JSON.stringify(dados),
      });
      if (!res.ok) throw new Error('Erro ao criar produto.');
      return res.json();
    },
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ['produtos'] }),
  });

  // Remoção — React Query Mutation
  const remover = useMutation({
    mutationFn: async (id) => {
      const res = await fetch(`/api/produtos/${id}`, {
        method: 'DELETE',
        headers,
      });
      if (!res.ok) throw new Error('Erro ao remover produto.');
      return res.json();
    },
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ['produtos'] }),
  });

  return { listagem, criar, remover };
}

// Uso no componente — interface limpa
function PainelProdutos() {
  const [filtros, setFiltros] = useState({});
  const { listagem, criar, remover } = useProdutos(filtros);

  if (listagem.isLoading) return <Skeleton />;
  if (listagem.isError) return <p>{listagem.error.message}</p>;

  return (
    <div>
      <button
        onClick={() => criar.mutate({ nome: 'Novo', preco: 100, categoria: 'outros' })}
        disabled={criar.isPending}
      >
        {criar.isPending ? 'Criando...' : 'Novo Produto'}
      </button>

      <ul>
        {listagem.data?.dados.map(p => (
          <li key={p._id}>
            {p.nome}
            <button onClick={() => remover.mutate(p._id)}>🗑</button>
          </li>
        ))}
      </ul>
    </div>
  );
}

Prefetching e cache avançado

import { useQueryClient } from '@tanstack/react-query';

// Pré-carregar dados ao passar o mouse
function LinkProduto({ id, nome }) {
  const queryClient = useQueryClient();

  function preCarregar() {
    queryClient.prefetchQuery({
      queryKey: ['produtos', id],
      queryFn: () => buscarProdutoPorId(id),
      staleTime: 1000 * 60 * 5,
    });
  }

  return (
    <a
      href={`/produtos/${id}`}
      onMouseEnter={preCarregar}  // pré-carrega ao passar o mouse
    >
      {nome}
    </a>
  );
}

// Invalidação seletiva
queryClient.invalidateQueries({ queryKey: ['produtos'] });           // todos
queryClient.invalidateQueries({ queryKey: ['produtos', 'lista'] }); // só lista
queryClient.invalidateQueries({ queryKey: ['produtos', id] });      // só um item

// Manipulação direta do cache
queryClient.setQueryData(['produtos', id], novoValor);
queryClient.removeQueries({ queryKey: ['produtos'] });

Tarefa para você

Construa um painel de e-commerce conectando a API do Módulo 4 ao React:

// 1. Configure Zustand + React Query no projeto Vite

// 2. Store de autenticação (Zustand):
//    - login, logout, persistência com localStorage
//    - token usado em todas as requisições

// 3. React Query para produtos:
//    useQuery: listar com filtros e paginação
//    useMutation: criar, atualizar, remover
//    Update otimista ao toggle de ativo/inativo

// 4. React Query para tarefas:
//    - Listagem com filtro por status
//    - Criar tarefa com formulário
//    - Marcar como concluída (update otimista)

// 5. Componente de busca com debounce:
//    - Input de busca (useDebounce 500ms do artigo anterior)
//    - queryKey inclui o termo de busca
//    - placeholderData mantém dados anteriores enquanto busca

// 6. DevTools:
//    - Instale @tanstack/react-query-devtools
//    - Observe as queries, cache e invalidações em tempo real

Conclusão

Neste artigo você aprendeu:

  • Por que Zustand e React Query resolvem problemas diferentes
  • Zustand — criando stores simples e poderosas sem boilerplate
  • persist middleware — salvando estado no localStorage automaticamente
  • Seletores granulares — evitando re-renders desnecessários
  • React Query — configurando o QueryClient com opções globais
  • useQuery — buscando dados com cache, loading e erro automáticos
  • useMutation — criando, atualizando e removendo com feedback visual
  • Update otimista — atualiza a UI antes da resposta do servidor
  • Prefetching — pré-carregando dados para navegação instantânea
  • Como combinar os dois — Zustand para UI, React Query para servidor

No próximo artigo vamos aprender React Router — navegação entre páginas e construção de SPAs completas.


📌 Próximo artigo: Aula 41 — React Router: navegação em SPAs


📚 Fontes e Referências

  • Zustand — Documentação: https://zustand.docs.pmnd.rs
  • TanStack Query — Documentação: https://tanstack.com/query/latest
  • TanStack Query — Guia de início: https://tanstack.com/query/latest/docs/framework/react/quick-start
  • TanStack Query — Mutations: https://tanstack.com/query/latest/docs/framework/react/guides/mutations
  • TanStack Query — Optimistic Updates: https://tanstack.com/query/latest/docs/framework/react/guides/optimistic-updates
  • Zustand vs Redux: https://docs.pmnd.rs/zustand/getting-started/comparison
  • Fluent React — Tejas Kumar (O'Reilly)
  • roadmap.sh — React: https://roadmap.sh/react
Comentários

Mais em Javascript

Segurança em aplicações web
Segurança em aplicações web

Segurança não é um recurso que você adiciona ao final do projeto — é uma cama...

Escopo, Hoisting e Closures
Escopo, Hoisting e Closures

Este &eacute; um dos artigos mais importantes da s&eacute;rie. N&atilde;o por...

Criando um servidor HTTP com Node.js puro
Criando um servidor HTTP com Node.js puro

Antes de usar o Express — o framework que simplifica tudo — é fundamental ent...