Mastering State Management: Combining Zustand and React Query for a Seamless UI

Many programmers find it challenging to use both Zustand and React Query together effectively.

When building modern React applications, managing state efficiently is crucial for both performance and maintainability. Two powerful libraries that help with state management are Zustand and React Query, each serving distinct but complementary purposes.

Zustand is a lightweight state management library that provides a simple API for managing client-side state without the boilerplate of Redux. It excels at handling UI state, such as modal visibility, form inputs, and user preferences.

On the other hand, React Query is designed for managing server state—data that comes from an API. It simplifies fetching, caching, synchronizing, and updating remote data, eliminating the need for manually handling loading states and refetching logic.

Why Combine Zustand and React Query?

While React Query is excellent for fetching and syncing data with the server, it doesn’t manage temporary client-side state efficiently. This is where Zustand comes in. By using Zustand for UI state (e.g., managing filters, modal states, or optimistic updates) and React Query for server state (fetching and caching API data), you can create a highly optimized and scalable application.

For example, instead of repeatedly refetching data from the server, you can store API results in Zustand for quick access while React Query keeps it in sync in the background. This combination reduces unnecessary network requests, improves performance, and provides a better user experience.

In this article, we’ll explore how to integrate these two libraries seamlessly to build a robust state management system for React applications.

Setting Up the Project

To integrate Zustand and React Query into a React project, follow these steps:

  1. Create a project

    First, create a project using one of the following commands depending on your framework (React, Next.js, or React Native):

For Next.js:

npx create-next-app@latest

For React:

npm create vite@latest my-react-app --template react
  1. Install Dependencies

Next, install the required dependencies using one of the following commands:

Using Yarn:

yarn add @tanstack/react-query zustand axios

Using NPM:

npm install @tanstack/react-query zustand axios

Using PNPM:

npm add @tanstack/react-query zustand axios

Folder Structure Overview:

This is the folder structure of my Next.js project. It organizes the various components, configurations, and resources necessary for the app’s functionality. Below is a brief breakdown of some key directories and files:

  • api: Holds server-side logic and API routes.

  • app: Main application directory where pages and components reside.

  • components: Reusable React components used across different parts of the application.

  • config: Contains configuration files for Next.js and other project settings.

  • public: Static assets such as images, fonts, etc.

  • store: State management files for zustand.

  1. Set Up React Query Provider.

    Create a Provider File:

    It's a good practice to centralize your application’s providers. I like to create a providers folder and store all my provider components there for easy reusability and organization.

    In your providers folder, create a file called AppProvider.tsx. This component will contain the QueryClientProvider and wrap the rest of the application in it.

"use client";

import React from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import AppToast from "@/components/AppToast";

const AppProvider = ({ children }: { children: React.ReactNode }) => {
  const queryClient = new QueryClient();
  return (
    <QueryClientProvider client={queryClient}>
      {children}
      <AppToast />
    </QueryClientProvider>
  );
};

export default AppProvider;

Here, we:

  • Import QueryClient and QueryClientProvider from @tanstack/react-query.

  • Create a new instance of QueryClient which React Query uses to manage API requests.

  • Wrap the children (your entire app) in QueryClientProvider to make React Query’s functionality available globally.

  • Added an optional custom toast component (AppToast) to be globally available.

    Wrap Your Application with the AppProvider:

    Now that the provider is set up, the next step is to wrap your entire app in AppProvider so React Query’s context is accessible throughout your app.

    Assuming you're using a layout file like RootLayout.tsx or a main entry point like index.tsx, you can wrap your app as follows:

RootLayout.tsx Example:

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body
        className={`${geistSans.variable} ${geistMono.variable} antialiased`}
      >
        <AppProvider>
          <div>{children}</div>
        </AppProvider>
      </body>
    </html>
  );
}

In this layout file, we:

  • Import AppProvider from the providers folder.

  • Wrap the entire app ({children}) inside the AppProvider to make sure React Query is available throughout the application.

Now, React Query is ready to manage server state.

Using Zustand for Global Client State

Zustand is a minimalistic state management tool that can be used for managing UI state globally in a simple and scalable way without the need for prop drilling.

  1. Create a Zustand Store

    Create a new file called auth.ts inside your store folder:

import { getSession } from "@/lib/axios";
import { create } from "zustand";

type StringNull = string | null;

interface User {
  accessToken: string;
  email: string;
  firstName: string;
  gender: string;
  id: number;
  image: string;
  lastName: string;
  refreshToken: string;
  username: string;
}

interface AuthState {
  user: User | null;
  accessToken: StringNull;
  isAuthenticated: boolean;
  setAuth: (user: any, token: string) => void;
  logout: () => void;
}

export const useAuthstore = create<AuthState>((set) => ({
  user: null,
  accessToken: null,
  isAuthenticated: !!getSession(),

  setAuth: (user, token) =>
    set((state) => ({
      ...state,
      user,
      accessToken: token,
      isAuthenticated: true,
    })),

  logout: () =>
    set((state) => ({
      ...state,
      user: null,
      accessToken: null,
      isAuthenticated: false,
    })),
}));

Using React Query for Server State

React Query simplifies data fetching using the useQuery hook and the useMutation hook. Let’s fetch a list of users from an API and handle user authentication asynchronously.

Folder Structure:

We will have the following structure inside the api folder:

api/
  ├── hooks/         # Custom React Query hooks (for data fetching, mutations)
  └── services/      # API services or functions that handle the logic for API requests (e.g., fetching users, authentication)

Creating the services/auth.ts File:

In this file, you'll define functions that will interact with your API endpoints for authentication and fetching users. These functions will be used in the React Query hooks.

api/services/auth.ts (Handles API calls like login, fetching users, etc.)

import api, { getSession } from "@/lib/axios";
import { LoginValues } from "@/types/auth";

const login = async (data: LoginValues) => {
  const res = await api.post("/user/login", {
    username: data.username,
    password: data.password,
  });
  return res.data;
};

const fetchUsers = async () => {
  const res = await api.get("/users");
  return res.data;
};

export { login, fetchUsers };

In this file:

  • login handles sending a POST request to authenticate the user.

  • fetchUsers sends a GET request to fetch all users from the server.

Creating the hooks/auth.ts File:

we create a custom React Query hook in the hooks/auth.ts file to handle both authentication (login) and user data fetching. The file defines a mutation for logging in (useMutation) and a query for fetching user data (useQuery).

import { useAuthstore, useToast } from "@/store";
import { LoginValues } from "@/types/auth";
import { useMutation, useQuery } from "@tanstack/react-query";
import { fetchUsers, login } from "../services/auth";
import { setSession } from "@/lib/axios";
import { useRouter } from "next/navigation";
import { localStorageManager } from "@/lib/custom";

export const useAuth = () => {
  const { setAuth } = useAuthstore();
  const { showToast } = useToast();
  const router = useRouter();

  const loginMutation = useMutation({
    mutationFn: (data: LoginValues) => login(data),
    onSuccess: async (data) => {
      console.log({ data });
      const { accessToken, ...rest } = data;
      console.log({ rest });
      await setSession(accessToken);
      setAuth(rest, accessToken);
      await localStorageManager.set("user", rest);
      showToast("Success", "success");
      router.push("/");
      console.log({ data });
    },

    onError: (error: any) => {
      console.log({ error });
      const errorMessage = error.response?.data?.message || "Login failed";
      showToast(errorMessage, "error");
    },
  });

  const useFetchUsers = () => {
    return useQuery({
      queryKey: ["fetchUsers"],
      queryFn: fetchUsers,
      staleTime: 1000 * 60 * 10,
      refetchOnWindowFocus: false,
    });
  };

  return { loginMutation, useFetchUsers };
};

In the auth.ts file inside the hooks folder, we define the main logic for handling user authentication and fetching users.

  1. Login Mutation (useMutation):

    • The useMutation hook is used to handle the login process asynchronously.

    • We send a request to the server with the user's login credentials (email, password), using the login function from the services folder.

    • On success, we handle the response by setting the authentication session, updating the global state with user data, storing it in localStorage, and navigating to the homepage.

    • On error, we display an error message in a toast.

  2. Fetching Users Query (useQuery):

    • The useQuery hook is used to fetch a list of users from the server asynchronously.

    • We set a stale time of 10 minutes to keep the data fresh and prevent unnecessary refetching.

    • We disable refetching when the window is focused, but this can be enabled if needed for your app.

  3. Handling State and Side Effects:

    • Global State Management: We use Zustand's useAuthstore to manage authentication state globally. When the login succeeds, we update the global state with the user's data and access token.

    • Toast Notifications: The useToast hook is used to display success or error messages using toast notifications.

    • Session Management: We use setSession to store the access token in a session or cookie for maintaining the user’s login state.

Implementing the Login Page with React Query:

To implement user authentication in a React application, we integrate React Query and Zustand to manage server state and global state respectively. Here's how to build a login page that handles user authentication using React Query's useMutation.

Step 1: Create the Login Page

In the LoginPage component, we handle form state management, validation, and login functionality.

"use client";

import { useState } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { motion } from "framer-motion";
import { Eye, EyeOff } from "lucide-react";
import { useAuth } from "@/api/hooks/auth";

export default function LoginPage() {
  const [showPassword, setShowPassword] = useState(false);
  const [username, setUsername] = useState("");
  const [password, setPassword] = useState("");
  const [errors, setErrors] = useState({ username: "", password: "" });
  const { loginMutation } = useAuth();

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();

    // Validate fields
    let formErrors = { username: "", password: "" };
    if (!username) formErrors.username = "Username is required";
    if (!password) formErrors.password = "Password is required";

    setErrors(formErrors);

    if (!formErrors.username && !formErrors.password) {
      loginMutation.mutate({ username, password });
    }
  };

  return (
    <div className="flex min-h-screen items-center justify-center bg-gray-100 p-4">
      <motion.div
        initial={{ opacity: 0, y: -20 }}
        animate={{ opacity: 1, y: 0 }}
        transition={{ duration: 0.5 }}
      >
        <Card className="w-full max-w-md shadow-xl bg-white p-6 rounded-2xl">
          <CardHeader>
            <CardTitle className="text-2xl text-center">Login</CardTitle>
          </CardHeader>
          <CardContent>
            <form className="space-y-4" onSubmit={handleSubmit}>
              <div>
                <label className="block text-sm font-medium text-gray-700">
                  Username
                </label>
                <Input
                  type="text"
                  placeholder="Enter your username"
                  value={username}
                  onChange={(e) => setUsername(e.target.value)}
                  className="mt-1"
                />
                {errors.username && (
                  <p className="text-red-500 text-sm">{errors.username}</p>
                )}
              </div>

              <div>
                <label className="block text-sm font-medium text-gray-700">
                  Password
                </label>
                <div className="relative">
                  <Input
                    type={showPassword ? "text" : "password"}
                    placeholder="Enter your password"
                    value={password}
                    onChange={(e) => setPassword(e.target.value)}
                    className="mt-1 pr-10"
                  />
                  <button
                    type="button"
                    className="absolute right-3 top-3 text-gray-500"
                    onClick={() => setShowPassword(!showPassword)}
                  >
                    {showPassword ? <EyeOff size={20} /> : <Eye size={20} />}
                  </button>
                </div>
                {errors.password && (
                  <p className="text-red-500 text-sm">{errors.password}</p>
                )}
              </div>

              <Button
                type="submit"
                className="w-full bg-blue-600 hover:bg-blue-700 text-white"
              >
                {loginMutation.isPending ? "Loading..." : "Login"}
              </Button>
            </form>
          </CardContent>
        </Card>
      </motion.div>
    </div>
  );
}

Step 2: Handling the Form Validation

In the LoginPage, we handle form validation to ensure both the username and password fields are not empty. If the fields are left empty, error messages are displayed below the respective inputs.

const handleSubmit = (e: React.FormEvent) => {
  e.preventDefault();

  // Validate fields
  let formErrors = { username: "", password: "" };
  if (!username) formErrors.username = "Username is required";
  if (!password) formErrors.password = "Password is required";

  setErrors(formErrors);

  if (!formErrors.username && !formErrors.password) {
    loginMutation.mutate({ username, password });
  }
};

Step 3: Password Visibility Toggle

The password input field includes a visibility toggle feature. When the user clicks on the Eye icon, the password is revealed, and when clicked again, it hides the password. This is controlled by the showPassword state.

<button
  type="button"
  className="absolute right-3 top-3 text-gray-500"
  onClick={() => setShowPassword(!showPassword)}
>
  {showPassword ? <EyeOff size={20} /> : <Eye size={20} />}
</button>

Step 4: React Query Mutation for Login

The loginMutation is used to trigger the login process when the user submits the form. It calls the mutate() function from React Query's useMutation hook to send the login request with the entered credentials.

loginMutation.mutate({ username, password });

We also display a loading state on the login button while the mutation is in progress using the isPending property of the mutation:

<Button
  type="submit"
  className="w-full bg-blue-600 hover:bg-blue-700 text-white"
>
  {loginMutation.isPending ? "Loading..." : "Login"}
</Button>

This Login Page provides a smooth and responsive authentication flow. By leveraging React Query for the mutation and Zustand for global state management, we ensure that the user's login state is maintained throughout the app. With the password visibility toggle and real-time validation, the user experience is enhanced, and form errors are handled gracefully.

Displaying User Information in the Navbar

In this section, we demonstrate how to retrieve user information from the global state using Zustand and display it in the AppNavbar component. We’ll display the user's full name and profile image once they are logged in.

Fetching User Data from the Global State

In the AppNavbar component, we use useAuthstore from Zustand to get the user data stored globally. This allows us to dynamically display the user’s name and profile image in the UI.

"use client";

import { useState } from "react";
import Image from "next/image";
import { Input } from "@/components/ui/input";
import { motion } from "framer-motion";
import { useAuthstore } from "@/store";

export default function AppNavbar() {
  const { user } = useAuthstore();

  return (
    <nav className="flex items-center justify-between px-6 py-4 bg-white shadow-md">
      {/* Left: Tutorial Name */}
      <motion.h1
        initial={{ opacity: 0, x: -20 }}
        animate={{ opacity: 1, x: 0 }}
        transition={{ duration: 0.5 }}
        className="text-lg font-semibold text-gray-800"
      >
        Mastering State Management
      </motion.h1>

      {/* Right: User Info */}
      <div className="flex items-center gap-3">
        <span className="text-gray-700 text-sm hidden sm:block">
          {user?.firstName} {user?.lastName}
        </span>
        <Image
          src={user?.image}
          alt={user?.username}
          width={40}
          height={40}
          className="rounded-full border"
        />
      </div>
    </nav>
  );
}

Displaying a List of Users on the Home Page

In the home page, we fetch a list of users from the server and display them in a grid format. We also manage the loading state and handle cases where there is no user data available.

Step 1: Fetching User Data

We use the useFetchUsers hook from the React Query API to fetch a list of users. While the data is loading, a loading spinner is displayed. If there is no data, a message is shown to inform the user.

"use client";

import { useAuth } from "@/api/hooks/auth";
import AppNavbar from "@/components/AppNavbar";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import Image from "next/image";
import { motion } from "framer-motion";
import { Loader } from "lucide-react";

export default function Home() {
  const { useFetchUsers } = useAuth();
  const { data, isLoading } = useFetchUsers();

  return (
    <div className="min-h-screen bg-gray-100 p-6">
      <AppNavbar />

      {/* Show Loading or No Data messages inside the return statement */}
      {isLoading ? (
        <motion.div
          initial={{ opacity: 0, scale: 0.8 }}
          animate={{ opacity: 1, scale: 1 }}
          transition={{ duration: 0.5, ease: "easeInOut" }}
          className="flex flex-col justify-center items-center mt-10"
        >
          <Loader className="animate-spin" />
        </motion.div>
      ) : !data ? (
        <p className="text-center mt-10">No user data available</p>
      ) : (
        <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 p-10">
          {data?.users?.map((user: any) => (
            <motion.div
              key={user.id}
              initial={{ opacity: 0, y: -10 }}
              animate={{ opacity: 1, y: 0 }}
              transition={{ duration: 0.5 }}
              className="flex justify-center"
            >
              <Card className="w-full bg-white shadow-xl rounded-2xl overflow-hidden hover:shadow-2xl transition-shadow duration-300">
                <CardHeader className="flex flex-col items-center ">
                  <Image
                    src={
                      user?.image !== ""
                        ? user?.image
                        : "https://dummyimage.com/600x400/000/fff"
                    }
                    alt={user.username}
                    width={100}
                    height={100}
                    className="rounded-full border-2 border-gray-300"
                  />
                  <CardTitle className="text-xl font-semibold mt-3">
                    {user.firstName} {user.lastName}
                  </CardTitle>
                </CardHeader>
                <CardContent className="space-y-3 p-5">
                  <p>
                    <strong>Username:</strong> {user.username}
                  </p>
                  <p>
                    <strong>Phone:</strong> {user.phone}
                  </p>
                  <p>
                    <strong>Birth Date:</strong> {user.birthDate}
                  </p>
                  <p>
                    <strong>Role:</strong> {user.role}
                  </p>
                  <p>
                    <strong>Company:</strong> {user.company?.name} (
                    {user.company?.department})
                  </p>
                  <p>
                    <strong>Address:</strong> {user.address?.city},{" "}
                    {user.address?.state}, {user.address?.country}
                  </p>
                </CardContent>
              </Card>
            </motion.div>
          ))}
        </div>
      )}
    </div>
  );
}

Step 2: Handling the Loading and No Data States

While the user data is being fetched, we display a loading spinner using Framer Motion and the Loader icon from Lucide React. Once the data is available, the list of users is rendered. If no data is returned, a message is displayed saying "No user data available".

Step 3: Displaying User Data

The fetched data is mapped over, and for each user, we render a Card component that displays the user’s profile image, name, and additional information such as username, phone number, birth date, and address.

The Card components are animated using Framer Motion, adding smooth transitions as the user cards enter the view.

<motion.div
  key={user.id}
  initial={{ opacity: 0, y: -10 }}
  animate={{ opacity: 1, y: 0 }}
  transition={{ duration: 0.5 }}
  className="flex justify-center"
>
  <Card className="w-full bg-white shadow-xl rounded-2xl overflow-hidden hover:shadow-2xl transition-shadow duration-300">
    <CardHeader className="flex flex-col items-center ">
      <Image
        src={
          user?.image !== ""
            ? user?.image
            : "https://dummyimage.com/600x400/000/fff"
        }
        alt={user.username}
        width={100}
        height={100}
        className="rounded-full border-2 border-gray-300"
      />
      <CardTitle className="text-xl font-semibold mt-3">
        {user.firstName} {user.lastName}
      </CardTitle>
    </CardHeader>
    <CardContent className="space-y-3 p-5">
      <p><strong>Username:</strong> {user.username}</p>
      <p><strong>Phone:</strong> {user.phone}</p>
      <p><strong>Birth Date:</strong> {user.birthDate}</p>
      <p><strong>Role:</strong> {user.role}</p>
      <p><strong>Company:</strong> {user.company?.name} ({user.company?.department})</p>
      <p><strong>Address:</strong> {user.address?.city}, {user.address?.state}, {user.address?.country}</p>
    </CardContent>
  </Card>
</motion.div>

Conclusion

By leveraging Zustand for UI state management and React Query for server state management, we create a highly maintainable and efficient React application. Zustand simplifies client-side state management, reducing the need for prop drilling, while React Query optimizes API interactions, handling server-side state with ease. Together, they offer a seamless developer experience and ensure a smooth, responsive UI.

Additional Learning Resources

By implementing these strategies, you can build high-performance, scalable React applications with minimal complexity. 🚀

1
Subscribe to my newsletter

Read articles from Abdul-Fattah Abdul-Kareem directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Abdul-Fattah Abdul-Kareem
Abdul-Fattah Abdul-Kareem