Appearance
State Management
Overview
Jubiloop uses a hybrid approach to state management, combining different solutions for different types of state:
- Zustand - Global client state (UI preferences, app settings)
- TanStack Query - Server state and data caching
- React Hook Form - Form state management
- Local Component State - UI-specific state
State Architecture
State Categories
- Server State: Data from the API (users, events, organizations, auth)
- Client State: UI state, user preferences, theme settings
- Form State: Form inputs, validation, submission status
- URL State: Search params, filters, pagination
When to Use Each Solution
| State Type | Solution | Examples |
|---|---|---|
| Server data | React Query | User profiles, events, organizations |
| UI preferences | Zustand | Theme, sidebar state, language |
| Form data | React Hook Form | Sign up forms, event creation |
| Component UI | useState | Modal open/close, tooltips |
| URL params | Router state | Filters, search, pagination |
Zustand for Client State
Zustand is used for client-side UI state that doesn't come from the server:
Example: UI Preferences Store
typescript
// src/store/ui-preferences.ts
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
type TUIState = {
theme: 'light' | 'dark'
sidebarCollapsed: boolean
setTheme: (theme: 'light' | 'dark') => void
toggleSidebar: () => void
}
export const useUIStore = create<TUIState>()(
persist(
(set) => ({
theme: 'light',
sidebarCollapsed: false,
setTheme: (theme) => set({ theme }),
toggleSidebar: () =>
set((state) => ({
sidebarCollapsed: !state.sidebarCollapsed,
})),
}),
{
name: 'ui-preferences',
}
)
)Server State Management
TanStack Query Setup
Server state is managed through TanStack Query with custom configuration:
typescript
// src/integrations/tanstack-query/client.ts
export const queryClient = new QueryClient({})Data Fetching Patterns
Note: The following examples demonstrate common patterns. Replace "items" with your actual resource names (events, users, etc.).
Basic Query
typescript
import { useQuery } from '@tanstack/react-query'
import { apiClient } from '@/lib/api/client'
// Example: Fetch a list of items
function useItems(filters?: ItemFilters) {
return useQuery({
queryKey: ['items', filters],
queryFn: async () => {
const { data } = await apiClient.get('/items', { params: filters })
return data
},
enabled: !!filters?.organizationId, // Only fetch when required params exist
})
}Mutations
typescript
import { useMutation, useQueryClient } from '@tanstack/react-query'
// Example: Create a new item
function useCreateItem() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (itemData: CreateItemData) => {
const { data } = await apiClient.post('/items', itemData)
return data
},
onSuccess: (data) => {
// Invalidate related queries to refetch
queryClient.invalidateQueries({
queryKey: ['items'],
})
// Show success message
toast.success('Item created successfully!')
},
onError: (error) => {
toast.error('Failed to create item')
},
})
}Optimistic Updates
typescript
// Example: Update item status with optimistic UI
const updateItemStatus = useMutation({
mutationFn: async ({ itemId, status }: UpdateItemStatus) => {
const { data } = await apiClient.patch(`/items/${itemId}`, { status })
return data
},
onMutate: async (variables) => {
// Cancel in-flight queries
await queryClient.cancelQueries({ queryKey: ['items'] })
// Snapshot previous value
const previousItems = queryClient.getQueryData(['items'])
// Optimistically update
queryClient.setQueryData(['items'], (old: Item[]) =>
old.map((item) =>
item.id === variables.itemId ? { ...item, status: variables.status } : item
)
)
return { previousItems }
},
onError: (err, variables, context) => {
// Rollback on error
queryClient.setQueryData(['items'], context.previousItems)
toast.error('Failed to update item')
},
onSettled: () => {
// Always refetch after error or success
queryClient.invalidateQueries({ queryKey: ['items'] })
},
})Form State Management
React Hook Form Integration
Note: This example shows the pattern. Adapt the schema and fields to your specific use case.
typescript
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { createItemSchema } from '@/schemas/items'
function CreateItemForm() {
const createItem = useCreateItem()
const form = useForm({
resolver: zodResolver(createItemSchema),
defaultValues: {
name: '',
description: '',
category: '',
isActive: true,
},
})
const onSubmit = async (data) => {
createItem.mutate(data, {
onSuccess: () => {
form.reset()
// Navigate to item details or list
}
})
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
{/* Form fields */}
</form>
</Form>
)
}Form Validation with Zod
typescript
// Example schema - adapt to your needs
import { z } from 'zod'
export const createItemSchema = z.object({
name: z.string().min(3, 'Name must be at least 3 characters'),
description: z.string().min(10, 'Description must be at least 10 characters'),
category: z.string().min(1, 'Category is required'),
isActive: z.boolean().optional(),
})Local Component State
For UI-specific state, use React's built-in state management:
typescript
function Modal() {
const [isOpen, setIsOpen] = useState(false)
const [activeTab, setActiveTab] = useState('details')
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
{/* Modal content */}
</Dialog>
)
}State Synchronization
Cross-Tab Synchronization
Different state types sync across browser tabs in different ways:
- Zustand: Automatically syncs persisted state across tabs via localStorage events
- React Query: Server state syncs through shared cache and refetch on window focus
- Component State: Not synced (each tab maintains its own)
State Update Flow
Example of how state updates flow through the application:
typescript
// Example: How state updates flow through the app
const handleItemUpdate = async (itemData) => {
// 1. Set loading state (optional)
setIsUpdating(true)
// 2. Trigger server mutation
await updateItem.mutateAsync(itemData)
// 3. React Query invalidates related queries
// 4. All components using this data re-render
// 5. Show success feedback
toast.success('Item updated!')
// 6. Clean up loading state
setIsUpdating(false)
}Query Key Management
Query Key Factory Pattern
typescript
// Example query key factory pattern
export const queryKeys = {
// Resource A queries
items: {
all: ['items'],
list: (filters?: ItemFilters) => ['items', 'list', filters],
detail: (id: string) => ['items', 'detail', id],
byCategory: (category: string) => ['items', 'category', category],
},
// Resource B queries
users: {
all: ['users'],
profile: (id: string) => ['users', 'profile', id],
preferences: () => ['users', 'preferences'],
},
// Auth queries (see authentication docs for implementation)
auth: {
all: ['auth'],
session: () => ['auth', 'session'],
// ... other auth queries
},
}Using hierarchical keys allows for granular cache invalidation:
typescript
// Invalidate all items
queryClient.invalidateQueries({ queryKey: queryKeys.items.all })
// Invalidate only item lists (not details)
queryClient.invalidateQueries({ queryKey: ['items', 'list'] })
// Invalidate specific item
queryClient.invalidateQueries({
queryKey: queryKeys.items.detail(itemId),
})Best Practices
1. State Type Selection
Choose the right state management for your use case:
- Server State: Use React Query (auth, user data, organizations)
- Client State: Use Zustand (UI preferences, theme)
- Form State: Use React Hook Form
- Component State: Use useState for local UI
2. Query Key Conventions
Use hierarchical, type-safe query keys:
typescript
// Examples of query key patterns
;['resource'][('resource', 'list', filters)][('resource', 'detail', id)][ // All resource data // Filtered lists // Specific item details
('resource', 'relation', id)
][('user', 'profile', id)] // Related data // User-specific data3. Optimistic UI
Implement optimistic updates for better UX:
typescript
// Update UI immediately
// Rollback on error
// Revalidate on success4. Error Handling
Centralized error handling in API client:
typescript
// src/lib/api/client.ts
apiClient.interceptors.response.use(
(response) => response,
(error: AxiosError) => {
// Handle different error scenarios
if (error.response?.status === 401) {
// Handle unauthorized errors
// Auth logic handled separately in auth feature
} else if (error.response?.status === 403) {
toast.error('You do not have permission to perform this action')
} else if (error.response?.status >= 500) {
toast.error('Server error. Please try again later.')
}
return Promise.reject(error)
}
)5. State Persistence
Choose appropriate persistence strategies:
- Server data: React Query cache (configurable TTL)
- UI preferences: Zustand + localStorage (persisted)
- Form drafts: sessionStorage (temporary)
- Temporary UI state: Component state (not persisted)
Common Patterns
Loading States
typescript
function DataList() {
const { data, isLoading, error } = useQuery({
queryKey: ['items'],
queryFn: fetchItems,
})
if (isLoading) return <Spinner />
if (error) return <ErrorMessage error={error} />
return <ItemList items={data} />
}Dependent Queries
typescript
function UserPosts({ userId }) {
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
})
const { data: posts } = useQuery({
queryKey: ['posts', userId],
queryFn: () => fetchUserPosts(userId),
enabled: !!user, // Only run when user is loaded
})
return <PostList posts={posts} />
}Infinite Queries
typescript
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam = 0 }) => fetchPosts({ page: pageParam }),
getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
})Performance Optimization
Memoization
typescript
// Memoize expensive computations
const expensiveValue = useMemo(() => {
return computeExpensiveValue(data)
}, [data])
// Memoize callbacks
const handleClick = useCallback(() => {
doSomething(id)
}, [id])Selective Subscriptions
typescript
// Subscribe to specific store slices
const user = useAuthStore((state) => state.user)
// Instead of
const { user } = useAuthStore() // Re-renders on any store changeQuery Optimization
typescript
// Prefetch data
queryClient.prefetchQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
})
// Stale-while-revalidate
const { data } = useQuery({
queryKey: ['items'],
queryFn: fetchItems,
staleTime: 5 * 60 * 1000, // Consider data fresh for 5 minutes
})Advanced Patterns
Cache Management
Manage query cache effectively:
typescript
// Example: Prefetch data before navigation
const handleNavigateToDetail = async (itemId: string) => {
// Prefetch item data
await queryClient.prefetchQuery({
queryKey: ['items', 'detail', itemId],
queryFn: () => fetchItem(itemId),
})
// Navigate after data is cached
navigate({ to: '/items/$itemId', params: { itemId } })
}
// Example: Manual cache updates after creation
const handleItemCreated = (newItem: Item) => {
// Add to list cache
queryClient.setQueryData(['items', 'list'], (old: Item[] = []) => [...old, newItem])
// Set detail cache
queryClient.setQueryData(['items', 'detail', newItem.id], newItem)
}Testing State
Testing Zustand Stores
typescript
import { act, renderHook } from '@testing-library/react'
import { useUIStore } from '@/store/ui-preferences'
test('UI store theme toggle', () => {
const { result } = renderHook(() => useUIStore())
// Initial state
expect(result.current.theme).toBe('light')
// Toggle theme
act(() => {
result.current.setTheme('dark')
})
expect(result.current.theme).toBe('dark')
})Testing Queries
typescript
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { renderHook, waitFor } from '@testing-library/react'
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
return ({ children }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
}
test('useItems query', async () => {
const { result } = renderHook(
() => useItems({ category: 'active' }),
{ wrapper: createWrapper() }
)
await waitFor(() => {
expect(result.current.isSuccess).toBe(true)
expect(result.current.data).toBeDefined()
})
})Key Architecture Decisions
Why This Architecture?
React Query for Server State:
- Single Source of Truth: Server data is the source of truth
- Automatic Cache Management: Built-in cache invalidation and updates
- Request Deduplication: Prevents redundant API calls
- Built-in Loading/Error States: No manual state management
- Optimistic Updates: Better UX with immediate feedback
Why Zustand for UI State?
- Simplicity: Minimal boilerplate for client-only state
- Performance: Fine-grained subscriptions prevent unnecessary re-renders
- Persistence: Built-in localStorage integration
- DevTools: Excellent debugging experience
State Management Rules
- Server Data → React Query: All API data (your domain resources)
- Client UI → Zustand: Theme, sidebar, language preferences
- Forms → React Hook Form: Form inputs and validation
- Component UI → useState: Modals, tooltips, hover states
- URL State → Router: Filters, search params, pagination