| name | preferences-react-tanstack-ui-development |
| description | React and TanStack UI development patterns including component design, routing, and state management. Load when working with React components or TanStack libraries. |
React / TanStack UI development
Architectural patterns alignment
See @~/.claude/skills/preferences-architectural-patterns/SKILL.md for overarching principles.
Apply functional programming patterns to React UI development:
- Components are pure functions from props to JSX
- State transformations should be explicit and traceable
- Side effects isolated to hooks and boundaries (useEffect, mutations)
- Composition over inheritance for component design
- Type-safety enforced from routing through rendering
TanStack ecosystem integration
The TanStack ecosystem provides type-safe, composable primitives for modern React applications.
Core libraries
- TanStack Router: File-based routing with full type inference for params, search, and loaders
- TanStack Query: Declarative server state management with automatic caching and revalidation
- TanStack Form: Type-safe form handling with validation and error management
- TanStack Store: Reactive client state with fine-grained subscriptions
- TanStack DB: Reactive collections with live queries for complex client-side data
Integration pattern
import { createFileRoute } from '@tanstack/react-router'
import { api } from '@/lib/trpc'
export const Route = createFileRoute('/users/$userId')({
loader: async ({ params }) => {
const user = await api.users.getById.query(params.userId)
return { user }
},
})
function UserProfile() {
const { user } = Route.useLoaderData()
}
State management philosophy
Separate concerns between server state and client state.
Server state (TanStack Query)
Use TanStack Query for any data that originates from the server:
- API responses
- Database queries via tRPC
- Remote resources and assets
- Paginated and infinite lists
Key patterns:
- Let TanStack Query handle caching, revalidation, and background updates
- Use optimistic updates for perceived performance
- Leverage query invalidation for cache management
- Structure queries with proper keys for selective invalidation
import { api } from '@/lib/trpc'
function UserList() {
const { data, isLoading } = api.users.list.useQuery()
const updateUser = api.users.update.useMutation({
onSuccess: () => {
utils.users.list.invalidate()
}
})
}
Client state (TanStack Store / Zustand)
Use client state management for UI-only state:
- Form field values before submission
- UI toggles (modals, dropdowns, themes)
- Client-side filters and sorts
- Transient interaction state
Key patterns:
- Keep client state minimal and derive from server state when possible
- Use TanStack Store for reactive updates with fine-grained subscriptions
- Consider Zustand for simpler, less reactive client state needs
- Avoid duplicating server state in client state
import { create } from 'zustand'
interface UIState {
isModalOpen: boolean
selectedIds: Set<string>
toggleModal: () => void
selectId: (id: string) => void
}
const useUIStore = create<UIState>((set) => ({
isModalOpen: false,
selectedIds: new Set(),
toggleModal: () => set((state) => ({ isModalOpen: !state.isModalOpen })),
selectId: (id) => set((state) => ({
selectedIds: new Set(state.selectedIds).add(id)
})),
}))
Pragmatic exceptions for specialized use cases
Client-side analytics with DuckDB WASM
For applications performing client-side data analysis with DuckDB WASM (e.g., using SQLRooms), Zustand is acceptable instead of TanStack Store when:
- The application architecture follows vertical slices isolating analytics concerns
- Type-safe data source configuration uses Zod schemas (ADT alignment maintained)
- Query execution and state updates are confined to dedicated slices
- The imperative state patterns do not leak into general application logic
Rationale: DuckDB WASM analytics applications require fine-grained control over query execution, caching, and result streaming.
Zustand's imperative API provides ergonomic access to these patterns without the overhead of TanStack Store's reactive primitives.
The vertical slice architecture ensures analytics state remains isolated from general UI state.
SQLRooms vertical slice pattern
SQLRooms implements a composable slice architecture where each feature is self-contained:
import { createRoomStore, createRoomShellSlice } from '@sqlrooms/room-shell'
import { createSqlEditorSlice } from '@sqlrooms/sql-editor'
import { createWasmDuckDbConnector } from '@sqlrooms/duckdb'
import { z } from 'zod'
import { persist } from 'zustand/middleware'
const RoomConfig = BaseRoomConfig.merge(SqlEditorSliceConfig)
type RoomConfig = z.infer<typeof RoomConfig>
const { roomStore, useRoomStore } = createRoomStore<RoomConfig, RoomState>(
persist(
(set, get, store) => ({
...createRoomShellSlice({
connector: createWasmDuckDbConnector(),
config: { layout: {...}, dataSources: [...] },
room: { panels: {...} }
})(set, get, store),
...createSqlEditorSlice()(set, get, store),
: {
: {},
: ({ : {...} })
}
}),
{
: ,
: ({
: .(state.)
})
}
)
)
Type-safe S3/HTTPFS data sources with Zod
Use discriminated unions for data source configuration (matches ADT patterns from @~/.claude/skills/preferences-algebraic-data-types/SKILL.md):
const DataSourceTypes = z.enum(['file', 'url', 'sql'])
const BaseDataSource = z.object({
type: DataSourceTypes,
tableName: z.string()
})
const UrlDataSource = BaseDataSource.extend({
type: z.literal('url'),
url: z.string(),
loadOptions: z.object({
method: z.enum(['read_csv', 'read_parquet']),
select: z.array(z.string()).optional(),
where: z.string().optional()
}).optional(),
httpMethod: z.string().optional(),
headers: z.record(z.string(), z.string()).optional()
})
const DataSource = z.discriminatedUnion(, [
,
,
])
: = {
: ,
: ,
: ,
: {
: ,
: [, , , ],
:
}
}
config = {
: [s3DataSource]
}
.(config)
Railway-oriented programming for query error handling
Apply Result types to query execution for explicit error handling.
See @~/.claude/skills/preferences-railway-oriented-programming/SKILL.md for Result patterns and error composition.
import { z } from 'zod'
type Result<T, E> =
| { ok: true; value: T }
| { ok: false; error: E }
const success = <T, E>(value: T): Result<T, E> =>
({ ok: true, value })
const failure = <T, E>(error: E): Result<T, E> =>
({ ok: false, error })
type QueryError =
| { type: 'connection'; message: string }
| { type: 'syntax'; message: string; line?: number }
| { type: 'data_source'; message: string; source: string }
| { type: 'network'; message: string; status?: number }
const executeQuery = async (
sql: string,
db:
): <<, >> => {
{
syntaxResult = (sql)
(!syntaxResult.) syntaxResult
result = db.(sql)
(result)
} (error) {
(error..()) {
({
: ,
: ,
: (error.)
})
}
(error..()) {
({
: ,
: error.,
: (error.)
})
}
({
: ,
: error.
})
}
}
= () => ({
: {
: ,
: | ,
: | ,
: ,
: (: ) => {
({ : { ...()., : , : } })
result = (sql, ()..)
(result.) {
({
: {
...().,
: result.,
:
}
})
} {
({
: {
...().,
: result.,
:
}
})
}
}
}
})
() {
(error.) {
:
(
)
:
(
)
:
(
)
:
(
)
}
}
S3/HTTPFS configuration requirements
For browser-based S3 queries with DuckDB WASM httpfs extension:
CORS configuration: S3 bucket must allow browser origins
{
"CORSRules": [
{
"AllowedOrigins": ["https://your-app.com"],
"AllowedMethods": ["GET", "HEAD"],
"AllowedHeaders": ["*"],
"ExposeHeaders": ["ETag", "Content-Length"]
}
]
}
DuckDB httpfs extension: Automatically loaded by @sqlrooms/duckdb
const connector = createWasmDuckDbConnector({
initializationQuery: `
INSTALL httpfs;
LOAD httpfs;
SET s3_region='us-east-1';
`
})
Direct parquet queries: No table creation needed for one-off queries
const adhocQuery = `
SELECT
event_type,
COUNT(*) as count
FROM read_parquet('s3://ducklake-public/data/events/*.parquet')
WHERE created_at >= '2024-01-01'
GROUP BY event_type
`
const result = await executeQuery(adhocQuery, db)
When to use this pattern
Use SQLRooms with Zustand for:
- Client-side analytics applications: User-facing data exploration tools
- Privacy-preserving analytics: Data never leaves the user's device
- Offline-first data tools: Full query capabilities without server
- Local-first dashboards: Real-time visualization of local/remote data
Return to TanStack Store for:
- General application state: Non-analytics UI state
- Server state management: Use TanStack Query + tRPC instead
- Real-time collaboration: Use TanStack DB for live queries
TanStack DB for reactive collections
Use TanStack DB when you need multiple live views of the same data:
- Dashboards with filtered sections
- Real-time collaborative interfaces
- Complex client-side aggregations
- Data tables with dynamic filtering
import { createCollection } from '@tanstack/react-db'
import { useLiveQuery } from '@tanstack/react-db'
const todoCollection = createCollection(
queryCollectionOptions<Todo>({
queryKey: ['todos'],
queryFn: async () => api.todos.list.query(),
queryClient,
getKey: (item) => item.id,
})
)
function TodoDashboard() {
const { data: all } = useLiveQuery(q => q.from({ todo: todoCollection }))
const { data: pending } = useLiveQuery(q =>
q.from({ todo: todoCollection }).where(({ todo }) => !todo.completed)
)
const { data: completed } = useLiveQuery(q =>
q.from({ todo: todoCollection }).( todo.)
)
}
Component architecture
Composition patterns
Favor composition over prop drilling or complex context hierarchies.
function DataTable({ children }: { children: React.ReactNode }) {
const [sorting, setSorting] = useState<SortingState>([])
return (
<DataTableContext.Provider value={{ sorting, setSorting }}>
{children}
</DataTableContext.Provider>
)
}
DataTable.Header = function Header() {
const { sorting, setSorting } = useDataTableContext()
}
DataTable.Body = function Body({ data }: { data: unknown[] }) {
const { sorting } = useDataTableContext()
}
<DataTable>
<DataTable.Header />
<DataTable.Body data={users} />
</DataTable>
Component libraries
Preferred: shadcn/ui with Radix UI primitives
- Copy-paste components for full control
- Accessible by default (Radix UI)
- Customizable with Tailwind CSS
- Type-safe composition
Pattern: Extend shadcn/ui components with domain logic
import { Button } from '@/components/ui/button'
import { cva, type VariantProps } from 'class-variance-authority'
const appButtonVariants = cva('', {
variants: {
intent: {
primary: 'bg-primary text-primary-foreground',
danger: 'bg-destructive text-destructive-foreground',
success: 'bg-green-600 text-white',
},
},
})
interface AppButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof appButtonVariants> {
isLoading?: boolean
}
export function AppButton({ intent, isLoading, children, ...props }: AppButtonProps) {
return (
<Button className={appButtonVariants({ intent })} disabled={isLoading} {...props}>
{isLoading ? <Spinner /> : children}
</Button>
)
}
Avoid prop drilling
Use composition and context judiciously:
- Composition first: Pass components as children or props
- Context for cross-cutting concerns: Theme, auth, i18n
- Not for data flow: Use TanStack Router loaders instead
Type-safe routing
TanStack Router provides compile-time type safety for routing.
File-based routing conventions
routes/
__root.tsx # Root layout
index.tsx # / route
users/
index.tsx # /users
$userId.tsx # /users/:userId
$userId/
edit.tsx # /users/:userId/edit
(auth)/ # Route group (no path segment)
login.tsx # /login
register.tsx # /register
Route definitions with loaders
import { createFileRoute } from '@tanstack/react-router'
import { z } from 'zod'
const userSearchSchema = z.object({
tab: z.enum(['profile', 'settings', 'activity']).default('profile'),
})
export const Route = createFileRoute('/users/$userId')({
validateSearch: userSearchSchema,
loader: async ({ params, context }) => {
const user = await context.api.users.getById.query(params.userId)
if (!user) throw new Error('User not found')
return { user }
},
errorComponent: ({ error }) => <ErrorDisplay error={error} />,
})
function UserPage() {
{ user } = .()
search = .()
navigate = .()
({
: ,
: { : user. },
: { : },
})
}
Protected routes
export const Route = createFileRoute('/(protected)/_layout')({
beforeLoad: async ({ context }) => {
if (!context.auth.isAuthenticated) {
throw redirect({ to: '/login' })
}
},
})
Form handling
Integrate TanStack Form with Zod for type-safe validation and railway-oriented submission.
Form patterns with railway-oriented programming
See @~/.claude/skills/preferences-railway-oriented-programming/SKILL.md for Result types and error composition.
import { useForm } from '@tanstack/react-form'
import { zodValidator } from '@tanstack/zod-form-adapter'
import { z } from 'zod'
const userSchema = z.object({
email: z.string().email('Invalid email'),
name: z.string().min(2, 'Name too short'),
age: z.number().min(18, 'Must be 18+'),
})
type UserFormData = z.infer<typeof userSchema>
function UserForm() {
const createUser = api.users.create.useMutation()
const form = useForm({
defaultValues: {
email: '',
name: '',
age: 0,
},
onSubmit: async ({ value }) => {
result = createUser.(value)
(result.) {
toast.()
({ : , : { : result.. } })
}
{
toast.(result..)
}
},
: (),
})
(
)
}
Collecting all validation errors
Use applicative validation for better UX - show all errors at once.
See @~/.claude/skills/preferences-railway-oriented-programming/SKILL.md for applicative patterns.
const form = useForm({
validators: {
onSubmit: userSchema,
},
})
Reactive patterns
Avoiding unnecessary re-renders
function BadComponent() {
const store = useUIStore()
return <div>{store.someValue}</div>
}
function GoodComponent() {
const someValue = useUIStore((state) => state.someValue)
return <div>{someValue}</div>
}
React Server Components (TanStack Start)
When using TanStack Start with SSR:
- Use server functions for data fetching where appropriate
- Minimize client-side JavaScript with Server Components
- Stream data for progressive enhancement
import { createServerFn } from '@tanstack/start'
export const getUser = createServerFn('GET', async (userId: string) => {
const user = await db.users.findById(userId)
return user
})
function UserProfile({ userId }: { userId: string }) {
const user = await getUser(userId)
return <div>{user.name}</div>
}
Build tooling
Vite with Rolldown
Prefer Rolldown over traditional Vite for faster builds:
{
"devDependencies": {
"vite": "npm:rolldown-vite@latest"
},
"overrides": {
"vite": "npm:rolldown-vite@latest"
}
}
Benefits:
- Faster build times (Rust-based)
- Better tree-shaking
- Smaller bundle sizes
Biome for linting and formatting
Use Biome instead of ESLint + Prettier for unified tooling.
Configuration (biome.json):
{
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 120
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"style": {
"useAsConstAssertion": "error",
"noParameterAssign": "error"
}
}
}
}
Scripts:
{
"scripts": {
"format": "biome format --write",
"lint": "biome lint",
"check": "biome check",
"check:fix": "biome check --write"
}
}
Package managers
Bun vs pnpm
Prefer Bun for:
- Monorepo projects with local development
- Projects using Bun runtime features
- Faster installation and execution
Use pnpm for:
- Better compatibility with legacy packages
- Stricter dependency isolation
- Projects with complex peer dependencies
Lock file convention:
- Commit
bun.lockb or pnpm-lock.yaml
- Never mix lock files in same project
Styling
Tailwind CSS patterns
Use Tailwind CSS v4 with the Vite plugin:
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [tailwindcss()],
})
Import in entry:
import 'tailwindcss'
Component variants with CVA
Use class-variance-authority for type-safe variant management:
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
'inline-flex items-center justify-center rounded-md font-medium transition-colors',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border border-input bg-background hover:bg-accent',
ghost: 'hover:bg-accent hover:text-accent-foreground',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 px-3',
lg: 'h-11 px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
)
interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
export function Button({ className, variant, size, ...props }: ) {
(
)
}
Design tokens
Extract design tokens to CSS variables for consistency:
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 47.4% 11.2%;
}
}
Performance optimization
Code splitting patterns
import { lazy, Suspense } from 'react'
const HeavyChart = lazy(() => import('./HeavyChart'))
function Dashboard() {
return (
<Suspense fallback={<ChartSkeleton />}>
<HeavyChart data={data} />
</Suspense>
)
}
Bundle optimization with Rolldown
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: {
'vendor-react': ['react', 'react-dom'],
'vendor-tanstack': [
'@tanstack/react-router',
'@tanstack/react-query',
],
'vendor-ui': ['@radix-ui/react-dialog', '@radix-ui/react-dropdown-menu'],
},
},
},
},
})
Image optimization
<picture>
<source srcSet="/image.avif" type="image/avif" />
<source srcSet="/image.webp" type="image/webp" />
<img src="/image.jpg" alt="Description" loading="lazy" />
</picture>
Deployment
See @~/.claude/skills/preferences-web-application-deployment/SKILL.md for comprehensive deployment guidance including:
- Cloudflare Workers/Pages deployment (preferred)
- Database configuration (D1, PostgreSQL)
- Wrangler configuration and platform bindings
- Multi-environment strategies
- Service bindings for microservices architecture
React-specific deployment patterns
Vite configuration with Cloudflare plugin
import { defineConfig } from "vite";
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import viteReact from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import { cloudflare } from "@cloudflare/vite-plugin";
export default defineConfig({
plugins: [
tailwindcss(),
tanstackStart({
srcDirectory: "src",
start: { entry: "./start.tsx" },
server: { entry: "./server.ts" },
}),
viteReact(),
cloudflare({
viteEnvironment: {
name: "ssr",
},
}),
],
});
SSR hydration considerations
Environment variables:
- Avoid
VITE_ prefix for secrets - they bundle into client code
- Access runtime environment via Cloudflare bindings in server functions
import { env } from "cloudflare:workers";
export const serverFunction = createServerFn()
.handler(async () => {
const data = await env.DB.prepare("SELECT * FROM users").all();
return data;
});
Hydration:
- Ensure server and client render identical markup
- Use
suppressHydrationWarning only for intentional mismatches (timestamps, randomness)
function ClientOnly({ children }: { children: React.ReactNode }) {
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
if (!mounted) return null
return <>{children}</>
}
<ClientOnly>
<BrowserOnlyFeature />
</ClientOnly>
Common hydration issues:
- Different timestamps between server and client renders
- Browser-specific APIs called during SSR (window, localStorage)
- Random values generated server-side not matching client-side
function BadComponent() {
return <div>{Math.random()}</div>
}
function GoodComponent() {
return <div suppressHydrationWarning>{Math.random()}</div>
}
function BestComponent() {
return (
<ClientOnly>
<div>{Math.random()}</div>
</ClientOnly>
)
}
Integration with backend patterns
tRPC bridges frontend and backend
tRPC provides end-to-end type safety from database to UI.
Server procedure:
import { publicProcedure, router } from '../trpc'
import { z } from 'zod'
export const usersRouter = router({
getById: publicProcedure
.input(z.string().uuid())
.query(async ({ input, ctx }) => {
const user = await ctx.db.users.findById(input)
if (!user) {
throw new TRPCError({ code: 'NOT_FOUND' })
}
return user
}),
create: publicProcedure
.input(z.object({
email: z.string().email(),
name: z.string().min(2),
}))
.mutation(async ({ input, ctx }) => {
const user = await ctx.db.users.create(input)
return user
}),
})
Client usage:
function UserProfile({ userId }: { userId: string }) {
const { data: user, isLoading } = api.users.getById.useQuery(userId)
const createUser = api.users.create.useMutation()
}
Where to apply Effect-TS in UI layer
Reserve Effect-TS for complex client-side effects that benefit from explicit effect management:
import { Effect } from 'effect'
const submitFormWithRetry = (data: FormData) =>
Effect.gen(function* (_) {
const result = yield* _(
Effect.tryPromise({
try: () => api.users.create.mutate(data),
catch: (error) => new SubmitError({ cause: error }),
})
)
return result
}).pipe(
Effect.retry({
times: 3,
schedule: Schedule.exponential('100 millis'),
}),
Effect.catchAll(() =>
Effect.succeed(queueForLater(data))
)
)
Most UI code should use simpler patterns (TanStack Query, tRPC) unless:
- Complex retry/fallback logic required
- Multiple dependent effects need composition
- Advanced concurrency control needed
See @~/.claude/skills/preferences-typescript-nodejs-development/SKILL.md for Effect-TS patterns in backend code.
Testing UI components
Testing Library patterns
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
describe('UserForm', () => {
it('validates all fields and shows errors', async () => {
const user = userEvent.setup()
render(<UserForm />)
await user.click(screen.getByRole('button', { name: /submit/i }))
await waitFor(() => {
expect(screen.getByText(/invalid email/i)).toBeInTheDocument()
expect(screen.getByText(/name too short/i)).toBeInTheDocument()
expect(screen.getByText(/must be 18\+/i)).toBeInTheDocument()
})
})
})
Testing with TanStack Query
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
})
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
}
describe('UserList', () => {
it('fetches and displays users', async () => {
render(<UserList />, { wrapper: createWrapper() })
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument()
})
})
})
Integration with algebraic data types
See @~/.claude/skills/preferences-algebraic-data-types/SKILL.md for ADT patterns in TypeScript.
Use discriminated unions for component state machines:
type FormState =
| { status: 'idle' }
| { status: 'validating' }
| { status: 'submitting' }
| { status: 'success'; data: User }
| { status: 'error'; error: Error }
function FormComponent() {
const [state, setState] = useState<FormState>({ status: 'idle' })
switch (state.status) {
case 'idle':
return <FormFields onSubmit={handleSubmit} />
case 'validating':
return <FormFields disabled />
case 'submitting':
return <FormFields disabled><Spinner /></FormFields>
case 'success':
return
:
}
}
Use branded types for type-safe IDs:
import { Brand } from '@/lib/utils'
type UserId = Brand<string, 'UserId'>
type PostId = Brand<string, 'PostId'>
function getUser(id: UserId): Promise<User> { }
function getPost(id: PostId): Promise<Post> { }