Skip to main content

Source: ocean/docs/patterns/data-fetching.md | ✏️ Edit on GitHub

Data Fetching Patterns

Ocean provides sophisticated data fetching patterns that handle loading states, errors, and caching automatically.

Core Hooks

useGraphQLQuery

Type-safe GraphQL queries with automatic error handling and performance monitoring.

import { useGraphQLQuery } from '@/hooks/use-graphql-query'

// Basic usage
const { data, isLoading, error } = useGraphQLQuery(
GET_USER_QUERY,
{ userId: user.id },
{
enabled: !!user.id,
staleTime: 5 * 60 * 1000, // 5 minutes
}
)

// With typed response
interface UserData {
user: {
id: string
name: string
email: string
}
}

const { data } = useGraphQLQuery<UserData>(GET_USER_QUERY, variables)

useGraphQLMutation

Type-safe mutations with built-in error handling.

import { useGraphQLMutation } from '@/hooks/use-graphql-mutation'

const mutation = useGraphQLMutation(UPDATE_USER_MUTATION)

const handleUpdate = async (data: UpdateUserInput) => {
const result = await mutation.mutateAsync({
userId: user.id,
input: data,
})
// Result is typed based on your GraphQL schema
}

useMutationWithToast

The recommended pattern for all mutations. Automatically handles:

  • Loading states
  • Success/error toast notifications
  • Query cache invalidation
  • Error logging
import { useMutationWithToast } from '@/hooks/use-mutation-with-toast'

const updateProfile = useMutationWithToast({
mutationFn: async (data: ProfileUpdate) => {
const response = await fetch('/api/profile', {
method: 'PUT',
body: JSON.stringify(data)
})
if (!response.ok) throw new Error('Update failed')
return response.json()
},
successMessage: 'Profile updated successfully',
errorMessage: 'Failed to update profile', // Optional, auto-generated if omitted
invalidateQueries: [['profile', userId]], // Refresh these queries on success
onSuccess: (data) => {
// Optional callback
console.log('Updated:', data)
}
})

// Usage in component
<Button
onClick={() => updateProfile.mutate(formData)}
disabled={updateProfile.isPending}
>
{updateProfile.isPending ? 'Saving...' : 'Save Profile'}
</Button>

API Client Hook

useApiClient

Provides a unified interface for all API interactions.

const apiClient = useApiClient()

// GraphQL operations
const userData = await apiClient.graphql(GET_USER_QUERY, { userId })
const result = await apiClient.graphqlMutation(UPDATE_USER_MUTATION, { input })

// Supabase operations
const { data, error } = await apiClient.supabase.from('profiles').select('*').eq('id', userId)

// Edge function calls
const response = await apiClient.edgeFunction('stripe-checkout', {
priceId: 'price_123',
})

Retry Logic

withRetry

Built-in exponential backoff for resilient API calls.

import { withRetry } from '@/lib/api-utils'

// Basic usage
const data = await withRetry(() => fetch('/api/flaky-endpoint'), {
maxAttempts: 3,
delayMs: 1000,
backoffFactor: 2, // Exponential backoff
})

// With custom error handling
const result = await withRetry(
async () => {
const res = await fetch('/api/data')
if (res.status === 429) {
throw new Error('Rate limited') // Will retry
}
if (res.status === 404) {
return null // Won't retry, returns immediately
}
return res.json()
},
{
shouldRetry: (error, attempt) => {
// Custom retry logic
return attempt < 3 && error.message !== 'Not found'
},
}
)

Query Key Patterns

Consistent query key patterns for cache management:

// User queries
;['user', userId][('users', { organizationId, page, limit })][
// Organization queries
('organization', orgId)
][('organizations', userId)][
// Combined queries
('organization-members', orgId)
][('user-permissions', userId, orgId)]

// Invalidation examples
queryClient.invalidateQueries({ queryKey: ['user'] }) // All user queries
queryClient.invalidateQueries({ queryKey: ['user', userId] }) // Specific user

Loading States

Skeleton Components

Pre-built loading skeletons for consistent UX:

import { DashboardLoadingSkeleton } from '@/components/dashboard-loading-skeleton'
import { AuthLoadingSkeleton } from '@/components/auth-loading-skeleton'

// In your component
if (isLoading) {
return <DashboardLoadingSkeleton />
}

// Custom skeleton pattern
function MyComponentSkeleton() {
return (
<div className="space-y-4">
<Skeleton className="h-8 w-64" /> {/* Title */}
<Skeleton className="h-4 w-full" /> {/* Text line */}
<Skeleton className="h-4 w-3/4" /> {/* Text line */}
</div>
)
}

Error Handling

Queries and mutations integrate with error boundaries:

// Errors are automatically caught by error boundaries
const { data } = useGraphQLQuery(QUERY, variables, {
throwOnError: true // Default: false
})

// Manual error handling
const { data, error } = useGraphQLQuery(QUERY, variables)
if (error) {
return <ErrorMessage error={error} />
}

Performance Monitoring

All GraphQL operations are automatically monitored:

// Automatic performance tracking includes:
// - Operation name
// - Duration
// - Success/failure status
// - Variable size
// - Response size

// Custom performance marks
performance.mark('my-operation-start')
const result = await apiCall()
performance.mark('my-operation-end')
performance.measure('my-operation', 'my-operation-start', 'my-operation-end')

Best Practices

  1. Always use useMutationWithToast for mutations that modify data
  2. Set appropriate staleTime for queries based on data volatility
  3. Use consistent query keys for effective cache invalidation
  4. Implement loading skeletons for better perceived performance
  5. Let error boundaries handle errors instead of manual try-catch
  6. Use enabled option to prevent unnecessary queries
  7. Batch related queries when possible for efficiency

Common Patterns

Dependent Queries

const { data: user } = useGraphQLQuery(GET_USER, { id: userId })
const { data: posts } = useGraphQLQuery(
GET_USER_POSTS,
{ userId: user?.id },
{ enabled: !!user?.id } // Only run when user is loaded
)

Optimistic Updates

const mutation = useMutationWithToast({
mutationFn: updatePost,
onMutate: async (newData) => {
// Cancel in-flight queries
await queryClient.cancelQueries({ queryKey: ['post', postId] })

// Save current data
const previousPost = queryClient.getQueryData(['post', postId])

// Optimistically update
queryClient.setQueryData(['post', postId], newData)

return { previousPost }
},
onError: (err, newData, context) => {
// Rollback on error
queryClient.setQueryData(['post', postId], context.previousPost)
},
})

Pagination

const [page, setPage] = useState(1)
const { data, isPreviousData } = useGraphQLQuery(
GET_POSTS,
{ page, limit: 20 },
{
keepPreviousData: true, // Smooth pagination
}
)