Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/egeuysall/ryva-archive/llms.txt

Use this file to discover all available pages before exploring further.

Overview

Ryva uses a layered state management approach:
  • TanStack Query (React Query) - Server state, data fetching, caching
  • Zustand - Client-side global state (minimal usage)
  • React Context - Component tree state sharing
  • React Hook Form - Form state management

TanStack Query (React Query)

Setup and Configuration

Query client is configured in src/lib/query-client.ts:
// src/lib/query-client.ts
import { QueryClient } from '@tanstack/react-query'

const defaultQueryOptions = {
  queries: {
    staleTime: 5 * 60 * 1000,        // 5 minutes
    gcTime: 10 * 60 * 1000,          // 10 minutes (formerly cacheTime)
    retry: 1,
    retryDelay: (attemptIndex: number) => Math.min(1000 * 2 ** attemptIndex, 30000),
    refetchOnWindowFocus: process.env.NODE_ENV === 'production',
    refetchOnReconnect: true,
    refetchOnMount: false,
  },
  mutations: {
    retry: 1,
  },
}

export function createQueryClient(): QueryClient {
  return new QueryClient({
    defaultOptions: defaultQueryOptions,
  })
}

let browserQueryClient: QueryClient | undefined = undefined

export function getQueryClient(): QueryClient {
  // Server: always create new client
  if (typeof window === 'undefined') {
    return createQueryClient()
  }

  // Browser: reuse existing client
  if (!browserQueryClient) {
    browserQueryClient = createQueryClient()
  }

  return browserQueryClient
}

Provider Setup

The QueryClientProvider wraps the app in src/lib/providers.tsx:
'use client'

import { QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import { getQueryClient } from './query-client'

export function Providers({ children }: { children: ReactNode }) {
  const queryClient = getQueryClient()

  return (
    <QueryClientProvider client={queryClient}>
      {children}
      {process.env.NODE_ENV === 'development' && (
        <ReactQueryDevtools initialIsOpen={false} buttonPosition="bottom-right" />
      )}
    </QueryClientProvider>
  )
}

Query Patterns

1. Query Keys Organization

Query keys are organized hierarchically:
// src/modules/auth/hooks/use-auth-api.ts
export const authKeys = {
  me: ['auth', 'me'] as const,
  preferences: ['auth', 'preferences'] as const,
}

// src/lib/query-client.ts
export const queryKeys = {
  users: {
    all: ['users'] as const,
    lists: () => [...queryKeys.users.all, 'list'] as const,
    list: (filters: Record<string, unknown>) => [...queryKeys.users.lists(), filters] as const,
    details: () => [...queryKeys.users.all, 'detail'] as const,
    detail: (id: string) => [...queryKeys.users.details(), id] as const,
  },
} as const
Pattern: ['resource', 'type', ...params] allows for granular cache invalidation.

2. useQuery Hook Pattern

Standard query hook implementation:
import { useQuery } from '@tanstack/react-query'
import { apiClient } from '@/lib/api/client'

export function useUser(options?: { enabled?: boolean }) {
  return useQuery({
    queryKey: authKeys.me,
    queryFn: async () => {
      const res = await apiClient.get<GetMeResponse>('/v1/auth/me')
      if (!res.success || !res.data) {
        throw new Error(res.error?.message || 'Failed to fetch user')
      }
      return res.data
    },
    retry: false,
    enabled: options?.enabled ?? true,
  })
}

// Usage in component
function UserProfile() {
  const { data: user, isLoading, error } = useUser()
  
  if (isLoading) return <Skeleton />
  if (error) return <Alert>Error loading user</Alert>
  
  return <div>{user.name}</div>
}

3. useMutation Hook Pattern

Mutations for data modifications:
import { useMutation, useQueryClient } from '@tanstack/react-query'

export function useCreateOrganization() {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: async (data: CreateOrganizationRequest) => {
      const response = await apiClient.post<CreateOrganizationResponse>(
        '/v1/organizations',
        data
      )
      return response.data
    },
    onSuccess: () => {
      // Invalidate and refetch user data (includes organizations list)
      setTimeout(() => {
        queryClient.invalidateQueries({ queryKey: authKeys.me })
      }, 500)
    },
  })
}

// Usage in component
function CreateOrgForm() {
  const createOrg = useCreateOrganization()
  
  const handleSubmit = async (data: FormData) => {
    try {
      await createOrg.mutateAsync(data)
      toast.success('Organization created!')
    } catch (error) {
      toast.error('Failed to create organization')
    }
  }
  
  return (
    <form onSubmit={handleSubmit}>
      <Button type="submit" disabled={createOrg.isPending}>
        {createOrg.isPending ? 'Creating...' : 'Create'}
      </Button>
    </form>
  )
}
export function useUpdateProfile() {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: async (data: UpdateProfileRequest) => {
      const res = await apiClient.patch<UpdateProfileResponse>(
        '/v1/auth/profile',
        data
      )
      if (!res.success || !res.data) {
        throw new Error(res.error?.message || 'Failed to update profile')
      }
      return res.data
    },
    onSuccess: () => {
      // Invalidate user query to refetch with updated data
      queryClient.invalidateQueries({ queryKey: authKeys.me })
    },
  })
}
export function useCancelInvitation() {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: async ({ organizationId, invitationId }) => {
      await apiClient.delete(
        `/v1/organizations/${organizationId}/invitations/${invitationId}`
      )
    },
    onSuccess: (_data, variables) => {
      // Invalidate only the specific invitations list
      setTimeout(() => {
        queryClient.invalidateQueries({
          queryKey: [ORGANIZATIONS_KEY, variables.organizationId, INVITATIONS_KEY],
        })
      }, 100)
    },
  })
}

4. Cache Invalidation Patterns

// Invalidate exact query
queryClient.invalidateQueries({
  queryKey: authKeys.me
})
Timeout Pattern: The codebase uses setTimeout delays (100-500ms) after mutations to avoid immediate CORS preflight issues in development with the Next.js proxy.

API Client

Custom HTTP client in src/lib/api/client.ts:
import { createClient } from '@/lib/supabase/client'
import type { APIResponse } from './types'

class APIClient {
  private baseURL: string

  constructor() {
    this.baseURL = siteConfig.apiUrl
  }

  private async getAuthToken(): Promise<string | null> {
    const supabase = createClient()
    const { data: { session } } = await supabase.auth.getSession()
    return session?.access_token ?? null
  }

  async request<T>(endpoint: string, options: RequestInit = {}): Promise<APIResponse<T>> {
    const token = await this.getAuthToken()

    const headers: Record<string, string> = {
      'Content-Type': 'application/json',
      ...(options.headers as Record<string, string>),
    }

    if (token) {
      headers['Authorization'] = `Bearer ${token}`
    }

    // Request timeout handling
    const controller = new AbortController()
    const timeoutId = setTimeout(() => controller.abort(), 30000)

    try {
      const response = await fetch(`${this.baseURL}${endpoint}`, {
        ...options,
        headers,
        signal: controller.signal,
        cache: 'no-store',
      })
      clearTimeout(timeoutId)

      // Handle 204 No Content
      if (response.status === 204) {
        return { success: true, data: null as T }
      }

      const data = await response.json()
      if (!response.ok) throw data

      return data as APIResponse<T>
    } catch (error) {
      clearTimeout(timeoutId)
      if (error.name === 'AbortError') {
        throw new Error('Request timeout - please try again')
      }
      throw error
    }
  }

  async get<T>(endpoint: string) { return this.request<T>(endpoint, { method: 'GET' }) }
  async post<T>(endpoint: string, body?: unknown) {
    return this.request<T>(endpoint, { method: 'POST', body: JSON.stringify(body) })
  }
  async patch<T>(endpoint: string, body?: unknown) {
    return this.request<T>(endpoint, { method: 'PATCH', body: JSON.stringify(body) })
  }
  async put<T>(endpoint: string, body?: unknown) {
    return this.request<T>(endpoint, { method: 'PUT', body: JSON.stringify(body) })
  }
  async delete<T>(endpoint: string) { return this.request<T>(endpoint, { method: 'DELETE' }) }
}

export const apiClient = new APIClient()

Zustand Stores

Minimal client state management for non-server data.

Auth Store

Session user state (src/stores/auth.ts):
import { create } from 'zustand'
import type { User as SupabaseUser } from '@supabase/supabase-js'

interface AuthState {
  user: SupabaseUser | null
  isLoading: boolean
  setUser: (user: SupabaseUser | null) => void
  setLoading: (loading: boolean) => void
  logout: () => void
}

export const useAuthStore = create<AuthState>(set => ({
  user: null,
  isLoading: true,
  setUser: user => set({ user, isLoading: false }),
  setLoading: isLoading => set({ isLoading }),
  logout: () => set({ user: null }),
}))

// Usage
function UserBadge() {
  const user = useAuthStore(state => state.user)
  return <div>{user?.email}</div>
}

Waitlist Store (Persisted)

Local storage persistence with middleware (src/stores/waitlist.ts):
import { create } from 'zustand'
import { persist } from 'zustand/middleware'

interface WaitlistState {
  hasJoined: boolean
  email: string | null
  position: number | null
  setJoined: (email: string, position: number) => void
  reset: () => void
}

export const useWaitlistStore = create<WaitlistState>()(
  persist(
    set => ({
      hasJoined: false,
      email: null,
      position: null,
      setJoined: (email, position) => set({ hasJoined: true, email, position }),
      reset: () => set({ hasJoined: false, email: null, position: null }),
    }),
    {
      name: 'ryva-waitlist', // localStorage key
    }
  )
)
When to use Zustand: UI state (modals, sidebar open/closed), temporary client data, or state that doesn’t belong on the server.

React Context

Organization Context

Active organization selection (src/contexts/organization-context.tsx):
'use client'

import { createContext, useContext, useState, useEffect, useMemo, useCallback } from 'react'
import { useUser } from '@/modules/auth/hooks/use-auth-api'
import type { OrganizationMembership } from '@/lib/api/types'

interface OrganizationContextValue {
  activeOrganization: OrganizationMembership | null
  setActiveOrganization: (org: OrganizationMembership | null) => void
  organizations: OrganizationMembership[]
  isLoading: boolean
  subdomain: string | null
}

const OrganizationContext = createContext<OrganizationContextValue | undefined>(undefined)

const ACTIVE_ORG_KEY = 'ryva:active-org-id'

export function OrganizationProvider({ children }: { children: React.ReactNode }) {
  const { data: user, isLoading: isUserLoading } = useUser()
  const [activeOrganization, setActiveOrganizationState] = useState<OrganizationMembership | null>(null)
  const subdomain = useMemo(() => getSubdomain(), [])

  // Memoize organizations to prevent re-renders
  const organizations = useMemo(() => user?.organizations || [], [user])

  // Initialize state based on subdomain and localStorage
  useEffect(() => {
    if (isUserLoading || !user) return

    if (subdomain) {
      const matchedOrg = organizations.find(
        org => org.organization_slug?.toLowerCase() === subdomain.toLowerCase()
      )
      if (matchedOrg) {
        setActiveOrganizationState(matchedOrg)
        localStorage.setItem(ACTIVE_ORG_KEY, matchedOrg.organization_id)
        return
      }
    }

    // Fallback to localStorage or first org
    const savedOrgId = localStorage.getItem(ACTIVE_ORG_KEY)
    const savedOrg = organizations.find(org => org.organization_id === savedOrgId)
    
    if (savedOrg) {
      setActiveOrganizationState(savedOrg)
    } else if (organizations.length > 0) {
      setActiveOrganizationState(organizations[0])
      localStorage.setItem(ACTIVE_ORG_KEY, organizations[0].organization_id)
    }
  }, [user, organizations, isUserLoading, subdomain])

  const setActiveOrganization = useCallback((org: OrganizationMembership | null) => {
    if (org === null) {
      setActiveOrganizationState(null)
      localStorage.removeItem(ACTIVE_ORG_KEY)
    } else {
      setActiveOrganizationState(org)
      localStorage.setItem(ACTIVE_ORG_KEY, org.organization_id)
    }
  }, [])

  const value = {
    activeOrganization,
    setActiveOrganization,
    organizations,
    isLoading: isUserLoading,
    subdomain,
  }

  return <OrganizationContext.Provider value={value}>{children}</OrganizationContext.Provider>
}

export function useOrganization() {
  const context = useContext(OrganizationContext)
  if (context === undefined) {
    throw new Error('useOrganization must be used within an OrganizationProvider')
  }
  return context
}
Usage:
function TeamSwitcher() {
  const { activeOrganization, setActiveOrganization, organizations } = useOrganization()
  
  return (
    <Select value={activeOrganization?.id} onValueChange={handleChange}>
      {organizations.map(org => (
        <SelectItem key={org.id} value={org.id}>
          {org.name}
        </SelectItem>
      ))}
    </Select>
  )
}

Best Practices

Use TanStack Query for all data from APIs. It handles caching, revalidation, and loading states automatically.
Define query keys in module files alongside the hooks. Use hierarchical keys for easy invalidation.
Only use Zustand for true client state (UI preferences, temporary data). Server data should use TanStack Query.
Always invalidate related queries in onSuccess callbacks to keep the UI in sync.
Only implement optimistic updates for simple mutations where rollback is easy.
Always render appropriate UI for isLoading, error, and empty states.

State Management Decision Tree

Performance Tips

Memoize Expensive Computations

Use useMemo for derived data and useCallback for stable function references.

Configure Stale Time

Set appropriate staleTime to reduce unnecessary refetches.

Selective Query Invalidation

Invalidate only affected queries, not entire query prefixes.

Enable Query Devtools

Use React Query Devtools in development to debug cache behavior.

Next Steps

Component Patterns

Learn about component architecture

Frontend Structure

Review the project organization