Tipando stores do Zustand

1. Introdução ao Zustand com TypeScript

O Zustand é uma biblioteca de gerenciamento de estado para React que se destaca pela simplicidade e performance. Quando combinado com TypeScript, ele oferece tipagem forte que previne erros comuns em tempo de compilação, como acessar propriedades inexistentes ou chamar ações com argumentos incorretos.

Benefícios da tipagem forte em stores globais:
- Autocomplete inteligente no editor
- Detecção precoce de erros de tipo
- Refatoração segura de estado e ações
- Documentação viva através dos tipos

Instalação:

npm install zustand
# ou
yarn add zustand

Diferente do Redux, o Zustand não requer configuração de providers ou action creators, tornando-o ideal para projetos TypeScript onde a simplicidade é prioridade.

2. Criando a primeira store tipada

Vamos criar uma store de carrinho de compras com tipagem completa:

import { create } from 'zustand'

interface CartItem {
  id: number
  name: string
  price: number
  quantity: number
}

interface CartState {
  items: CartItem[]
  total: number
  addItem: (item: Omit<CartItem, 'quantity'>) => void
  removeItem: (id: number) => void
  clearCart: () => void
}

const useCartStore = create<CartState>()((set) => ({
  items: [],
  total: 0,
  addItem: (item) => set((state) => {
    const existingItem = state.items.find(i => i.id === item.id)
    if (existingItem) {
      return {
        items: state.items.map(i => 
          i.id === item.id 
            ? { ...i, quantity: i.quantity + 1 }
            : i
        ),
        total: state.total + item.price
      }
    }
    return {
      items: [...state.items, { ...item, quantity: 1 }],
      total: state.total + item.price
    }
  }),
  removeItem: (id) => set((state) => ({
    items: state.items.filter(i => i.id !== id),
    total: state.items
      .filter(i => i.id !== id)
      .reduce((acc, i) => acc + i.price * i.quantity, 0)
  })),
  clearCart: () => set({ items: [], total: 0 })
}))

Note o uso de create<CartState>() com parênteses duplos — isso é necessário para inferência correta de tipos no TypeScript.

3. Tipagem de ações e mutações complexas

Ações assíncronas são comuns em aplicações reais. Veja como tipá-las corretamente:

interface UserState {
  user: User | null
  loading: boolean
  error: string | null
  fetchUser: (id: number) => Promise<void>
  updateUser: (data: Partial<User>) => Promise<void>
}

const useUserStore = create<UserState>()((set, get) => ({
  user: null,
  loading: false,
  error: null,

  fetchUser: async (id) => {
    set({ loading: true, error: null })
    try {
      const response = await fetch(`/api/users/${id}`)
      if (!response.ok) throw new Error('Failed to fetch')
      const user: User = await response.json()
      set({ user, loading: false })
    } catch (error) {
      set({ 
        error: error instanceof Error ? error.message : 'Unknown error',
        loading: false 
      })
    }
  },

  updateUser: async (data) => {
    const currentUser = get().user
    if (!currentUser) return

    set({ loading: true })
    try {
      const response = await fetch(`/api/users/${currentUser.id}`, {
        method: 'PATCH',
        body: JSON.stringify(data)
      })
      const updatedUser: User = await response.json()
      set({ user: updatedUser, loading: false })
    } catch (error) {
      set({ 
        error: error instanceof Error ? error.message : 'Update failed',
        loading: false 
      })
    }
  }
}))

O get() permite acessar o estado atual dentro de ações, essencial para lógicas que dependem do estado anterior.

4. Utilizando slices para modularizar stores

Para stores grandes, use slices para organizar o código:

import { create, StateCreator } from 'zustand'

interface AuthSlice {
  user: User | null
  token: string | null
  login: (email: string, password: string) => Promise<void>
  logout: () => void
}

interface CartSlice {
  items: CartItem[]
  addToCart: (item: CartItem) => void
}

interface AppStore extends AuthSlice, CartSlice {}

const createAuthSlice: StateCreator<AppStore, [], [], AuthSlice> = (set) => ({
  user: null,
  token: null,
  login: async (email, password) => {
    const response = await fetch('/api/login', {
      method: 'POST',
      body: JSON.stringify({ email, password })
    })
    const data = await response.json()
    set({ user: data.user, token: data.token })
  },
  logout: () => set({ user: null, token: null })
})

const createCartSlice: StateCreator<AppStore, [], [], CartSlice> = (set) => ({
  items: [],
  addToCart: (item) => set((state) => ({
    items: [...state.items, item]
  }))
})

const useAppStore = create<AppStore>()((...a) => ({
  ...createAuthSlice(...a),
  ...createCartSlice(...a)
}))

A tipagem StateCreator garante que os slices compartilhem o mesmo tipo de store, permitindo acesso cruzado entre eles.

5. Middlewares com tipagem avançada

O middleware persist com tipagem parcial:

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

interface SettingsState {
  theme: 'light' | 'dark'
  language: string
  setTheme: (theme: 'light' | 'dark') => void
  setLanguage: (lang: string) => void
}

const useSettingsStore = create<SettingsState>()(
  persist(
    (set) => ({
      theme: 'light',
      language: 'en',
      setTheme: (theme) => set({ theme }),
      setLanguage: (language) => set({ language })
    }),
    {
      name: 'settings-storage',
      storage: createJSONStorage(() => localStorage),
      partialize: (state) => ({
        theme: state.theme,
        language: state.language
      })
    }
  )
)

Middleware immer para mutações imutáveis:

import { immer } from 'zustand/middleware/immer'

interface TodoState {
  todos: Todo[]
  addTodo: (title: string) => void
  toggleTodo: (id: number) => void
}

const useTodoStore = create<TodoState>()(
  immer((set) => ({
    todos: [],
    addTodo: (title) => set((state) => {
      state.todos.push({ id: Date.now(), title, completed: false })
    }),
    toggleTodo: (id) => set((state) => {
      const todo = state.todos.find(t => t.id === id)
      if (todo) todo.completed = !todo.completed
    })
  }))
)

6. Integração com hooks do React

Hooks customizados com seletores tipados:

import { useStore } from 'zustand'
import { useCartStore } from './cart-store'

// Hook com seletor parcial
function useCartTotal() {
  return useCartStore((state) => state.total)
}

// Hook customizado completo
function useUserStore() {
  const user = useStore(useUserStore, (state) => state.user)
  const loading = useStore(useUserStore, (state) => state.loading)
  const fetchUser = useStore(useUserStore, (state) => state.fetchUser)

  return { user, loading, fetchUser }
}

// Subscribe para reatividade fina
useCartStore.subscribe(
  (state) => state.items.length,
  (length) => console.log(`Cart has ${length} items`)
)

7. Boas práticas e padrões avançados

Usando type vs interface:

// Prefira interface para objetos que podem ser estendidos
interface AppState {
  user: User | null
}

// Use type para unions ou tipos complexos
type Status = 'idle' | 'loading' | 'success' | 'error'
type AsyncState<T> = {
  data: T | null
  status: Status
  error: string | null
}

Evitando any com unknown:

// Ruim
const updateItem = (item: any) => set({ item })

// Bom
const updateItem = <T>(item: T) => set({ item: item as unknown })

Testando stores tipadas com vitest:

import { describe, it, expect } from 'vitest'
import { useCartStore } from './cart-store'

describe('Cart Store', () => {
  beforeEach(() => {
    useCartStore.setState({ items: [], total: 0 })
  })

  it('should add item to cart', () => {
    useCartStore.getState().addItem({ id: 1, name: 'Book', price: 29.90 })

    const state = useCartStore.getState()
    expect(state.items).toHaveLength(1)
    expect(state.total).toBe(29.90)
  })

  it('should increase quantity for existing item', () => {
    useCartStore.getState().addItem({ id: 1, name: 'Book', price: 29.90 })
    useCartStore.getState().addItem({ id: 1, name: 'Book', price: 29.90 })

    const state = useCartStore.getState()
    expect(state.items[0].quantity).toBe(2)
    expect(state.total).toBe(59.80)
  })
})

A tipagem forte do Zustand com TypeScript transforma o gerenciamento de estado em uma experiência segura e produtiva. Ao adotar esses padrões, você reduzirá bugs, melhorará a manutenibilidade e proporcionará uma melhor experiência de desenvolvimento para sua equipe.

Referências