Zustand Async: 5 Effective Ways to Handle Async in React

Every real-world React app needs to communicate with async APIs. If handled poorly, it’s easy to run into issues like frozen UI, race conditions, memory leaks, or showing stale data. Zustand solves this with an extremely 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 in React
Avatar of Trung Vũ Hoàng

Trung Vũ Hoàng

Author

9/5/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.

  • Easier handling of race conditions and canceling requests when a component unmounts.

2. Approach 1 — Basic Async Store

An async action in Zustand is simply a regular 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. Approach 2 — Use in a Component with a Selector

Only subscribe to the 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. Approach 3 — Run Multiple Requests in Parallel

Use Promise.all inside an action to fetch multiple sources at once:

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. Approach 4 — Optimistic Update

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

addItem: async (item) => {
  // Update the 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. Approach 5 — Cancel Requests When They’re No Longer Needed

Use AbortController to cancel fetch when a 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: Integrates with Redux DevTools — making it easy to debug async actions.

  • immer: Mutable state update syntax while still being immutable under the hood.

  • persist: Save state to localStorage and restore it on page reload.

Conclusion

Zustand makes handling async in React leaner and clearer than any other solution:

  • Basics: async functions directly in the store.

  • Performance: Selectors to avoid unnecessary re-renders.

  • Parallel: Promise.all inside an action.

  • 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