Skip to main content

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

TypeToolVersionPurpose
Server stateTanStack Queryv5API data fetching, caching, background sync, mutations
Client stateZustandv5UI 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

PatternExampleScope
[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

StoreFileStatePurpose
Authauth.store.tsuser, tokens, isAuthenticatedCurrent user and JWT tokens
Workspaceworkspace.store.tsactiveWorkspace, workspaces, membershipActive workspace context
UIui.store.tssidebarOpen, theme, language, modalsUI preferences and modal state
Composercomposer.store.tscaption, platforms, media, schedulePost 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

ScenarioSolution
Data fetched from the APITanStack Query
Form state shared across componentsZustand store
Single-component form stateReact useState / React Hook Form
UI toggle (sidebar, modal)Zustand ui.store
Authentication tokensZustand auth.store (persisted)
Active workspace contextZustand workspace.store
Server cache invalidation after mutationTanStack 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