Skip to main content

Overview

The Auth Dashboard uses Zustand for state management. Zustand is a lightweight, fast, and scalable state management solution that uses React hooks.
Zustand was chosen for its simplicity, minimal boilerplate, and excellent TypeScript support compared to Redux or Context API.

Why Zustand?

Minimal Boilerplate

No providers, actions, or reducers needed. Just create a store and use it.

TypeScript First

Excellent TypeScript inference with minimal type annotations.

Persistence Built-in

Easy persistence with the persist middleware.

No Re-render Issues

Fine-grained subscriptions prevent unnecessary re-renders.

Store Architecture

Each feature has its own dedicated store:
src/
├── features/
│   ├── auth/
│   │   └── authStore.tsx          # Authentication state
│   └── users/
│       └── usersStore.ts          # Users management state
└── shared/
    └── store/
        ├── useToastStore.ts       # Global toast notifications
        └── useSettingsStore.ts    # App settings (theme, language)

Creating a Store

Basic Store Pattern

Here’s the pattern used throughout the application:
import { create } from "zustand";

interface MyState {
  // State properties
  data: MyData[];
  loading: boolean;
  error: string | null;
  
  // Actions
  fetchData: () => Promise<void>;
  updateData: (item: MyData) => void;
}

export const useMyStore = create<MyState>((set, get) => ({
  // Initial state
  data: [],
  loading: false,
  error: null,
  
  // Actions
  fetchData: async () => {
    set({ loading: true, error: null });
    try {
      const result = await apiCall();
      set({ data: result, loading: false });
    } catch (error) {
      set({ error: error.message, loading: false });
    }
  },
  
  updateData: (item) => set((state) => ({
    data: state.data.map(d => d.id === item.id ? item : d)
  })),
}));

Authentication Store

The auth store (features/auth/authStore.tsx:7-38) manages user authentication state:
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { loginRequest } from "./authService";
import type { AuthState } from "./types";

export const useAuthStore = create<AuthState>()(\n  persist(
    (set) => ({
      user: null,
      loading: false,

      login: async (username, password) => {
        try {
          set({ loading: true });
          const data = await loginRequest({ username, password });
          set({ user: data, loading: false });
          localStorage.setItem("token", data.token);
        } catch (error) {
          set({ loading: false });
          console.error(error);
          alert("Credenciales incorrectas");
        }
      },

      logout: () => {
        localStorage.removeItem("token");
        set({ user: null });
      },
    }),
    { name: "auth-storage" }
  )
);

Using the Auth Store

import { useAuthStore } from "@/features/auth/authStore";

function LoginPage() {
  const login = useAuthStore((state) => state.login);
  const loading = useAuthStore((state) => state.loading);
  
  const handleSubmit = async (e) => {
    e.preventDefault();
    await login(username, password);
  };
  
  return (
    <form onSubmit={handleSubmit}>
      {/* form fields */}
      <button disabled={loading}>
        {loading ? "Logging in..." : "Login"}
      </button>
    </form>
  );
}
The auth store uses the persist middleware to save authentication state to localStorage with the key auth-storage.

Users Store

The users store (features/users/usersStore.ts:18-66) demonstrates a more complex state management pattern:
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { getUsers } from "./usersService";
import type { User } from "./types";

interface UsersState {
  users: User[];
  loading: boolean;
  error: string | null;
  deletingId: number | null;

  fetchUsers: () => Promise<void>;
  addUser: (user: User) => void;
  deleteUser: (id: number) => void;
  updateUser: (user: User) => void;
}

export const useUsersStore = create<UsersState>()(\n  persist(
    (set, get) => ({
      users: [],
      loading: false,
      error: null,
      deletingId: null,

      fetchUsers: async () => {
        // Skip if already loaded
        if (get().users.length > 0) return;

        try {
          set({ loading: true, error: null });
          const usersFromApi = await getUsers();
          set({ users: usersFromApi, loading: false });
        } catch {
          set({ error: "Error fetching users", loading: false });
        }
      },

      addUser: (user) =>
        set((state) => ({
          users: [user, ...state.users],
        })),

      deleteUser: (id) => {
        set({ deletingId: id });
        set((state) => ({
          users: state.users.filter((user) => user.id !== id),
          deletingId: null,
        }));
      },

      updateUser: (updatedUser: User) =>
        set((state) => ({
          users: state.users.map((user) =>
            user.id === updatedUser.id ? updatedUser : user
          ),
        })),
    }),
    { name: "users-storage" }
  )
);

Key Patterns

The addUser and updateUser actions update the local state immediately without waiting for API confirmation. This provides a snappy user experience.
addUser: (user) =>
  set((state) => ({
    users: [user, ...state.users],
  })),
Track multiple loading states for different operations:
loading: false,        // General loading
deletingId: null,      // Specific item being deleted
The fetchUsers function checks if data is already loaded before making an API call:
fetchUsers: async () => {
  if (get().users.length > 0) return;
  // ... fetch from API
}
The get function provides access to the current state within actions:
create<State>()((set, get) => ({
  myAction: () => {
    const currentUsers = get().users;
    // ... use currentUsers
  }
}))

Global Stores

Toast Store

Simple store for showing temporary notifications (shared/store/useToastStore.ts:9-13):
import { create } from "zustand";

interface ToastState {
  message: string | null;
  show: (msg: string) => void;
  hide: () => void;
}

export const useToastStore = create<ToastState>((set) => ({
  message: null,
  show: (msg) => set({ message: msg }),
  hide: () => set({ message: null }),
}));
Usage:
import { useToastStore } from "@/shared/store/useToastStore";

function MyComponent() {
  const showToast = useToastStore((state) => state.show);
  
  const handleSuccess = () => {
    showToast("Operation successful!");
  };
}

Settings Store

Manages app-wide settings like theme and language preferences:
import { create } from "zustand";
import { persist } from "zustand/middleware";

interface SettingsState {
  theme: "light" | "dark";
  language: "en" | "es";
  setTheme: (theme: "light" | "dark") => void;
  setLanguage: (language: "en" | "es") => void;
}

export const useSettingsStore = create<SettingsState>()(\n  persist(
    (set) => ({
      theme: "light",
      language: "en",
      setTheme: (theme) => set({ theme }),
      setLanguage: (language) => set({ language }),
    }),
    { name: "settings-storage" }
  )
);

Using Stores in Components

Selector Pattern

Multiple Selectors

function UserStats() {
  const users = useUsersStore((state) => state.users);
  const loading = useUsersStore((state) => state.loading);
  const error = useUsersStore((state) => state.error);
  
  if (loading) return <Spinner />;
  if (error) return <Error message={error} />;
  
  return <div>Total users: {users.length}</div>;
}

Calling Actions

function AddUserButton() {
  const addUser = useUsersStore((state) => state.addUser);
  
  const handleClick = () => {
    addUser({
      id: Date.now(),
      name: "New User",
      email: "[email protected]"
    });
  };
  
  return <button onClick={handleClick}>Add User</button>;
}

Persist Middleware

The persist middleware automatically saves and restores state from localStorage.

Configuration

import { persist } from "zustand/middleware";

export const useMyStore = create<MyState>()(\n  persist(
    (set, get) => ({
      // ... store implementation
    }),
    {
      name: "my-storage",              // localStorage key
      partialize: (state) => ({         // Optional: only persist specific fields
        users: state.users,
      }),
    }
  )
);
Sensitive data like tokens should be stored in localStorage separately, not in persisted Zustand stores, for better security control.

Best Practices

One Store Per Feature

Keep stores focused on a single domain. Don’t create a global store for everything.

Use Selectors

Always use selectors to pick specific state slices. This prevents unnecessary re-renders.

Async in Actions

Handle async operations inside store actions, not in components.

TypeScript Types

Define TypeScript interfaces in separate types.ts files for reusability.

Do’s and Don’ts

// ✅ Select specific state
const user = useAuthStore((state) => state.user);

// ✅ Handle errors in the store
try {
  const data = await api();
  set({ data });
} catch (error) {
  set({ error: error.message });
}

// ✅ Use functional updates for state that depends on previous state
set((state) => ({
  count: state.count + 1
}));

Testing Stores

Stores are easy to test since they’re just JavaScript functions:
import { renderHook, act } from "@testing-library/react";
import { useUsersStore } from "./usersStore";

describe("useUsersStore", () => {
  beforeEach(() => {
    // Reset store before each test
    useUsersStore.setState({ users: [], loading: false });
  });
  
  it("should add a user", () => {
    const { result } = renderHook(() => useUsersStore());
    
    act(() => {
      result.current.addUser({
        id: 1,
        name: "Test User",
        email: "[email protected]"
      });
    });
    
    expect(result.current.users).toHaveLength(1);
    expect(result.current.users[0].name).toBe("Test User");
  });
});

Next Steps

Architecture

Learn about the overall application architecture

API Integration

See how stores integrate with API services

Components

Use stores in shared components

Zustand Docs

Official Zustand documentation

Build docs developers (and LLMs) love