Why TypeScript + Zustand is a Perfect Match
Basic TypeScript Setup
JavaScript Way (No Safety)
const useStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 }))
}))
TypeScript Way (Type Safe)
import { create } from 'zustand'
interface CounterState {
count: number
increment: () => void
decrement: () => void
reset: () => void
}
const useCounterStore = create<CounterState>()((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 }),
}))
Separating State and Actions
interface BearState {
bears: number
fish: number
}
interface BearActions {
increasePopulation: () => void
removeAllBears: () => void
updateBears: (bears: number) => void
}
type BearStore = BearState & BearActions
const useBearStore = create<BearStore>()((set) => ({
// State
bears: 0,
fish: 0,
// Actions
increasePopulation: () => set((state) => ({
bears: state.bears + 1
})),
removeAllBears: () => set({ bears: 0 }),
updateBears: (bears) => set({ bears }),
}))
Benefits
- Clear separation between data and behavior
- Easy to see all state properties at a glance
- Intellisense shows actions separately from state
Typing Selectors
function BearCounter() {
// Type of 'bears' is inferred as number
const bears = useBearStore((state) => state.bears)
// Type of 'increasePopulation' is inferred as () => void
const increasePopulation = useBearStore((state) => state.increasePopulation)
return (
<div>
<h1>{bears} bears around here</h1>
<button onClick={increasePopulation}>Add bear</button>
</div>
)
}
Using useShallow for Multiple Selections
import { useShallow } from 'zustand/react/shallow'
function BearInfo() {
const { bears, fish } = useBearStore(
useShallow((state) => ({
bears: state.bears,
fish: state.fish
}))
)
return <div>Bears: {bears}, Fish: {fish}</div>
}
Typing with Middleware
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
import type {} from '@redux-devtools/extension' // Required for devtools typing
interface AuthState {
user: User | null
token: string | null
isAuthenticated: boolean
login: (email: string, password: string) => Promise<void>
logout: () => void
}
const useAuthStore = create<AuthState>()(
devtools(
persist(
(set) => ({
user: null,
token: null,
isAuthenticated: false,
login: async (email, password) => {
const response = await authAPI.login(email, password)
set({
user: response.user,
token: response.token,
isAuthenticated: true
})
},
logout: () => set({
user: null,
token: null,
isAuthenticated: false
}),
}),
{ name: 'auth-storage' }
),
{ name: 'AuthStore' }
)
)
Typing Slices
// userSlice.ts
interface UserSliceState {
user: User | null
isLoading: boolean
}
interface UserSliceActions {
setUser: (user: User) => void
clearUser: () => void
}
type UserSlice = UserSliceState & UserSliceActions
export const createUserSlice = (set: StateCreator<UserSlice>): UserSlice => ({
user: null,
isLoading: false,
setUser: (user) => set({ user, isLoading: false }),
clearUser: () => set({ user: null }),
})
// cartSlice.ts
interface CartSliceState {
items: CartItem[]
total: number
}
interface CartSliceActions {
addItem: (item: CartItem) => void
removeItem: (id: string) => void
}
type CartSlice = CartSliceState & CartSliceActions
export const createCartSlice = (set: StateCreator<CartSlice>): CartSlice => ({
items: [],
total: 0,
addItem: (item) => set((state) => ({
items: [...state.items, item],
total: state.total + item.price,
})),
removeItem: (id) => set((state) => ({
items: state.items.filter(i => i.id !== id),
})),
})
// Combine slices
import { create } from 'zustand'
type AppStore = UserSlice & CartSlice
const useAppStore = create<AppStore>()((...a) => ({
...createUserSlice(...a),
...createCartSlice(...a),
}))
Resetting State with Type Safety
interface TodoState {
todos: Todo[]
filter: 'all' | 'active' | 'completed'
addTodo: (text: string) => void
toggleTodo: (id: string) => void
resetState: () => void
}
const initialState = {
todos: [] as Todo[],
filter: 'all' as const,
}
const useTodoStore = create<TodoState>()((set) => ({
...initialState,
addTodo: (text) => set((state) => ({
todos: [...state.todos, { id: uuid(), text, completed: false }],
})),
toggleTodo: (id) => set((state) => ({
todos: state.todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
),
})),
resetState: () => set(initialState), // TypeScript ensures compatibility
}))
Common TypeScript Patterns
Using useShallow for Multiple Selections
interface BaseState<T> {
data: T | null
isLoading: boolean
error: string | null
fetch: () => Promise<void>
reset: () => void
}
function createDataStore<T>(fetchFn: () => Promise<T>) {
return create<BaseState<T>>()((set) => ({
data: null,
isLoading: false,
error: null,
fetch: async () => {
set({ isLoading: true, error: null })
try {
const data = await fetchFn()
set({ data, isLoading: false })
} catch (error) {
set({ error: error.message, isLoading: false })
}
},
reset: () => set({ data: null, isLoading: false, error: null }),
}))
}
// Usage
const useUserStore = createDataStore<User>(() => api.fetchUser())
const usePostsStore = createDataStore<Post[]>(() => api.fetchPosts())
Explore project snapshots or discuss custom web solutions.
Strict Mode Configuration
{
"compilerOptions": {
"strict": true,
"strictNullChecks": true,
"noUncheckedIndexedAccess": true,
"noImplicitAny": true
}
}
Type Inference Tips
- Let TypeScript Infer When Possible - Don't over-annotate
- Use `as const` for Literals - Better type narrowing
- Avoid `any` - Use `unknown` for uncertain types
- Generic Constraints - Ensure types meet requirements
Type systems are about catching bugs early, not annoying developers.
Thank You for Spending Your Valuable Time
I truly appreciate you taking the time to read blog. Your valuable time means a lot to me, and I hope you found the content insightful and engaging!
Frequently Asked Questions
The curried form is required for proper TypeScript inference. It's a workaround for TypeScript's limitation with invariant generics. The Zustand docs explain this is due to the impossibility of implementing type-perfect inference.
Same as sync actions, just return `Promise<void>`: `login: (email: string) => Promise<void>`. TypeScript infers the promise automatically.
Zustand hooks work only in Client Components. Mark components with `'use client'` directive. The Zustand README warns against adding state in React Server Components to avoid bugs and privacy issues.
Either works. Interfaces are more extensible, types are more powerful. Pick one and stay consistent. Modern TS best practices slightly favor interfaces for object shapes.
Use discriminated unions: `type State = {status: 'loading'} | {status: 'success', data: T} | {status: 'error', error: string}`. TypeScript narrows types based on status.
Comments are closed