TL;DR: A combinação de Zustand para estado do cliente e TanStack Query para estado do servidor cria uma solução elegante e performática para gerenciamento de estado em React, reduzindo boilerplate e melhorando a experiência do desenvolvedor.


Por Que Esta Combinação Funciona Tão Bem

Antes de mergulhar nos detalhes técnicos, vamos entender por que Zustand e TanStack Query se complementam perfeitamente:

  • Zustand lida com estado do cliente — preferências do usuário, estado da UI, dados de formulários e lógica específica da aplicação que vive inteiramente no seu app.

  • TanStack Query lida com estado do servidor — dados buscados de APIs, cache, sincronização e toda a complexidade de gerenciar dados que vivem no seu servidor.

Essa clara separação de preocupações elimina a confusão que costumava ter sobre onde diferentes tipos de estado deveriam viver.

Zustand: Redux Sem a Cerimônia

Zustand (alemão para "estado") é uma solução de gerenciamento de estado pequena, rápida e escalável. O que a torna especial é como parece com Redux, mas sem todo o boilerplate.

Configuração Básica do Zustand

Veja o quão simples é criar um store:

import { create } from 'zustand';

const useCounterStore = create((set, get) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
  reset: () => set({ count: 0 }),
  // Valores computados
  isEven: () => get().count % 2 === 0,
}));

// Uso em componentes
function Counter() {
  const { count, increment, decrement, reset, isEven } = useCounterStore();

  return (
    <div>
      <p>Contagem: {count} ({isEven() ? 'par' : 'ímpar'})</p>
      <button onClick={increment}>+</button>
      <button onClick={decrement}>-</button>
      <button onClick={reset}>Resetar</button>
    </div>
  );
}

O que eu amo nisso:

  • Nenhum provider necessário

  • Sem action creators ou reducers

  • Sem connect() ou mapStateToProps

  • Acesso direto ao estado e ações

  • Suporte a TypeScript pronto para usar

Padrões Avançados do Zustand

À medida que sua aplicação cresce, o Zustand escala de forma elegante. Aqui estão os padrões que uso em produção:

Dividindo Grandes Stores

Em vez de uma store massiva, crie fatias focadas:

// Fatia de usuário
const createUserSlice = (set, get) => ({
  user: null,
  isAuthenticated: false,

  login: async (credentials) => {
    set({ isLoading: true });
    try {
      const user = await authAPI.login(credentials);
      set({ user, isAuthenticated: true, isLoading: false });
    } catch (error) {
      set({ error: error.message, isLoading: false });
    }
  },

  logout: () => {
    set({ user: null, isAuthenticated: false });
  },
});

// Fatia de UI
const createUISlice = (set, get) => ({
  theme: 'light',
  sidebar: { isOpen: false },
  notifications: [],

  toggleTheme: () => set((state) => ({
    theme: state.theme === 'light' ? 'dark' : 'light'
  })),

  toggleSidebar: () => set((state) => ({
    sidebar: { isOpen: !state.sidebar.isOpen }
  })),

  addNotification: (notification) => set((state) => ({
    notifications: [...state.notifications, {
      id: Date.now(),
      ...notification
    }]
  })),

  removeNotification: (id) => set((state) => ({
    notifications: state.notifications.filter(n => n.id !== id)
  })),
});

// Fatia de carrinho de compras
const createCartSlice = (set, get) => ({
  items: [],
  isOpen: false,

  addItem: (product) => set((state) => {
    const existingItem = state.items.find(item => item.id === product.id);

    if (existingItem) {
      return {
        items: state.items.map(item =>
          item.id === product.id
            ? { ...item, quantity: item.quantity + 1 }
            : item
        )
      };
    }

    return {
      items: [...state.items, { ...product, quantity: 1 }]
    };
  }),

  // ... outras funções do carrinho
});

// Combinar fatias
const useAppStore = create((...args) => ({
  ...createUserSlice(...args),
  ...createUISlice(...args),
  ...createCartSlice(...args),
}));

Middleware e Persistência

O sistema de middleware do Zustand é incrivelmente poderoso. Veja como adiciono persistência e depuração:

import { persist, createJSONStorage } from 'zustand/middleware';
import { devtools } from 'zustand/middleware';

const useAppStore = create(
  devtools(
    persist(
      (...args) => ({
        ...createUserSlice(...args),
        ...createUISlice(...args),
        ...createCartSlice(...args),
      }),
      {
        name: 'app-storage',
        storage: createJSONStorage(() => localStorage),
        // Persistir apenas partes específicas do store
        partialize: (state) => ({
          user: state.user,
          isAuthenticated: state.isAuthenticated,
          theme: state.theme,
          cart: {
            items: state.items
          }
        }),
      }
    ),
    {
      name: 'app-store',
    }
  )
);

Esta configuração oferece:

  • Persistência automática no localStorage

  • Integração com Redux DevTools

  • Persistência seletiva (não salvar estado temporário da UI)

  • Depuração com viagem no tempo

Subscriptions e Atualizações Externas

Às vezes você precisa reagir às mudanças do store fora de componentes React:

const useAppStore = create((set, get) => ({
  // ... definição do store
}));

// Inscrever-se em mudanças específicas
const unsubscribe = useAppStore.subscribe(
  (state) => state.user,
  (user, previousUser) => {
    if (user && !previousUser) {
      // Usuário acabou de fazer login
      analytics.track('user_login', { userId: user.id });
    } else if (!user && previousUser) {
      // Usuário acabou de fazer logout
      analytics.track('user_logout');
    }
  }
);

TypeScript com Zustand

O suporte a TypeScript do Zustand é excelente. Veja como estruturo stores tipados:

interface UserState {
  user: User | null;
  isAuthenticated: boolean;
  isLoading: boolean;
  error: string | null;
}

interface UserActions {
  login: (credentials: LoginCredentials) => Promise<void>;
  logout: () => void;
  clearError: () => void;
}

// ... outras interfaces

type AppStore = UserState & UserActions & UIState & UIActions;

const useAppStore = create<AppStore>((set, get) => ({
  // Implementação do store
}));

TanStack Query: Estado do Servidor Feito Direito

Enquanto o Zustand lida elegantemente com o estado do cliente, o TanStack Query (antigo React Query) revoluciona como você trabalha com estado do servidor. Ele lida com cache, sincronização, atualizações em segundo plano e toda a complexidade de gerenciar dados que vêm da sua API.

O Modelo Mental

Antes do TanStack Query, eu tratava dados de servidor como estado regular:

// A forma antiga - tratando dados de servidor como estado local
function ProductList() {
  const [products, setProducts] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    fetchProducts()
      .then(setProducts)
      .catch(setError)
      .finally(() => setLoading(false));
  }, []);

  // E sobre refetch? E cache?
  // E se os dados mudarem no servidor?
}

Com TanStack Query, dados de servidor se tornam um recurso gerenciado:

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

function ProductList() {
  const {
    data: products,
    isLoading,
    error,
    refetch
  } = useQuery({
    queryKey: ['products'],
    queryFn: () => fetchProducts(),
    staleTime: 5 * 60 * 1000, // 5 minutos
    cacheTime: 10 * 60 * 1000, // 10 minutos
  });

  if (isLoading) return <LoadingSpinner />;
  if (error) return <ErrorMessage error={error} onRetry={refetch} />;

  return (
    <div>
      {products?.map(product => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  );
}

O que o TanStack Query oferece automaticamente:

  • Cache com expiração configurável

  • Refetch em segundo plano quando os dados ficam obsoletos

  • Deduplicação de solicitações idênticas

  • Tentativas automáticas em caso de falha

  • Estados de loading e erro

  • Atualizações otimistas

  • Suporte offline

Padrões Avançados do TanStack Query

Chaves de Query: A Base

Chaves de query são como o TanStack Query sabe quais dados pertencem juntos. Eu as penso como índices de banco de dados para meu cache:

// Chave simples
const productsQuery = useQuery({
  queryKey: ['products'],
  queryFn: fetchProducts
});

// Chaves parametrizadas
const productQuery = useQuery({
  queryKey: ['product', productId],
  queryFn: () => fetchProduct(productId)
});

// Chaves complexas com filtros
const filteredProductsQuery = useQuery({
  queryKey: ['products', { category, sortBy, search }],
  queryFn: () => fetchProducts({ category, sortBy, search })
});

Estratégia de Chave que Sigo:

  • Partes mais específicas no final

  • Use objetos para parâmetros complexos

  • Mantenha chaves consistentes na aplicação

  • Pense em padrões de invalidação

Mutations: Alterando Estado do Servidor

Mutations lidam com operações CREATE, UPDATE, DELETE:

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

function useAddProduct() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (newProduct) => createProduct(newProduct),

    onMutate: async (newProduct) => {
      // Cancelar refetches pendentes
      await queryClient.cancelQueries(['products']);

      // Capturar valor anterior
      const previousProducts = queryClient.getQueryData(['products']);

      // Atualização otimista
      queryClient.setQueryData(['products'], (old) =>
        old ? [...old, { ...newProduct, id: Date.now() }] : [newProduct]
      );

      return { previousProducts };
    },

    onError: (err, newProduct, context) => {
      // Reverter em caso de erro
      queryClient.setQueryData(['products'], context.previousProducts);
    },

    onSettled: () => {
      // Sempre refetch após erro ou sucesso
      queryClient.invalidateQueries(['products']);
    },
  });
}

Combinando Zustand e TanStack Query

Aqui é onde a mágica acontece. Usar ambas as bibliotecas juntos cria uma arquitetura poderosa e limpa:

// Estado do cliente no Zustand
const useAppStore = create((set, get) => ({
  // Estado da UI
  currentView: 'grid',
  filters: {
    category: null,
    priceRange: [0, 1000],
    search: '',
  },
  selectedProducts: [],

  // Ações
  setView: (view) => set({ currentView: view }),
  updateFilters: (newFilters) => set((state) => ({
    filters: { ...state.filters, ...newFilters }
  })),
  toggleProductSelection: (productId) => set((state) => {
    const isSelected = state.selectedProducts.includes(productId);
    return {
      selectedProducts: isSelected
        ? state.selectedProducts.filter(id => id !== productId)
        : [...state.selectedProducts, productId]
    };
  }),
}));

// Estado do servidor com TanStack Query
function useProducts() {
  const filters = useAppStore(state => state.filters);

  return useQuery({
    queryKey: ['products', filters],
    queryFn: () => fetchProducts(filters),
    keepPreviousData: true, // Mostrar dados antigos enquanto busca novos
  });
}

// Componente usando ambas
function ProductManagement() {
  const {
    currentView,
    filters,
    selectedProducts,
    setView,
    updateFilters,
    toggleProductSelection,
  } = useAppStore();

  const { data: products, isLoading } = useProducts();

  return (
    <div>
      {/* Componentes usando o store e queries */}
    </div>
  );
}

Configuração e Otimização Avançadas

Configuração do Query Client

Configure seu QueryClient com padrões sensatos:

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 5 * 60 * 1000, // 5 minutos
      cacheTime: 10 * 60 * 1000, // 10 minutos
      retry: (failureCount, error) => {
        // Não tentar novamente em erros 4xx
        if (error.status >= 400 && error.status < 500) {
          return false;
        }
        // Tentar até 3 vezes para outros erros
        return failureCount < 3;
      },
      refetchOnWindowFocus: false,
      refetchOnReconnect: 'always',
    },
    mutations: {
      retry: 1,
    },
  },
});

Estratégias de Teste

Ambas as bibliotecas são muito testáveis quando configuradas corretamente:

Testando Stores do Zustand

import { act, renderHook } from '@testing-library/react';
import { useAppStore } from './store';

describe('App Store', () => {
  beforeEach(() => {
    useAppStore.setState({
      items: [],
      user: null,
      isAuthenticated: false,
    });
  });

  test('deve adicionar item ao carrinho', () => {
    const { result } = renderHook(() => useAppStore());

    act(() => {
      result.current.addItem({ id: 1, name: 'Produto Teste', price: 10 });
    });

    expect(result.current.items).toHaveLength(1);
    expect(result.current.getTotalPrice()).toBe(10);
  });
});

Testando TanStack Query

import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

function createWrapper() {
  const queryClient = new QueryClient({
    defaultOptions: {
      queries: { retry: false },
      mutations: { retry: false },
    },
  });

  return ({ children }) => (
    <QueryClientProvider client={queryClient}>
      {children}
    </QueryClientProvider>
  );
}

describe('useProducts', () => {
  test('deve buscar produtos com sucesso', async () => {
    const mockProducts = [{ id: 1, name: 'Produto Teste' }];

    global.fetch = jest.fn(() =>
      Promise.resolve({
        ok: true,
        json: () => Promise.resolve(mockProducts),
      })
    );

    const { result } = renderHook(() => useProducts(), {
      wrapper: createWrapper(),
    });

    expect(result.current.isLoading).toBe(true);

    await waitFor(() => {
      expect(result.current.isSuccess).toBe(true);
    });

    expect(result.current.data).toEqual(mockProducts);
  });
});

Armadilhas Comuns e Como Evitá-las

Armadilhas do Zustand

  1. Mutando Estado Diretamente

// ❌ Não mutate diretamente
const useStore = create((set) => ({
  items: [],
  addItem: (item) => {
    // Isso muta o array original!
    get().items.push(item);
  }
}));

// ✅ Sempre crie novo estado
const useStore = create((set) => ({
  items: [],
  addItem: (item) => set((state) => ({
    items: [...state.items, item]
  }))
}));

2. Sobrecarregando Estado Global

Nem tudo precisa estar no Zustand. Mantenha estado local quando possível.

Armadilhas do TanStack Query

  1. Não Usando Chaves de Query Adequadamente

// ❌ Chaves estáticas não funcionam com parâmetros
const { data } = useQuery({
  queryKey: ['products'],
  queryFn: () => fetchProducts(filters), // filters não está na chave!
});

// ✅ Inclua todas as variáveis na chave
const { data } = useQuery({
  queryKey: ['products', filters],
  queryFn: () => fetchProducts(filters),
});

2. Não Lidando com Estados de Loading Adequadamente

Sempre considere a experiência do usuário durante estados de loading e erro.

Exemplo de Arquitetura Real

Aqui está como estruturo uma aplicação React típica com Zustand e TanStack Query:

src/
  stores/
    useAppStore.js          # Store principal do Zustand
    slices/
      userSlice.js
      uiSlice.js
      cartSlice.js

  hooks/
    queries/
      useProducts.js        # Hooks do TanStack Query
      useUsers.js
    mutations/
      useCreateProduct.js

  components/
    ProductList.jsx         # Usa stores e queries
    ShoppingCart.jsx

  utils/
    queryClient.js          # Configuração do query client
    api.js                  # Camada de API

Impacto de Performance: Os Números

No meu último projeto, a mudança de uma configuração Redux + lógica assíncrona personalizada para Zustand + TanStack Query resultou em:

  • 40% de redução no tamanho do bundle (menos código boilerplate)

  • 60% menos re-renders (melhor organização de estado)

  • Desenvolvimento mais rápido (menos tempo escrevendo boilerplate)

  • Melhor experiência do usuário (refetch automático em segundo plano, atualizações otimistas)

Embora seus resultados possam variar, a combinação consistentemente entrega melhor performance e experiência de desenvolvedor.

Conclusão

Zustand e TanStack Query mudaram fundamentalmente como abordo o gerenciamento de estado em React. Eles resolvem problemas reais sem introduzir complexidade desnecessária:

Zustand oferece:

  • Gerenciamento de estado simples e previsível

  • Sem boilerplate ou cerimônia

  • Excelente suporte a TypeScript

  • Poderoso sistema de middleware

TanStack Query oferece:

  • Gerenciamento inteligente de estado do servidor

  • Cache e sincronização automáticos

  • Atualizações otimistas e tratamento de erro

  • Refetch em segundo plano e gerenciamento de dados obsoletos

Juntos, criam uma solução de gerenciamento de estado que escala de apps simples a aplicações enterprise complexas.

Takeaway: Esta combinação representa o estado atual da arte em gerenciamento de estado para React - simples o suficiente para projetos pequenos, mas poderosa o suficiente para aplicações complexas, sem a complexidade desnecessária de soluções tradicionais.