Skip to main content

Source: ocean/docs/TANSTACK_CONVENTIONS.md | ✏️ Edit on GitHub

TanStack Conventions for Ocean

This document outlines the conventions for using TanStack Query, Router, Form, and Table in the Ocean project.

TanStack Query Conventions

State Properties

Always use the official TanStack Query state properties:

// ✅ Correct
const { data, isPending, isError, isSuccess } = useQuery({...})
const mutation = useMutation({...})
if (mutation.isPending) {...}

// ❌ Incorrect
const { data, isLoading, loading } = useQuery({...})
if (mutation.isLoading) {...}

Query Key Structure

Use consistent, hierarchical query keys:

// Single resource
;['resource', id][('user', userId)][('organization', orgId)][
// List with filters
('resources', filters)
][('users', { organizationId, page, limit })][('posts', { authorId, status })][
// Nested resources
('resource', id, 'subresource')
][('user', userId, 'posts')][('organization', orgId, 'members')]

Query Options

const { data } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),

// Common options
enabled: !!userId, // Conditional queries
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes (formerly cacheTime)
refetchOnWindowFocus: false, // Disable for sensitive data
retry: 3, // Or custom retry logic
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
})

Mutation Patterns

Always use useMutationWithToast for user-facing mutations:

const mutation = useMutationWithToast({
mutationFn: updateProfile,
successMessage: 'Profile updated successfully',
// Error message is auto-generated if omitted
invalidateQueries: [['profile', userId]]
})

// In your component
<Button
onClick={() => mutation.mutate(data)}
disabled={mutation.isPending}
>
{mutation.isPending ? 'Saving...' : 'Save'}
</Button>

TanStack Router Conventions

Route Loaders with Query

export const Route = createFileRoute('/users/$userId')({
// Prefetch data in loader
loader: ({ context: { queryClient }, params: { userId } }) =>
queryClient.ensureQueryData({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
}),

// Component receives prefetched data
component: UserProfile,
})

Search Params Type Safety

export const Route = createFileRoute('/users')({
validateSearch: (search: Record<string, unknown>) => {
return {
page: Number(search?.page ?? 1),
limit: Number(search?.limit ?? 10),
filter: (search?.filter as string) || '',
}
},
component: UsersPage,
})

TanStack Form Conventions

Form with Validation

import { useForm } from '@tanstack/react-form'
import { valibotValidator } from '@tanstack/valibot-form-adapter'

const form = useForm({
defaultValues: {
email: '',
name: '',
},
onSubmit: async ({ value }) => {
// Always use mutation
await mutation.mutateAsync(value)
},
validators: {
onChange: userSchema, // Valibot schema
},
})

Form Field Pattern

<form.Field name="email">
{(field) => (
<div>
<Label htmlFor={field.name}>Email</Label>
<Input
id={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
aria-invalid={!!field.state.meta.errors.length}
aria-describedby={
field.state.meta.errors.length
? `${field.name}-error`
: undefined
}
/>
{field.state.meta.errors.length > 0 && (
<p id={`${field.name}-error`} className="text-sm text-destructive">
{field.state.meta.errors.join(', ')}
</p>
)}
</div>
)}
</form.Field>

TanStack Table Conventions

Table with Server-Side Operations

const table = useReactTable({
data,
columns,

// State from query params
state: {
sorting,
columnFilters,
pagination: {
pageIndex: page - 1,
pageSize: limit,
},
},

// Manual mode for server-side operations
manualPagination: true,
manualSorting: true,
manualFiltering: true,

// Handlers update URL
onSortingChange: (updater) => {
const newSorting = typeof updater === 'function' ? updater(sorting) : updater
navigate({ search: { ...search, sort: newSorting } })
},

getCoreRowModel: getCoreRowModel(),
})

Integration Example

Complete example integrating all TanStack libraries:

// Route with loader
export const Route = createFileRoute('/users')({
validateSearch: (search) => ({
page: Number(search?.page ?? 1),
limit: Number(search?.limit ?? 10),
}),

loader: ({ context: { queryClient }, search }) =>
queryClient.ensureQueryData({
queryKey: ['users', search],
queryFn: () => fetchUsers(search),
}),

component: UsersPage,
})

function UsersPage() {
const { page, limit } = Route.useSearch()
const navigate = Route.useNavigate()

// Query with router search params
const { data, isPending } = useQuery({
queryKey: ['users', { page, limit }],
queryFn: () => fetchUsers({ page, limit }),
})

// Mutation with form
const createUser = useMutationWithToast({
mutationFn: createUserApi,
successMessage: 'User created',
invalidateQueries: [['users']],
})

const form = useForm({
defaultValues: { name: '', email: '' },
onSubmit: async ({ value }) => {
await createUser.mutateAsync(value)
form.reset()
},
validators: {
onChange: createUserSchema,
},
})

// Table with server-side pagination
const table = useReactTable({
data: data?.users ?? [],
columns: userColumns,
pageCount: data?.pageCount ?? 0,
state: {
pagination: {
pageIndex: page - 1,
pageSize: limit,
},
},
onPaginationChange: (updater) => {
const newPagination = typeof updater === 'function'
? updater({ pageIndex: page - 1, pageSize: limit })
: updater
navigate({
search: {
page: newPagination.pageIndex + 1,
limit: newPagination.pageSize
}
})
},
manualPagination: true,
getCoreRowModel: getCoreRowModel(),
})

if (isPending) return <TableSkeleton />

return (
<div>
<Form form={form}>...</Form>
<DataTable table={table} />
</div>
)
}

Best Practices

  1. Always use isPending not isLoading (deprecated in v5)
  2. Use gcTime not cacheTime (renamed in v5)
  3. Prefer mutateAsync for better error handling in forms
  4. Use query key factories for complex key structures
  5. Integrate with router for URL-driven state
  6. Manual mode for tables when doing server-side operations
  7. Type-safe search params with validateSearch