State Management
UniPulse separates state into two categories: server state (data from the API) managed by TanStack Query, and client state (UI preferences, local ephemeral data) managed by Zustand.
Architecture
| Type | Tool | Version | Purpose |
|---|---|---|---|
| Server state | TanStack Query | v5 | API data fetching, caching, background sync, mutations |
| Client state | Zustand | v5 | UI state, user preferences, ephemeral form state |
TanStack Query (Server State)
Data Fetching
import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api';
import type { Post } from '@unipulse/shared';
function PostList() {
const { data, isLoading, error } = useQuery<Post[]>({
queryKey: ['posts', workspaceId],
queryFn: () => api.posts.getAll(workspaceId),
staleTime: 5 * 60 * 1000, // 5 minutes
});
if (isLoading) return <Skeleton />;
if (error) return <ErrorMessage error={error} />;
return <DataTable data={data} columns={postColumns} />;
}
Mutations with Cache Invalidation
import { useMutation, useQueryClient } from '@tanstack/react-query';
function useCreatePost() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreatePostInput) => api.posts.create(data),
onSuccess: () => {
// Invalidate posts list so it refetches
queryClient.invalidateQueries({ queryKey: ['posts'] });
},
onError: (error) => {
toast.error(error.message);
},
});
}
Optimistic Updates
const deleteMutation = useMutation({
mutationFn: (postId: string) => api.posts.delete(postId),
onMutate: async (postId) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['posts'] });
// Snapshot current data
const previousPosts = queryClient.getQueryData(['posts']);
// Optimistically remove from cache
queryClient.setQueryData(['posts'], (old: Post[]) =>
old.filter(p => p.id !== postId)
);
return { previousPosts };
},
onError: (err, postId, context) => {
// Rollback on error
queryClient.setQueryData(['posts'], context?.previousPosts);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['posts'] });
},
});
Query Key Conventions
| Pattern | Example | Scope |
|---|---|---|
[resource] | ['posts'] | All posts in current workspace |
[resource, id] | ['posts', postId] | Single post |
[resource, filter] | ['posts', { status: 'published' }] | Filtered list |
[resource, workspaceId] | ['analytics', wsId] | Workspace-specific data |
[resource, workspaceId, params] | ['analytics', wsId, { range: '7d' }] | Parameterized query |
Zustand (Client State)
Key Stores
| Store | File | State | Purpose |
|---|---|---|---|
| Auth | auth.store.ts | user, tokens, isAuthenticated | Current user and JWT tokens |
| Workspace | workspace.store.ts | activeWorkspace, workspaces, membership | Active workspace context |
| UI | ui.store.ts | sidebarOpen, theme, language, modals | UI preferences and modal state |
| Composer | composer.store.ts | caption, platforms, media, schedule | Post composer draft state |
Auth Store
// stores/auth.store.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface AuthState {
user: User | null;
accessToken: string | null;
isAuthenticated: boolean;
login: (user: User, token: string) => void;
logout: () => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
accessToken: null,
isAuthenticated: false,
login: (user, accessToken) => set({ user, accessToken, isAuthenticated: true }),
logout: () => set({ user: null, accessToken: null, isAuthenticated: false }),
}),
{ name: 'auth-storage' }
)
);
Workspace Store
// stores/workspace.store.ts
export const useWorkspaceStore = create<WorkspaceState>()((set) => ({
activeWorkspace: null,
workspaces: [],
membership: null,
setActiveWorkspace: (workspace, membership) =>
set({ activeWorkspace: workspace, membership }),
setWorkspaces: (workspaces) => set({ workspaces }),
}));
UI Store
// stores/ui.store.ts
export const useUIStore = create<UIStore>((set) => ({
sidebarOpen: true,
theme: 'system',
language: 'en',
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
setTheme: (theme) => set({ theme }),
setLanguage: (language) => set({ language }),
}));
Composer Store
// stores/composer.store.ts
export const useComposerStore = create<ComposerState>((set) => ({
caption: '',
platforms: [],
mediaFiles: [],
scheduledAt: null,
brandVoiceId: null,
setCaption: (caption) => set({ caption }),
setPlatforms: (platforms) => set({ platforms }),
addMedia: (file) => set((s) => ({ mediaFiles: [...s.mediaFiles, file] })),
removeMedia: (id) => set((s) => ({ mediaFiles: s.mediaFiles.filter(f => f.id !== id) })),
setSchedule: (scheduledAt) => set({ scheduledAt }),
reset: () => set({ caption: '', platforms: [], mediaFiles: [], scheduledAt: null }),
}));
When to Use What
| Scenario | Solution |
|---|---|
| Data fetched from the API | TanStack Query |
| Form state shared across components | Zustand store |
| Single-component form state | React useState / React Hook Form |
| UI toggle (sidebar, modal) | Zustand ui.store |
| Authentication tokens | Zustand auth.store (persisted) |
| Active workspace context | Zustand workspace.store |
| Server cache invalidation after mutation | TanStack Query invalidateQueries |
Avoid State Duplication
Never duplicate server data in Zustand. If data comes from the API, let TanStack Query own it. Use Zustand only for truly client-side state that doesn't exist on the server.
Cross-Reference
- Frontend Local Setup -- tech stack overview
- Components -- how components consume state
- Routing -- route guards that read auth store
- API Reference -- API endpoints consumed by queries