blense.
HomeFrontendCodeAINewsGuidesCheatsheetsAbout

Technology decoded, always.

A curated read on AI, product, and the culture behind the code. No noise, no hype.

blense.

Technology with focus. Stories about what innovation really changes.

Sections

FrontendArtificial IntelligenceCode & DevTech News

Blense

AboutRSSPrivacy policyTermsCookies
© 2026 Blense · blense.fun
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.

Gby Genildo SouzaJun 119 min read
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();  },});
ℹ️

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.

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' }    )  ));
⚠️

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.

The elite dev's arsenal.
Stop Using useMemo Wrong: A Practical Guide to React Performance14 minAngular Micro-frontends: How to Scale Large Applications and Eliminate Monolithic Build Times with Module Federation10 minTanStack Query: How to Eliminate Manual Server State Management in React6 minMicrofrontends with Angular and Nx: what nobody tells you15 min

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) });
ℹ️

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.

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
ℹ️

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
  • 1StateCreator<AppStore, [], [], Slice> — each slice sees the entire store, and TypeScript validates the union on combination.
  • 2Middleware with double parentheses — create<T>()(devtools(persist(...))) is necessary for TypeScript to infer the middleware types.
  • 3Query key factories with as const — keys become literal tuples, not loose strings. Invalidations are validated at compile time.
  • 4useMutation<TData, TError, TVariables, TContext> — the fourth generic types the rollback context, which without typing is unknown.
  • 5Shared filter type — defined once, used in the store and the query. Changes propagate automatically.
  • 6queryOptions 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.

Ler a Parte 1

#JavaScript#TypeScript#React

The elite dev's arsenal.

Stop Using useMemo Wrong: A Practical Guide to React Performance
Frontend

Stop Using useMemo Wrong: A Practical Guide to React Performance

Master React useMemo with this guide. Learn when to memoize, how to fix dependency array bugs, and why over-optimization can hurt your performance.

Genildo Souza · Jul 8 · 14 min
CSS Animations: How to Build Performant, Professional Interfaces Without Messy Code
Frontend

CSS Animations: How to Build Performant, Professional Interfaces Without Messy Code

Bring your web interfaces to life. Learn when to use CSS transitions versus keyframes and how to optimize your animations for peak performance.

Genildo Souza · Jul 4 · 17 min
Angular Micro-frontends: How to Scale Large Applications and Eliminate Monolithic Build Times with Module Federation
Frontend

Angular Micro-frontends: How to Scale Large Applications and Eliminate Monolithic Build Times with Module Federation

Discover how to break down large Angular monoliths using Module Federation to enable independent deployments and seamless team scaling.

Genildo Souza · Jun 21 · 10 min
In this article
  • 1. Typing slices with StateCreator
  • 2. Typing slices with middleware
  • 3. useQuery with explicit generics
  • 4. Typed query key factories
  • 5. useMutation with all four generics
  • 6. Connecting Zustand and TanStack Query with types
  • 7. The queryOptions pattern (TanStack Query v5)