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

Frontend · 11 Jun 2026

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.

Genildo Souza11 Jun 2026Read: 9 min

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:

Code
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

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

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.

The correct type for a slice is StateCreator:

Code
import { create } from 'zustand';
import { StateCreator } from 'zustand';

// Tipos de domínio
interface User {
  id: string;
  name: string;
  email: string;
}

interface LoginCredentials {
  email: string;
  password: string;
}

// Tipos do slice de usuário
interface UserSlice {
  user: User | null;
  isAuthenticated: boolean;
  login: (credentials: LoginCredentials) => Promise<void>;
  logout: () => void;
}

// Tipos do slice de UI
interface UISlice {
  theme: 'light' | 'dark';
  sidebarOpen: boolean;
  toggleTheme: () => void;
  toggleSidebar: () => void;
}

// O tipo completo do store
type AppStore = UserSlice & UISlice;

// StateCreator garante que cada slice conhece o store inteiro
const 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();
  },
});

Why StateCreator and not just typing the functions manually? Because StateCreator ensures that get() inside any slice returns the type of the complete store — not just the current slice. Without it, TypeScript doesn't know that get().toggleSidebar exists when you're inside createUserSlice.

Code
// Combinando os slices — o TypeScript valida a union
const 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.

Code
import { StateCreator } from 'zustand';

// Com persist + devtools, o segundo parâmetro lista os middlewares
const createUserSlice: StateCreator<
  AppStore,
  [
    ['zustand/devtools', never],
    ['zustand/persist', Partial<AppStore>]
  ],
  [],
  UserSlice
> = (set, get) => ({ ... });

// O store combinado com middleware tipado corretamente
const useAppStore = create<AppStore>()(
  devtools(
    persist(
      (...args) => ({
        ...createUserSlice(...args),
        ...createUISlice(...args),
      }),
      { name: 'app-store' }
    )
  )
);

Important detail: when using middleware, create needs to be called with double parentheses: create<AppStore>()(). The first () lets TypeScript correctly infer the middleware types. Without it, you lose typing for devtools and persist.


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:

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

// Tipos de domínio
interface Product {
  id: number;
  name: string;
  price: number;
  category: string;
}

interface ApiError {
  message: string;
  statusCode: number;
}

// Sem generic explícito — error é unknown
const { data, error } = useQuery({
  queryKey: ['products'],
  queryFn: fetchProducts, // retorna Promise<Product[]>
});
// error aqui é: Error | null — não ApiError

// Com generics explícitos — error é ApiError
const { data, error } = useQuery<Product[], ApiError>({
  queryKey: ['products'],
  queryFn: fetchProducts,
});

// Agora error.statusCode existe e o TypeScript sabe disso
if (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

Code
useQuery({
  queryKey: ['products'],
  ...
});

// Typo — não invalida nada
queryClient.invalidateQueries(
  ['product']
);

Typed factory — safe

Code
// Typo vira erro em compile time
useQuery({
  queryKey: productKeys.list(filters),
  ...
});

queryClient.invalidateQueries({
  queryKey: productKeys.all
});
Code
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 key
useQuery({
  queryKey: productKeys.list({ category: 'electronics', priceRange: [0, 1000], search: '' }),
  queryFn: () => fetchProducts({ category: 'electronics', priceRange: [0, 1000], search: '' }),
});

// Invalidar todos os produtos
queryClient.invalidateQueries({ queryKey: productKeys.all });

// Invalidar só o produto de id 42
queryClient.invalidateQueries({ queryKey: productKeys.detail(42) });

The as const matters here. Without it, TypeScript infers the key types as string[] — and loses the ability to validate that you're using the right key when invalidating. With as const, each key becomes a literal tuple that the compiler compares with precision.


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.

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

Code
// O tipo dos filtros — definido uma vez, usado em dois lugares
interface ProductFilters {
  category: string | null;
  priceRange: [number, number];
  search: string;
}

// No store do Zustand
interface 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 store
function 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ário
const 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.

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

// Define a query uma vez
const 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 generics
function useProducts(filters: ProductFilters) {
  return useQuery(productsQueryOptions(filters));
}

// Prefetch com o mesmo objeto — consistência garantida
async function prefetchProducts(filters: ProductFilters) {
  await queryClient.prefetchQuery(productsQueryOptions(filters));
}

// Acesso direto ao cache — também tipado
const cached = queryClient.getQueryData(
  productsQueryOptions(filters).queryKey
);
// cached: Product[] | undefined

queryOptions is v5-exclusive. If you're still on v4, the equivalent is creating a manual config object and reusing it — the principle is the same, but without the helper. The migration to v5 is worth it for the overall improvement in type inference.


What changes when you type things correctly

  • StateCreator<AppStore, [], [], Slice> — each slice sees the entire store, and TypeScript validates the union on combination.

  • Middleware with double parentheses — create<T>()(devtools(persist(...))) is necessary for TypeScript to infer the middleware types.

  • Query key factories with as const — keys become literal tuples, not loose strings. Invalidations are validated at compile time.

  • useMutation<TData, TError, TVariables, TContext> — the fourth generic types the rollback context, which without typing is unknown.

  • Shared filter type — defined once, used in the store and the query. Changes propagate automatically.

  • queryOptions in v5 — centralizes key, function and configuration. Prefetch, hooks and invalidation share the same type.

Série

Wanna see the duo in action from scratch?

The first article in the series explains why Zustand and TanStack Query complement each other, with practical examples of slices, mutations and project architecture.