Zustand Async: 5 Effective Ways to Handle Async Operations in React

Every real-world React app has to communicate with asynchronous APIs. If managed poorly, your app can run into issues like a frozen UI, race conditions, memory leaks, or showing stale data. Zustand solves this with an incredibly simple syntax — define async actions directly in the store, without complex middleware like Redux Thunk or Saga.

Zustandasyncstate managementReact
Cover image: Zustand Async: 5 Effective Ways to Handle Async Operations in React
Avatar of Trung Vũ Hoàng

Trung Vũ Hoàng

Author

1/4/20263 min read

1. Why Do You Need Centralized Async State Management?

Each API request needs to track at least 3 states: loading, success, error. If this logic is scattered across components, the codebase quickly becomes hard to read and difficult to debug. Centralizing async state in a Zustand store helps:

  • Fetch logic is completely separated from UI components.

  • Multiple components share a single source of truth.

  • It’s easier to handle race conditions and cancel requests when a component unmounts.

2. Method 1 — Basic Async Store

An async action in Zustand is just a normal async function inside the store:

import { create } from 'zustand';

const useUserStore = create((set) => ({
  user: null,
  isLoading: false,
  error: null,

  fetchUser: async (userId) => {
    set({ isLoading: true, error: null });
    try {
      const res = await fetch(`/api/users/${userId}`);
      if (!res.ok) throw new Error('Unable to load data!');
      const data = await res.json();
      set({ user: data, isLoading: false });
    } catch (err) {
      set({ error: err.message, isLoading: false });
    }
  },
}));

3. Method 2 — Use It in a Component with Selectors

Subscribe only to the exact slice of state you need to avoid unnecessary re-renders:

const UserProfile = ({ userId }) => {
  const { user, isLoading, error, fetchUser } = useUserStore(state => ({
    user: state.user,
    isLoading: state.isLoading,
    error: state.error,
    fetchUser: state.fetchUser,
  }));

  useEffect(() => {
    fetchUser(userId);
  }, [userId]);

  if (isLoading) return <p>Loading...</p>;
  if (error) return <p>Error: {error}</p>;
  return <div>{user?.name}</div>;
};

4. Method 3 — Run Multiple Requests in Parallel

Use Promise.all inside an action to fetch multiple sources at the same time:

fetchDashboard: async () => {
  set({ isLoading: true });
  try {
    const [stats, users, orders] = await Promise.all([
      fetch('/api/stats').then(r => r.json()),
      fetch('/api/users').then(r => r.json()),
      fetch('/api/orders').then(r => r.json()),
    ]);
    set({ stats, users, orders, isLoading: false });
  } catch (err) {
    set({ error: err.message, isLoading: false });
  }
},

5. Method 4 — Optimistic Update

Update the UI immediately, then roll back if the API fails:

addItem: async (item) => {
  // Update UI first
  set(state => ({ items: [...state.items, item] }));
  try {
    await fetch('/api/items', { method: 'POST', body: JSON.stringify(item) });
  } catch (err) {
    // Roll back on error
    set(state => ({ items: state.items.filter(i => i.id !== item.id) }));
  }
},

6. Method 5 — Cancel Requests When They’re No Longer Needed

Use AbortController to cancel a fetch when the component unmounts or when userId changes:

fetchUser: async (userId, signal) => {
  set({ isLoading: true });
  try {
    const res = await fetch(`/api/users/${userId}`, { signal });
    const data = await res.json();
    set({ user: data, isLoading: false });
  } catch (err) {
    if (err.name !== 'AbortError') {
      set({ error: err.message, isLoading: false });
    }
  }
},

// In the component:
useEffect(() => {
  const controller = new AbortController();
  fetchUser(userId, controller.signal);
  return () => controller.abort(); // Cancel on unmount
}, [userId]);

7. Optimize with Middleware

  • devtools: Integrate Redux DevTools — debug async actions easily.

  • immer: Mutable-looking state update syntax while keeping immutability under the hood.

  • persist: Save state to localStorage and restore it when the page reloads.

Summary

Zustand makes handling async in React more lightweight and explicit than any other solution:

  • Basic: async functions directly in the store.

  • Performance: Selectors to avoid unnecessary re-renders.

  • Parallel: Promise.all inside actions.

  • Better UX: Optimistic updates + rollback.

  • Safety: AbortController to prevent memory leaks.

Frequently Asked Questions

Share this article
Zalo

Found this article helpful?

Contact us for a free consultation about our services

Contact us

Bài viết liên quan