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¶
- Server State is Truth: Database data is always fetched via React Query
- Client State is Ephemeral: UI state lives in Zustand stores
- No Duplication: Never store server data in Zustand
- 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:
- Install Redux DevTools browser extension
- Use
devtoolsmiddleware in your stores - View state changes in Redux DevTools
Debugging Tips¶
- Check Query Keys: Use React Query DevTools to verify query keys
- Monitor Cache: See what's cached and when it invalidates
- Track State Changes: Use Redux DevTools to see Zustand state updates
- Log Realtime Events: Check console for realtime subscription events
- 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} />;
}