# How to Implement Async Actions and Side Effects in Zustand

> Master async actions and side effects in Zustand. Learn to use async await with set and leverage subscriptions and middleware for robust state management.

- Repository: [Poimandres/zustand](https://github.com/pmndrs/zustand)
- Tags: how-to-guide
- Published: 2026-03-06

---

**Zustand supports async actions and side effects natively by allowing store methods to use `async/await` and calling `set` after promises resolve, while subscriptions and middleware like `subscribeWithSelector` and `persist` provide structured patterns for reactive side effects and async storage coordination.**

Zustand is a minimal, unopinionated state management library for React that treats your store as a plain JavaScript object. Unlike Redux or similar libraries, Zustand does not require special "thunk" middleware to handle asynchronous logic. This article explains how to implement async actions and side effects in Zustand according to the `pmndrs/zustand` source code.

## Understanding Zustand's Core Architecture for Async Operations

### The Vanilla Store Foundation

At its core, Zustand's store is built in [`src/vanilla.ts`](https://github.com/pmndrs/zustand/blob/main/src/vanilla.ts). The `create` function (or `createWithEqualityFn`) returns a hook that receives `set`, `get`, and the low-level `api` object. These helpers are pure functions that can be called from inside `async` functions without any extra plumbing.

Because the store is just a closure over these functions, you can define methods that perform asynchronous work—such as `fetch` requests, timers, or file I/O—and update state when the operation completes.

### State Updates After Async Resolution

When an async action resolves, it calls `set` (or `api.setState`) just like a synchronous action. In [`src/vanilla.ts`](https://github.com/pmndrs/zustand/blob/main/src/vanilla.ts), `set` merges state shallowly by default, triggering a re-render in React components that subscribe to the affected slices. This means you don't need to wrap async logic in special middleware; the store handles updates uniformly regardless of whether they originate from synchronous or asynchronous code.

## Implementing Async Actions in Zustand Stores

The idiomatic pattern for async actions in Zustand is to define an `async` method in your store that uses `await` for side effects and calls `set` upon completion. This pattern is documented in `docs/learn/guides/beginner-typescript.md#async-actions`.

```typescript
// src/stores/weatherStore.ts
import { create } from 'zustand'

interface WeatherState {
  temperature: number | null
  loading: boolean
  error: string | null
  fetchWeather: (city: string) => Promise<void>
}

export const useWeatherStore = create<WeatherState>()((set) => ({
  temperature: null,
  loading: false,
  error: null,
  
  fetchWeather: async (city) => {
    set({ loading: true, error: null })
    try {
      const res = await fetch(`https://api.example.com/weather?city=${city}`)
      const data = await res.json()
      set({ temperature: data.temp, loading: false })
    } catch (e) {
      set({ error: (e as Error).message, loading: false })
    }
  },
}))

```

You can trigger this action from any component or utility function:

```typescript
// Trigger from outside a React component
useWeatherStore.getState().fetchWeather('Berlin')

```

Because `fetchWeather` returns a `Promise`, you can also `await` it in your UI logic to show spinners or handle sequential operations.

## Handling Side Effects with Subscriptions

Zustand provides two primary patterns for side effects: direct execution inside actions and reactive subscriptions.

### Direct Side Effects Inside Actions

For one-off operations—such as logging, analytics, or non-idempotent API calls—you can place the side effect directly inside the action body before or after the `set` call. This is the simplest approach and requires no additional middleware.

### Reactive Side Effects with subscribeWithSelector

For side effects that must react to specific state changes, use the `subscribeWithSelector` middleware implemented in [`src/middleware/subscribeWithSelector.ts`](https://github.com/pmndrs/zustand/blob/main/src/middleware/subscribeWithSelector.ts). This middleware adds a selector-based overload to the `subscribe` method, allowing you to listen to a slice of state and run effects only when that slice changes.

```typescript
// src/stores/timerStore.ts
import { create } from 'zustand'
import { subscribeWithSelector } from 'zustand/middleware'

interface TimerState {
  seconds: number
  start: () => void
  stop: () => void
}

export const useTimerStore = create<TimerState>()(
  subscribeWithSelector((set, get) => {
    let intervalId: NodeJS.Timeout | null = null

    return {
      seconds: 0,
      start: () => {
        if (!intervalId) {
          intervalId = setInterval(() => set((s) => ({ seconds: s.seconds + 1 })), 1000)
        }
      },
      stop: () => {
        if (intervalId) {
          clearInterval(intervalId)
          intervalId = null
        }
      },
    }
  })
)

// Subscribe to side effects elsewhere in the app
useTimerStore.subscribe(
  (state) => state.seconds,
  (newSec, oldSec) => {
    console.log(`Timer ticked: ${oldSec} → ${newSec}`)
  },
  { fireImmediately: true }
)

```

This pattern decouples your side effects from your React components, making them testable and reusable across the application.

## Coordinating Async Storage and Hydration

When side effects involve asynchronous storage—such as `localStorage`, `IndexedDB`, or React Native's `AsyncStorage`—the `persist` middleware in [`src/middleware/persist.ts`](https://github.com/pmndrs/zustand/blob/main/src/middleware/persist.ts) demonstrates the canonical pattern. This middleware uses the internal `toThenable` helper to normalize both synchronous and asynchronous storage APIs, and it tracks hydration state via a `hydrationVersion` counter to prevent race conditions.

You can apply this same pattern to any async side effect that must coordinate with the store lifecycle:

```typescript
// src/stores/cartStore.ts
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'

interface CartState {
  items: string[]
  add: (id: string) => void
}

export const useCart = create<CartState>()(
  persist(
    (set) => ({
      items: [],
      add: (id) => set((s) => ({ items: [...s.items, id] })),
    }),
    {
      name: 'cart-storage',
      // Example: Async storage for React Native
      storage: createJSONStorage(() => ({
        getItem: async (key) => await AsyncStorage.getItem(key),
        setItem: async (key, value) => await AsyncStorage.setItem(key, value),
        removeItem: async (key) => await AsyncStorage.removeItem(key),
      })),
    }
  )
)

```

The `persist` middleware automatically handles the async `getItem` call during store initialization, re-hydrating the store when the Promise resolves without requiring manual intervention.

## Summary

- **Zustand stores are plain objects**: Async actions are simply methods that use `async/await` and call `set` when data arrives, as implemented in [`src/vanilla.ts`](https://github.com/pmndrs/zustand/blob/main/src/vanilla.ts).
- **No special middleware required**: Unlike Redux, Zustand does not need thunks; the `set` and `get` helpers work inside any async function.
- **Side effects have two paths**: Execute one-off effects directly inside actions, or use `subscribeWithSelector` ([`src/middleware/subscribeWithSelector.ts`](https://github.com/pmndrs/zustand/blob/main/src/middleware/subscribeWithSelector.ts)) for reactive side effects tied to specific state slices.
- **Async storage coordination**: The `persist` middleware ([`src/middleware/persist.ts`](https://github.com/pmndrs/zustand/blob/main/src/middleware/persist.ts)) demonstrates how to handle async storage using `toThenable` and version counters to prevent race conditions during hydration.

## Frequently Asked Questions

### Can I use async/await directly in Zustand actions?

Yes. Zustand actions are regular JavaScript methods. You can mark them as `async` and use `await` for any Promise-based API. When the async work completes, call `set` to update the store. This works because `set` and `get` are just functions passed to your store creator in [`src/vanilla.ts`](https://github.com/pmndrs/zustand/blob/main/src/vanilla.ts), and they remain valid regardless of when they are called.

### How do I handle loading states during async operations?

Handle loading states by setting a boolean flag before and after the async call. In your store, initialize a `loading` property to `false`. At the start of your async action, call `set({ loading: true })`, then perform the fetch. In a `finally` block or after the await, call `set({ loading: false })` along with your data. This pattern ensures your UI can show spinners while data loads.

### What's the difference between side effects in actions versus subscriptions?

Side effects inside actions are imperative and tied to specific user interactions or method calls, making them ideal for one-off tasks like logging or analytics. Subscriptions, particularly via `subscribeWithSelector` from [`src/middleware/subscribeWithSelector.ts`](https://github.com/pmndrs/zustand/blob/main/src/middleware/subscribeWithSelector.ts), are reactive and run whenever a specific slice of state changes, regardless of what caused the change. Use subscriptions when you need to synchronize external systems (like localStorage or WebSockets) with state changes automatically.

### How does the persist middleware handle async storage?

The `persist` middleware in [`src/middleware/persist.ts`](https://github.com/pmndrs/zustand/blob/main/src/middleware/persist.ts) normalizes async storage APIs using an internal `toThenable` helper, which treats both synchronous and asynchronous `getItem`/`setItem` calls uniformly. It also tracks hydration state with a `hydrationVersion` counter to prevent race conditions if the store is modified before async rehydration completes. This allows you to use any Promise-based storage backend (like React Native's AsyncStorage or IndexedDB) without manual coordination.