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
- Always use
isPendingnotisLoading(deprecated in v5) - Use
gcTimenotcacheTime(renamed in v5) - Prefer
mutateAsyncfor better error handling in forms - Use query key factories for complex key structures
- Integrate with router for URL-driven state
- Manual mode for tables when doing server-side operations
- Type-safe search params with validateSearch