Frontend

TypeScript with Zustand + TanStack Query: the types tutorials don't show you

Every tutorial shows the happy path. The real work starts when you split stores, add middleware, and try to keep TanStack Query keys honest.

TypeScript with Zustand + TanStack Query: the types tutorials don't show you

Part 2 of the Zustand + TanStack Query series. The previous article showed the duo working. This one shows it working safely — the types that TypeScript demands when you move beyond the basic example and start building something real.


The article previously mentioned that Zustand has "excellent TypeScript support" — and it does. But the support being available doesn't mean the path there is obvious.

Most tutorials show this and stop:

JAVASCRIPT
const useStore = create<AppStore>((set) => ({ ... }));

The problem starts when you try to type separate slices, combine stores, or use get inside a typed action. TypeScript starts complaining — and the error messages don't help you understand why.

Let's fix this from the start.


1. Typing slices with StateCreator

The previous article showed how to split a large store into slices. The code works in JavaScript — but in TypeScript, combining slices without the right type makes the compiler lose inference between them.

Why untyped slices break
1
Actions lose autocomplete

The compiler loses inference between slices, so cross-slice calls silently break type safety.

2
get() becomes any

The compiler loses inference between slices, so the getter drops type safety and returns an untyped object.

3
Store union fails to validate

The compiler loses inference between slices, so the combined AppStore union stops validating.

Combining Zustand slices without the right type makes the compiler lose inference between them.

The correct type for a slice is StateCreator:

TYPESCRIPT
import { create } from 'zustand';import { StateCreator } from 'zustand';// Tipos de domíniointerface User {  id: string;  name: string;  email: string;}interface LoginCredentials {  email: string;  password: string;}// Tipos do slice de usuáriointerface UserSlice {  user: User | null;  isAuthenticated: boolean;  login: (credentials: LoginCredentials) => Promise<void>;  logout: () => void;}// Tipos do slice de UIinterface UISlice {  theme: 'light' | 'dark';  sidebarOpen: boolean;  toggleTheme: () => void;  toggleSidebar: () => void;}// O tipo completo do storetype AppStore = UserSlice & UISlice;// StateCreator garante que cada slice conhece o store inteiroconst createUserSlice: StateCreator<  AppStore,   // tipo do store completo  [],         // middlewares (vazio por enquanto)  [],  UserSlice   // tipo deste slice> = (set, get) => ({  user: null,  isAuthenticated: false,  login: async (credentials) => {    const user = await authAPI.login(credentials);    set({ user, isAuthenticated: true });  },  logout: () => {    set({ user: null, isAuthenticated: false });    // get() aqui enxerga o AppStore inteiro — incluindo UISlice    get().toggleSidebar();  },});
JAVASCRIPT
// Combinando os slices — o TypeScript valida a unionconst useAppStore = create<AppStore>((...args) => ({  ...createUserSlice(...args),  ...createUISlice(...args),}));

2. Typing slices with middleware

The previous article used persist and devtools. When middleware enters, the second parameter of StateCreator changes — and this is where most people get stuck.

TYPESCRIPT
import { StateCreator } from 'zustand';// Com persist + devtools, o segundo parâmetro lista os middlewaresconst createUserSlice: StateCreator<  AppStore,  [    ['zustand/devtools', never],    ['zustand/persist', Partial<AppStore>]  ],  [],  UserSlice> = (set, get) => ({ ... });// O store combinado com middleware tipado corretamenteconst useAppStore = create<AppStore>()(  devtools(    persist(      (...args) => ({        ...createUserSlice(...args),        ...createUISlice(...args),      }),      { name: 'app-store' }    )  ));

3. useQuery with explicit generics

TanStack Query infers quite a lot automatically — but inference has limits. When the fetch function returns any, or when you need to type the error separately, explicit generics are the way.

useQuery accepts four generics: <TData, TError, TData, TQueryKey>. In practice, the first two are the most important:

TYPESCRIPT
import { useQuery } from '@tanstack/react-query';// Tipos de domíniointerface Product {  id: number;  name: string;  price: number;  category: string;}interface ApiError {  message: string;  statusCode: number;}// Sem generic explícito — error é unknownconst { data, error } = useQuery({  queryKey: ['products'],  queryFn: fetchProducts, // retorna Promise<Product[]>});// error aqui é: Error | null — não ApiError// Com generics explícitos — error é ApiErrorconst { data, error } = useQuery<Product[], ApiError>({  queryKey: ['products'],  queryFn: fetchProducts,});// Agora error.statusCode existe e o TypeScript sabe dissoif (error) {  console.log(error.statusCode); // sem erro de tipo}

4. Typed query key factories

One of the most silent problems with TanStack Query in TypeScript: query keys scattered throughout the codebase as loose strings. When you need to invalidate a query, it becomes impossible to guarantee the key is correct.

The solution is a factory object with as const:

Loose strings — easy to mess up
TEXT
useQuery({  queryKey: ['products'],  ...});// Typo — não invalida nadaqueryClient.invalidateQueries(  ['product']);
Typed factory — safe
TEXT
// Typo vira erro em compile timeuseQuery({  queryKey: productKeys.list(filters),  ...});queryClient.invalidateQueries({  queryKey: productKeys.all});
JAVASCRIPT
interface ProductFilters {  category: string | null;  priceRange: [number, number];  search: string;}export const productKeys = {  all: ['products'] as const,  lists: () => [...productKeys.all, 'list'] as const,  list: (filters: ProductFilters) =>    [...productKeys.lists(), filters] as const,  details: () => [...productKeys.all, 'detail'] as const,  detail: (id: number) =>    [...productKeys.details(), id] as const,} as const;// Uso — o TypeScript valida cada keyuseQuery({  queryKey: productKeys.list({ category: 'electronics', priceRange: [0, 1000], search: '' }),  queryFn: () => fetchProducts({ category: 'electronics', priceRange: [0, 1000], search: '' }),});// Invalidar todos os produtosqueryClient.invalidateQueries({ queryKey: productKeys.all });// Invalidar só o produto de id 42queryClient.invalidateQueries({ queryKey: productKeys.detail(42) });

5. useMutation with all four generics

useMutation has four generics: <TData, TError, TVariables, TContext>. The TContext is what most people ignore — it's the value you return in onMutate and receive back in onError for rollback. Without typing it, it's unknown — and you lose type safety at the most critical point.

TYPESCRIPT
import { useMutation, useQueryClient } from '@tanstack/react-query';interface CreateProductInput {  name: string;  price: number;  category: string;}interface MutationContext {  previousProducts: Product[] | undefined;}function useCreateProduct() {  const queryClient = useQueryClient();  return useMutation<    Product,             // TData — o que a API retorna    ApiError,            // TError — tipo do erro    CreateProductInput,  // TVariables — o que passa pra mutationFn    MutationContext      // TContext — o que onMutate retorna  >({    mutationFn: (input) => createProduct(input),    onMutate: async (newProduct) => {      await queryClient.cancelQueries({ queryKey: productKeys.all });      const previousProducts = queryClient.getQueryData<Product[]>(        productKeys.lists()      );      queryClient.setQueryData<Product[]>(productKeys.lists(), (old) =>        old ? [...old, { ...newProduct, id: Date.now() }] : []      );      // TypeScript sabe que o retorno é MutationContext      return { previousProducts };    },    onError: (error, variables, context) => {      // context é MutationContext | undefined — TypeScript força o check      if (context?.previousProducts) {        queryClient.setQueryData(          productKeys.lists(),          context.previousProducts        );      }      console.error(`Erro ${error.statusCode}: ${error.message}`);    },    onSettled: () => {      queryClient.invalidateQueries({ queryKey: productKeys.all });    },  });}

6. Connecting Zustand and TanStack Query with types

The previous article showed Zustand filters feeding the useQuery. With TypeScript, this contract needs to be explicit — the filter type in the store must match the type the query accepts.

JAVASCRIPT
// O tipo dos filtros — definido uma vez, usado em dois lugaresinterface ProductFilters {  category: string | null;  priceRange: [number, number];  search: string;}// No store do Zustandinterface UISlice {  filters: ProductFilters;  // mesmo tipo  updateFilters: (partial: Partial<ProductFilters>) => void;}const createUISlice: StateCreator<AppStore, [], [], UISlice> = (set) => ({  filters: {    category: null,    priceRange: [0, 1000],    search: '',  },  updateFilters: (partial) =>    set((state) => ({ filters: { ...state.filters, ...partial } })),});// No hook de query — o tipo do filtro é inferido do storefunction useProducts() {  const filters = useAppStore((state) => state.filters);  // filters: ProductFilters — inferido automaticamente  return useQuery<Product[], ApiError>({    queryKey: productKeys.list(filters), // bate o tipo    queryFn: () => fetchProducts(filters),    placeholderData: (prev) => prev, // keepPreviousData no v5  });}// Selectors tipados — evitam re-render desnecessárioconst category = useAppStore((state) => state.filters.category);// category: string | null — inferido automaticamente

Defining the filter type once and reusing it on both sides is more than organization — it's a contract. If the filter changes (adding a new field, for example), TypeScript points out all the places that need to change together.

Shared Type Contract: Bridging State and Server TS validates TS validates ProductFilters category, priceRange, search STATE MANAGEMENT Zustand Store filters state & setFilters SERVER STATE TanStack Query useQuery parameters
Shared Type Contract: Bridging State and Server

7. The queryOptions pattern (TanStack Query v5)

If you're on TanStack Query v5, the queryOptions helper centralizes the query definition — types, key, and function — in one place. The result is that anywhere using the query inherits the types automatically, without needing to repeat the generics.

TYPESCRIPT
import { queryOptions } from '@tanstack/react-query';// Define a query uma vezconst productsQueryOptions = (filters: ProductFilters) =>  queryOptions({    queryKey: productKeys.list(filters),    queryFn: () => fetchProducts(filters),    staleTime: 5 * 60 * 1000,  });// O tipo de retorno é inferido de fetchProducts automaticamente// Usa em componentes — sem repetir os genericsfunction useProducts(filters: ProductFilters) {  return useQuery(productsQueryOptions(filters));}// Prefetch com o mesmo objeto — consistência garantidaasync function prefetchProducts(filters: ProductFilters) {  await queryClient.prefetchQuery(productsQueryOptions(filters));}// Acesso direto ao cache — também tipadoconst cached = queryClient.getQueryData(  productsQueryOptions(filters).queryKey);// cached: Product[] | undefined