Awesome-omni-skill preferences-react-tanstack-ui-development
React and TanStack UI development patterns including component design, routing, and state management. Load when working with React components or TanStack libraries.
git clone https://github.com/diegosouzapw/awesome-omni-skill
T=$(mktemp -d) && git clone --depth=1 https://github.com/diegosouzapw/awesome-omni-skill "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/development/preferences-react-tanstack-ui-development" ~/.claude/skills/diegosouzapw-awesome-omni-skill-preferences-react-tanstack-ui-development && rm -rf "$T"
skills/development/preferences-react-tanstack-ui-development/SKILL.mdReact / 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
// TanStack Router + Query + tRPC creates end-to-end type safety import { createFileRoute } from '@tanstack/react-router' import { api } from '@/lib/trpc' export const Route = createFileRoute('/users/$userId')({ // Loader provides type-safe data to component loader: async ({ params }) => { const user = await api.users.getById.query(params.userId) return { user } }, }) function UserProfile() { const { user } = Route.useLoaderData() // Fully typed! // ... }
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
// Server state with TanStack Query + tRPC import { api } from '@/lib/trpc' function UserList() { const { data, isLoading } = api.users.list.useQuery() const updateUser = api.users.update.useMutation({ onSuccess: () => { // Invalidate to refetch 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
// Client state with Zustand 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:
// Vertical slice contains: state + actions + UI + config schema 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' // Type-safe configuration with Zod (ADT pattern) const RoomConfig = BaseRoomConfig.merge(SqlEditorSliceConfig) type RoomConfig = z.infer<typeof RoomConfig> // Compose slices into unified store const { roomStore, useRoomStore } = createRoomStore<RoomConfig, RoomState>( persist( (set, get, store) => ({ // DuckDB connector slice ...createRoomShellSlice({ connector: createWasmDuckDbConnector(), config: { layout: {...}, dataSources: [...] }, room: { panels: {...} } })(set, get, store), // SQL editor slice ...createSqlEditorSlice()(set, get, store), // Custom application slice customAnalytics: { filters: {}, applyFilter: (filter) => set({ customAnalytics: {...} }) } }), { name: 'analytics-app-storage', // Only persist configuration, not runtime state partialize: (state) => ({ config: RoomConfig.parse(state.config) }) } ) )
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):
// From @sqlrooms/room-config - Zod schemas for type safety const DataSourceTypes = z.enum(['file', 'url', 'sql']) const BaseDataSource = z.object({ type: DataSourceTypes, tableName: z.string() }) // Discriminated union for different source types 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('type', [ FileDataSource, UrlDataSource, SqlQueryDataSource ]) // S3 data source configuration for public ducklake data const s3DataSource: UrlDataSource = { type: 'url', tableName: 'public_ducklake_data', // DuckDB WASM with httpfs extension supports direct S3 access url: 's3://ducklake-public/data/events.parquet', loadOptions: { method: 'read_parquet', // Push-down filters to parquet reader (performance optimization) select: ['user_id', 'event_type', 'created_at', 'payload'], where: "created_at >= '2024-01-01' AND event_type = 'UserCreated'" } } // Configuration validated at runtime and compile-time const config = { dataSources: [s3DataSource] } RoomConfig.parse(config) // Runtime validation
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' // Result type for query operations 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 }) // Error types as discriminated union 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 } // Query execution with Result type const executeQuery = async ( sql: string, db: DuckDB ): Promise<Result<QueryResult, QueryError>> => { try { // Validate SQL syntax (applicative validation) const syntaxResult = validateSqlSyntax(sql) if (!syntaxResult.ok) return syntaxResult // Execute query const result = await db.query(sql) return success(result) } catch (error) { // Map exceptions to typed errors if (error.message.includes('Catalog Error')) { return failure({ type: 'data_source', message: 'Table not found or data source not loaded', source: extractTableName(error.message) }) } if (error.message.includes('Syntax error')) { return failure({ type: 'syntax', message: error.message, line: extractLineNumber(error.message) }) } return failure({ type: 'connection', message: error.message }) } } // Zustand slice with railway-oriented query actions const createQuerySlice = () => (set, get, store) => ({ query: { sql: '', result: null as QueryResult | null, error: null as QueryError | null, isExecuting: false, // Action with explicit error handling executeQuery: async (sql: string) => { set({ query: { ...get().query, isExecuting: true, error: null } }) const result = await executeQuery(sql, get().db.connection) // Railway tracks: success or failure if (result.ok) { set({ query: { ...get().query, result: result.value, isExecuting: false } }) } else { set({ query: { ...get().query, error: result.error, isExecuting: false } }) } } } }) // UI component with pattern matching on error type function QueryErrorDisplay({ error }: { error: QueryError }) { switch (error.type) { case 'syntax': return ( <div className="text-destructive"> <p>Syntax error{error.line ? ` at line ${error.line}` : ''}</p> <p className="text-sm">{error.message}</p> </div> ) case 'data_source': return ( <div className="text-destructive"> <p>Data source error: {error.source}</p> <p className="text-sm">{error.message}</p> <button onClick={loadDataSource}>Load data source</button> </div> ) case 'network': return ( <div className="text-destructive"> <p>Network error{error.status ? ` (${error.status})` : ''}</p> <p className="text-sm">{error.message}</p> <button onClick={retry}>Retry</button> </div> ) case 'connection': return ( <div className="text-destructive"> <p>Connection error</p> <p className="text-sm">{error.message}</p> </div> ) } }
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
// Extension loads transparently when using s3:// URLs const connector = createWasmDuckDbConnector({ // Optional: Initialize additional extensions initializationQuery: ` INSTALL httpfs; LOAD httpfs; SET s3_region='us-east-1'; ` })
Direct parquet queries: No table creation needed for one-off queries
// Query parquet files directly from S3 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
// Reactive collections with TanStack DB 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, }) ) // Multiple views of same data, all automatically reactive 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 }).where(({ todo }) => todo.completed) ) // All views update automatically when data changes }
Component architecture
Composition patterns
Favor composition over prop drilling or complex context hierarchies.
// Component composition with compound components 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() // ... } // Usage <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
// Extend shadcn Button with app-specific variants 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
// routes/users/$userId.tsx import { createFileRoute } from '@tanstack/react-router' import { z } from 'zod' // Validate search params const userSearchSchema = z.object({ tab: z.enum(['profile', 'settings', 'activity']).default('profile'), }) export const Route = createFileRoute('/users/$userId')({ // Validate params validateSearch: userSearchSchema, // Type-safe loader loader: async ({ params, context }) => { const user = await context.api.users.getById.query(params.userId) if (!user) throw new Error('User not found') return { user } }, // Error boundary errorComponent: ({ error }) => <ErrorDisplay error={error} />, }) function UserPage() { const { user } = Route.useLoaderData() // Typed as User! const search = Route.useSearch() // Typed from schema! const navigate = Route.useNavigate() // Type-safe navigation navigate({ to: '/users/$userId', params: { userId: user.id }, search: { tab: 'settings' }, // Type-checked! }) }
Protected routes
// routes/(protected)/_layout.tsx 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' // Schema defines validation rules 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 }) => { // Railway-oriented pipeline: // 1. Form validates (handled by TanStack Form + Zod) // 2. Submit to API (tRPC ensures type safety) // 3. Handle result const result = await createUser.mutateAsync(value) // Success track if (result.ok) { toast.success('User created') navigate({ to: '/users/$userId', params: { userId: result.data.id } }) } // Failure track else { toast.error(result.error.message) } }, validatorAdapter: zodValidator(), }) return ( <form onSubmit={(e) => { e.preventDefault() form.handleSubmit() }}> <form.Field name="email" validators={{ onChange: userSchema.shape.email, }} children={(field) => ( <div> <Input value={field.state.value} onChange={(e) => field.handleChange(e.target.value)} /> {field.state.meta.errors && ( <span className="text-destructive">{field.state.meta.errors[0]}</span> )} </div> )} /> {/* Other fields... */} <Button type="submit" disabled={form.state.isSubmitting}> Submit </Button> </form> ) }
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.
// TanStack Form validates all fields before submit // Errors collected and displayed per-field const form = useForm({ validators: { // Run all validations, collect all errors onSubmit: userSchema, }, })
Reactive patterns
Avoiding unnecessary re-renders
// ⊘ Bad: Subscribes to entire store function BadComponent() { const store = useUIStore() // Re-renders on ANY state change return <div>{store.someValue}</div> } // ● Good: Subscribe to specific values function GoodComponent() { const someValue = useUIStore((state) => state.someValue) // Only re-renders when someValue changes 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
// Server function import { createServerFn } from '@tanstack/start' export const getUser = createServerFn('GET', async (userId: string) => { // Runs only on server const user = await db.users.findById(userId) return user }) // Client component can call server function function UserProfile({ userId }: { userId: string }) { const user = await getUser(userId) // Type-safe server call 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
orbun.lockbpnpm-lock.yaml - Never mix lock files in same project
Styling
Tailwind CSS patterns
Use Tailwind CSS v4 with the Vite plugin:
// vite.config.ts import tailwindcss from '@tailwindcss/vite' export default defineConfig({ plugins: [tailwindcss()], })
Import in entry:
// src/main.tsx or src/app.css 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 }: ButtonProps) { return ( <button className={cn(buttonVariants({ variant, size }), className)} {...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
// Route-based code splitting (automatic with TanStack Router) // Each route component is lazy-loaded // Component-based splitting import { lazy, Suspense } from 'react' const HeavyChart = lazy(() => import('./HeavyChart')) function Dashboard() { return ( <Suspense fallback={<ChartSkeleton />}> <HeavyChart data={data} /> </Suspense> ) }
Bundle optimization with Rolldown
// vite.config.ts 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
// Use modern formats with fallbacks <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
// vite.config.ts 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" }, // Custom Cloudflare Workers entry }), viteReact(), cloudflare({ viteEnvironment: { name: "ssr", // Enable SSR environment for Workers }, }), ], });
SSR hydration considerations
Environment variables:
- Avoid
prefix for secrets - they bundle into client codeVITE_ - Access runtime environment via Cloudflare bindings in server functions
import { env } from "cloudflare:workers"; // Server function with runtime access export const serverFunction = createServerFn() .handler(async () => { // Access Cloudflare bindings const data = await env.DB.prepare("SELECT * FROM users").all(); return data; });
Hydration:
- Ensure server and client render identical markup
- Use
only for intentional mismatches (timestamps, randomness)suppressHydrationWarning
// Prevent hydration mismatch for client-only content function ClientOnly({ children }: { children: React.ReactNode }) { const [mounted, setMounted] = useState(false) useEffect(() => { setMounted(true) }, []) if (!mounted) return null return <>{children}</> } // Usage: Wrap client-only components <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
// ⊘ Causes hydration mismatch function BadComponent() { return <div>{Math.random()}</div> } // ● Use suppressHydrationWarning for intentional mismatch function GoodComponent() { return <div suppressHydrationWarning>{Math.random()}</div> } // ● Or use ClientOnly wrapper 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:
// server/routes/users.ts 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:
// Fully typed - no manual type definitions needed! function UserProfile({ userId }: { userId: string }) { const { data: user, isLoading } = api.users.getById.useQuery(userId) const createUser = api.users.create.useMutation() // TypeScript knows: // - user type from server return type // - createUser input type from server input schema // - Error types from TRPCError }
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' // Example: Complex form submission with retries and fallbacks const submitFormWithRetry = (data: FormData) => Effect.gen(function* (_) { // Attempt primary API const result = yield* _( Effect.tryPromise({ try: () => api.users.create.mutate(data), catch: (error) => new SubmitError({ cause: error }), }) ) return result }).pipe( // Retry with exponential backoff Effect.retry({ times: 3, schedule: Schedule.exponential('100 millis'), }), // Fallback to queue for offline 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 />) // Submit without filling fields await user.click(screen.getByRole('button', { name: /submit/i })) // All validation errors should appear 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:
// Form state as sum type 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' }) // Pattern match on state switch (state.status) { case 'idle': return <FormFields onSubmit={handleSubmit} /> case 'validating': return <FormFields disabled /> case 'submitting': return <FormFields disabled><Spinner /></FormFields> case 'success': return <SuccessMessage user={state.data} /> case 'error': return <ErrorMessage error={state.error} /> } }
Use branded types for type-safe IDs:
import { Brand } from '@/lib/utils' type UserId = Brand<string, 'UserId'> type PostId = Brand<string, 'PostId'> // Type error if you pass PostId to function expecting UserId function getUser(id: UserId): Promise<User> { /* ... */ } function getPost(id: PostId): Promise<Post> { /* ... */ }