FrontendFrontend · 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.
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:
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:
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.
// 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.
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:
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
useQuery({
queryKey: ['products'],
...
});
// Typo — não invalida nada
queryClient.invalidateQueries(
['product']
);Typed factory — safe
// Typo vira erro em compile time
useQuery({
queryKey: productKeys.list(filters),
...
});
queryClient.invalidateQueries({
queryKey: productKeys.all
});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.
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.
// 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 automaticamenteDefining 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.
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.
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[] | undefinedqueryOptions 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