Skip to content

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

  1. Server State: Data from the API (users, events, organizations, auth)
  2. Client State: UI state, user preferences, theme settings
  3. Form State: Form inputs, validation, submission status
  4. URL State: Search params, filters, pagination

When to Use Each Solution

State TypeSolutionExamples
Server dataReact QueryUser profiles, events, organizations
UI preferencesZustandTheme, sidebar state, language
Form dataReact Hook FormSign up forms, event creation
Component UIuseStateModal open/close, tooltips
URL paramsRouter stateFilters, 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 data

3. Optimistic UI

Implement optimistic updates for better UX:

typescript
// Update UI immediately
// Rollback on error
// Revalidate on success

4. 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 change

Query 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:

  1. Single Source of Truth: Server data is the source of truth
  2. Automatic Cache Management: Built-in cache invalidation and updates
  3. Request Deduplication: Prevents redundant API calls
  4. Built-in Loading/Error States: No manual state management
  5. Optimistic Updates: Better UX with immediate feedback

Why Zustand for UI State?

  1. Simplicity: Minimal boilerplate for client-only state
  2. Performance: Fine-grained subscriptions prevent unnecessary re-renders
  3. Persistence: Built-in localStorage integration
  4. DevTools: Excellent debugging experience

State Management Rules

  1. Server Data → React Query: All API data (your domain resources)
  2. Client UI → Zustand: Theme, sidebar, language preferences
  3. Forms → React Hook Form: Form inputs and validation
  4. Component UI → useState: Modals, tooltips, hover states
  5. URL State → Router: Filters, search params, pagination

Built with ❤️ by the Jubiloop team