Skip to content

State Management with Zustand and React Query

This guide explains our state management architecture using Zustand for client state and React Query for server state.

Overview

We use a hybrid approach to state management:

  • React Query (TanStack Query) - For server state (data from Supabase)
  • Zustand - For client state (UI state, user selections, app state)
  • React Context - For auth state and providers that need deep integration

Architecture Principles

  1. Server State is Truth: Database data is always fetched via React Query
  2. Client State is Ephemeral: UI state lives in Zustand stores
  3. No Duplication: Never store server data in Zustand
  4. Automatic Sync: Realtime updates invalidate React Query cache automatically

React Query for Server State

Creating a New Query Hook

Query hooks live in /hooks/queries/ and follow this pattern:

// File: /hooks/queries/useProjectQuery.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { queryKeys } from '@/lib/query';
import { getProject, updateProject } from '@/services/project/client';

// Single item query
export function useProjectQuery(
  projectId: string | undefined,
  options?: { enabled?: boolean }
) {
  return useQuery({
    queryKey: queryKeys.project(projectId),
    queryFn: () => getProject(projectId!),
    enabled: !!projectId && (options?.enabled ?? true),
    staleTime: 5 * 60 * 1000, // Consider data fresh for 5 minutes
    gcTime: 10 * 60 * 1000, // Keep in cache for 10 minutes (formerly cacheTime)
  });
}

// List query with pagination
export function useProjectsQuery(
  filters?: ProjectFilters,
  pagination?: PaginationParams,
  options?: { enabled?: boolean }
) {
  return useQuery({
    queryKey: queryKeys.projects(filters, pagination),
    queryFn: () => getProjects(filters, pagination),
    enabled: options?.enabled ?? true,
    staleTime: 30 * 1000, // Lists go stale faster
    placeholderData: keepPreviousData, // Keep previous data while fetching
  });
}

// Mutation for updates
export function useUpdateProjectMutation() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: ({
      projectId,
      data,
    }: {
      projectId: string;
      data: UpdateProjectData;
    }) => updateProject(projectId, data),
    onSuccess: (data, variables) => {
      // Invalidate and refetch
      queryClient.invalidateQueries({
        queryKey: queryKeys.project(variables.projectId),
      });
      queryClient.invalidateQueries({
        queryKey: queryKeys.projects(),
      });
    },
  });
}

Query Keys Pattern

Define query keys in /lib/query.ts:

export const queryKeys = {
  all: ['delta'] as const,

  // Projects
  projects: (filters?: ProjectFilters, pagination?: PaginationParams) =>
    [...queryKeys.all, 'projects', { filters, pagination }] as const,
  project: (id?: string) => [...queryKeys.all, 'project', id] as const,

  // Organizations
  organizations: () => [...queryKeys.all, 'organizations'] as const,
  organization: (id?: string) =>
    [...queryKeys.all, 'organization', id] as const,

  // Add more as needed...
} as const;

Using Query Hooks in Components

function ProjectDetails({ projectId }: { projectId: string }) {
  const { data: project, isLoading, error } = useProjectQuery(projectId);
  const updateProject = useUpdateProjectMutation();

  if (isLoading) return <Loading />;
  if (error) return <Error error={error} />;

  const handleUpdate = async (updates: UpdateProjectData) => {
    await updateProject.mutateAsync({
      projectId,
      data: updates,
    });
    // React Query will automatically refetch and update UI
  };

  return <ProjectForm project={project} onUpdate={handleUpdate} />;
}

Zustand for Client State

Creating a New Zustand Store

Zustand stores live in /hooks/ as custom hooks:

// File: /hooks/useNavigation.ts
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';

interface NavigationState {
  // State
  isSidebarOpen: boolean;
  activeMenuItem: string | null;
  breadcrumbs: Breadcrumb[];

  // Actions
  toggleSidebar: () => void;
  setSidebarOpen: (open: boolean) => void;
  setActiveMenuItem: (itemId: string | null) => void;
  setBreadcrumbs: (breadcrumbs: Breadcrumb[]) => void;
  reset: () => void;
}

const initialState = {
  isSidebarOpen: true,
  activeMenuItem: null,
  breadcrumbs: [],
};

export const useNavigation = create<NavigationState>()(
  devtools(
    (set) => ({
      ...initialState,

      // Actions
      toggleSidebar: () =>
        set((state) => ({ isSidebarOpen: !state.isSidebarOpen })),

      setSidebarOpen: (open) => set({ isSidebarOpen: open }),

      setActiveMenuItem: (itemId) => set({ activeMenuItem: itemId }),

      setBreadcrumbs: (breadcrumbs) => set({ breadcrumbs }),

      reset: () => set(initialState),
    }),
    {
      name: 'navigation-store', // For Redux DevTools
    }
  )
);

Store Patterns

1. Simple Store (UI State)

interface UIState {
  theme: 'light' | 'dark';
  sidebarCollapsed: boolean;
  setTheme: (theme: 'light' | 'dark') => void;
  toggleSidebar: () => void;
}

export const useUI = create<UIState>()((set) => ({
  theme: 'light',
  sidebarCollapsed: false,
  setTheme: (theme) => set({ theme }),
  toggleSidebar: () =>
    set((state) => ({
      sidebarCollapsed: !state.sidebarCollapsed,
    })),
}));

2. Complex Store with Computed Values

interface OrganizationState {
  selectedOrganizationId: string | null;
  setSelectedOrganizationId: (id: string | null) => void;

  // Computed from selectedOrganizationId + React Query
  get selectedOrganization() {
    const { data } = useOrganizationQuery(this.selectedOrganizationId);
    return data;
  },
}

3. Store with Persistence

import { persist } from 'zustand/middleware';

export const usePreferences = create<PreferencesState>()(
  persist(
    (set) => ({
      language: 'en',
      currency: 'USD',
      setLanguage: (language) => set({ language }),
      setCurrency: (currency) => set({ currency }),
    }),
    {
      name: 'user-preferences', // localStorage key
      partialize: (state) => ({
        // Only persist these fields
        language: state.language,
        currency: state.currency,
      }),
    }
  )
);

Using Zustand in Components

function Sidebar() {
  // Get only what you need - Zustand uses selectors for performance
  const isSidebarOpen = useNavigation((state) => state.isSidebarOpen);
  const toggleSidebar = useNavigation((state) => state.toggleSidebar);

  return (
    <aside className={isSidebarOpen ? 'w-64' : 'w-16'}>
      <button onClick={toggleSidebar}>Toggle</button>
      {/* sidebar content */}
    </aside>
  );
}

Combining Zustand with React Query

Pattern 1: Selected Item + Details

// Zustand store for selection
export const useProject = create<ProjectState>()((set, get) => ({
  projectId: null,
  setProjectId: (id: string | null) => set({ projectId: id }),

  // This is a getter, not stored state
  get isLoading() {
    const projectId = get().projectId;
    if (!projectId) return false;
    // This will cause re-render when query state changes
    const { isLoading } = useProjectQuery(projectId);
    return isLoading;
  },
}));

// Component using both
function ProjectView() {
  const projectId = useProject((state) => state.projectId);
  const { data: project, isLoading } = useProjectQuery(projectId);

  if (!projectId) return <div>Select a project</div>;
  if (isLoading) return <Loading />;

  return <ProjectDetails project={project} />;
}

Pattern 2: Filters + Cached Results

// Zustand for filter state
export const useTransactionFilters = create<FilterState>()((set) => ({
  status: 'all',
  type: null,
  dateRange: null,
  searchTerm: '',

  setStatus: (status) => set({ status }),
  setType: (type) => set({ type }),
  setDateRange: (dateRange) => set({ dateRange }),
  setSearchTerm: (searchTerm) => set({ searchTerm }),
  reset: () => set({ status: 'all', type: null, dateRange: null, searchTerm: '' }),
}));

// Component combines filter state with query
function TransactionsList() {
  const filters = useTransactionFilters();
  const { data, isLoading } = useTransactionsQuery({
    status: filters.status,
    type: filters.type,
    dateRange: filters.dateRange,
    search: filters.searchTerm,
  });

  return (
    <>
      <TransactionFilters />
      {isLoading ? <Loading /> : <TransactionTable data={data} />}
    </>
  );
}

Migration Guide: Context to Zustand

When migrating from React Context to Zustand:

Before (Context):

const ProjectContext = createContext<ProjectContextValue | null>(null);

export function ProjectProvider({ children }: { children: ReactNode }) {
  const [projectId, setProjectId] = useState<string | null>(null);
  const { data: project, isLoading } = useProjectQuery(projectId);

  return (
    <ProjectContext.Provider value={{ projectId, project, setProjectId, isLoading }}>
      {children}
    </ProjectContext.Provider>
  );
}

export function useProject() {
  const context = useContext(ProjectContext);
  if (!context) throw new Error('useProject must be used within ProjectProvider');
  return context;
}

After (Zustand):

// Store for client state only
export const useProject = create<ProjectState>()((set, get) => ({
  projectId: null,
  projectUrlKey: null,
  setProjectId: (id: string | null) => set({ projectId: id }),
  setProjectUrlKey: (key: string | null) => set({ projectUrlKey: key }),

  // Computed getters can reference React Query
  get project() {
    const { projectId, projectUrlKey } = get();
    if (!projectId && !projectUrlKey) return null;

    const { data } = useProjectQuery(projectUrlKey || projectId);
    return data;
  },

  get isLoading() {
    const { projectId, projectUrlKey } = get();
    if (!projectId && !projectUrlKey) return false;

    const { isLoading } = useProjectQuery(projectUrlKey || projectId);
    return isLoading;
  },
}));

// No provider needed! Just use the hook directly
function MyComponent() {
  const { project, isLoading, setProjectId } = useProject();
  // ...
}

Best Practices

DO

  • ✅ Use React Query for all server data
  • ✅ Use Zustand for UI state and user selections
  • ✅ Keep stores focused and small
  • ✅ Use TypeScript for all stores and queries
  • ✅ Invalidate queries after mutations
  • ✅ Use selectors to avoid unnecessary re-renders
  • ✅ Name query keys consistently

DON'T

  • ❌ Store server data in Zustand
  • ❌ Duplicate data between stores
  • ❌ Create giant monolithic stores
  • ❌ Mutate state directly
  • ❌ Mix server and client state in the same store
  • ❌ Forget to handle loading and error states

Testing

Testing React Query Hooks

import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

const createWrapper = () => {
  const queryClient = new QueryClient({
    defaultOptions: { queries: { retry: false } },
  });

  return ({ children }: { children: ReactNode }) => (
    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
  );
};

describe('useProjectQuery', () => {
  it('fetches project data', async () => {
    const { result } = renderHook(
      () => useProjectQuery('project-123'),
      { wrapper: createWrapper() }
    );

    await waitFor(() => {
      expect(result.current.isSuccess).toBe(true);
    });

    expect(result.current.data).toEqual(mockProjectData);
  });
});

Testing Zustand Stores

import { renderHook, act } from '@testing-library/react';
import { useNavigation } from '@/hooks/useNavigation';

describe('useNavigation', () => {
  beforeEach(() => {
    useNavigation.setState({
      isSidebarOpen: true,
      activeMenuItem: null,
    });
  });

  it('toggles sidebar', () => {
    const { result } = renderHook(() => useNavigation());

    expect(result.current.isSidebarOpen).toBe(true);

    act(() => {
      result.current.toggleSidebar();
    });

    expect(result.current.isSidebarOpen).toBe(false);
  });
});

Debugging

React Query DevTools

React Query DevTools are included in development:

// In RootProviders.tsx
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';

function QueryProvider({ children }: { children: ReactNode }) {
  return (
    <QueryClientProvider client={queryClient}>
      {children}
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}

Zustand DevTools

Zustand integrates with Redux DevTools:

  1. Install Redux DevTools browser extension
  2. Use devtools middleware in your stores
  3. View state changes in Redux DevTools

Debugging Tips

  1. Check Query Keys: Use React Query DevTools to verify query keys
  2. Monitor Cache: See what's cached and when it invalidates
  3. Track State Changes: Use Redux DevTools to see Zustand state updates
  4. Log Realtime Events: Check console for realtime subscription events
  5. Verify Invalidation: Ensure queries refetch after mutations

Common Patterns

Loading States with Suspense

// Use suspense for cleaner loading states
function ProjectDetails({ projectId }: { projectId: string }) {
  const { data: project } = useProjectQuery(projectId, {
    suspense: true, // Will throw promise while loading
  });

  // No loading check needed!
  return <ProjectForm project={project} />;
}

// Wrap with Suspense boundary
<Suspense fallback={<Loading />}>
  <ProjectDetails projectId={projectId} />
</Suspense>;

Optimistic Updates

const updateProject = useMutation({
  mutationFn: updateProjectAPI,

  // Optimistically update cache
  onMutate: async (variables) => {
    await queryClient.cancelQueries({
      queryKey: queryKeys.project(variables.id),
    });

    const previousProject = queryClient.getQueryData(
      queryKeys.project(variables.id)
    );

    queryClient.setQueryData(queryKeys.project(variables.id), (old) => ({
      ...old,
      ...variables.updates,
    }));

    return { previousProject };
  },

  // Rollback on error
  onError: (err, variables, context) => {
    if (context?.previousProject) {
      queryClient.setQueryData(
        queryKeys.project(variables.id),
        context.previousProject
      );
    }
  },

  // Always refetch after mutation
  onSettled: (data, error, variables) => {
    queryClient.invalidateQueries({
      queryKey: queryKeys.project(variables.id),
    });
  },
});

Dependent Queries

function ProjectTeam({ projectId }: { projectId: string }) {
  // First, get the project
  const { data: project } = useProjectQuery(projectId);

  // Then, get the team members (only runs after project loads)
  const { data: teamMembers } = useTeamMembersQuery(project?.teamId, {
    enabled: !!project?.teamId, // Only fetch when we have teamId
  });

  return <TeamList members={teamMembers} />;
}

Resources