Skip to main content

UI Component Patterns

This guide covers the UI component conventions, base primitives, and common patterns used in Cascadia's React frontend.

Technology Stack

  • Styling: Tailwind CSS 4
  • Primitives: Radix UI (accessible, unstyled components)
  • Forms: TanStack Form + Zod validation
  • Tables: TanStack Table (via DataGrid wrapper)
  • Icons: Lucide React

Base Components

Base UI primitives live in src/components/ui/. These are low-level building blocks used throughout the application.

Available Components

ComponentFileDescription
ButtonButton.tsxStandard button with variants
InputInput.tsxText input field
TextareaTextarea.tsxMulti-line text input
SelectSelect.tsxDropdown select (Radix)
CheckboxCheckbox.tsxCheckbox (Radix)
SwitchSwitch.tsxToggle switch (Radix)
RadioGroupRadioGroup.tsxRadio button group (Radix)
LabelLabel.tsxForm label (Radix)
FormFieldFormField.tsxLabel + input + error wrapper
CardCard.tsxContent card container
BadgeBadge.tsxStatus/label badges
DialogDialog.tsxModal dialog (Radix)
AlertDialogAlertDialog.tsxConfirmation dialog (Radix)
PopoverPopover.tsxFloating popover (Radix)
TooltipTooltip.tsxHover tooltip (Radix)
DropdownMenuDropdownMenu.tsxDropdown menu (Radix)
ContextMenuContextMenu.tsxRight-click menu (Radix)
TabsTabs.tsxTab navigation (Radix)
TableTable.tsxRaw HTML table primitives
DataGridDataGrid.tsxFull-featured data table
SkeletonSkeleton.tsxLoading skeleton
LoadingSpinnerLoadingSpinner.tsxSpinner animation
ProgressProgress.tsxProgress bar (Radix)
AvatarAvatar.tsxUser avatar (Radix)

Import Pattern

Import from the component file directly:

import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { FormField } from '@/components/ui/FormField'
import { Card, CardHeader, CardContent } from '@/components/ui/Card'

Or from the barrel export:

import { Button, Input, Card } from '@/components/ui'

The cn() Utility

Use cn() from @/lib/utils to merge class names. It wraps clsx for conditional and composable class strings:

import { cn } from '@/lib/utils'

function MyComponent({ className, isActive }: Props) {
return (
<div className={cn(
'rounded-lg border p-4', // Base classes
isActive && 'border-blue-500', // Conditional class
className, // Allow overrides from parent
)}>
...
</div>
)
}

FormField Component

FormField wraps a form control with a label, error message, and help text. It automatically handles accessibility attributes (aria-invalid, aria-describedby, aria-required):

import { FormField } from '@/components/ui/FormField'
import { Input } from '@/components/ui/Input'

<FormField label="Part Number" required error={errors.partNumber}>
<Input
value={value}
onChange={(e) => setValue(e.target.value)}
/>
</FormField>

Forms with TanStack Form + Zod

The zodValidator Wrapper

Zod v4 does not implement StandardSchemaV1 which TanStack Form expects. Use the zodValidator() wrapper from src/lib/form-validation.ts:

import { useForm } from '@tanstack/react-form'
import { zodValidator } from '@/lib/form-validation'
import { partCreateSchema } from '@/lib/api/schemas'

function PartForm({ onSubmit }: Props) {
const form = useForm({
defaultValues: {
itemNumber: '',
name: '',
description: '',
partType: '',
},
validators: {
onSubmit: zodValidator(partCreateSchema),
},
onSubmit: async ({ value }) => {
await onSubmit(value)
},
})

return (
<form onSubmit={(e) => { e.preventDefault(); form.handleSubmit() }}>
<form.Field name="itemNumber">
{(field) => (
<FormField
label="Item Number"
required
error={field.state.meta.errors?.[0] as string | undefined}
>
<Input
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
/>
</FormField>
)}
</form.Field>

<Button type="submit" disabled={form.state.isSubmitting}>
Save
</Button>
</form>
)
}

Getting Field Error Messages

Errors are strings, not objects. Cast them directly:

// CORRECT
error={field.state.meta.errors?.[0] as string | undefined}

// WRONG — .message does not exist
error={field.state.meta.errors?.[0]?.message}

Accessing Form State with useStore

form.useStore() does not exist. Import useStore and pass form.store:

import { useForm, useStore } from '@tanstack/react-form'

const form = useForm({ ... })

// CORRECT — import useStore and pass form.store
const partType = useStore(form.store, (state) => state.values.partType)

// WRONG — form.useStore() does not exist
const partType = form.useStore((state) => state.values.partType)

Helper Functions

src/lib/form-validation.ts exports additional helpers:

import { zodValidator, getFieldError, hasErrors } from '@/lib/form-validation'

// Get error for a specific field from the error array
const nameError = getFieldError(form.state.errors, 'name')

// Check if there are any validation errors
if (hasErrors(form.state.errors)) {
// Show error summary
}

DataGrid Component

DataGrid in src/components/ui/DataGrid.tsx wraps TanStack Table with sorting, filtering, pagination, global search, row expansion, and context menus.

Basic Usage

import { DataGrid } from '@/components/ui/DataGrid'
import type { DataGridColumn } from '@/components/ui/DataGrid'

interface Part {
id: string
itemNumber: string
name: string
state: string
}

const columns: Array<DataGridColumn<Part>> = [
{
id: 'itemNumber',
header: 'Item Number',
accessorKey: 'itemNumber',
enableSorting: true,
meta: { width: '150px' },
},
{
id: 'name',
header: 'Name',
accessorKey: 'name',
enableSorting: true,
meta: { width: '250px' },
},
{
id: 'state',
header: 'State',
accessorKey: 'state',
enableFiltering: true,
filterType: 'select',
filterOptions: [
{ label: 'Draft', value: 'Draft' },
{ label: 'Released', value: 'Released' },
],
},
{
id: 'actions',
header: '',
cell: ({ row }) => (
<Button size="sm" onClick={() => navigate(`/parts/${row.original.id}`)}>
View
</Button>
),
meta: { width: '80px' },
},
]

<DataGrid
data={parts}
columns={columns}
getRowId={(row) => row.id}
enablePagination
enableSorting
enableGlobalFilter
/>

Column Configuration

The DataGridColumn interface:

interface DataGridColumn<T> {
id: string
header: string
accessorKey?: keyof T | string // Simple field access
accessorFn?: (row: T) => unknown // Custom accessor
cell?: (props) => ReactNode // Custom cell renderer
enableSorting?: boolean
enableFiltering?: boolean
enableEditing?: boolean
filterType?: 'text' | 'select' | 'multiselect' | 'range' | 'date'
filterOptions?: Array<{ label: string; value: string }>
filterPlaceholder?: string
meta?: {
align?: 'left' | 'center' | 'right'
width?: string // CSS width value, e.g., '150px', '20%'
}
}

Column widths use meta.width as an inline style, not size/minSize/maxSize.

DataGrid Features

FeaturePropDescription
PaginationenablePaginationClient-side page controls
Server paginationserverPaginationServer-side with total count
SortingenableSortingColumn header sort
Global filterenableGlobalFilterFull-text search bar
Column filterenableFilteringPer-column filter popover
Row expansionenableHierarchyExpandable rows (tree/BOM)
Row actionsenableRowActionsContext menu on rows
Row clickonRowClickNavigate on row click

Controlled State

For URL-persisted state, pass controlled props:

const [sorting, setSorting] = useState<SortingState>([])
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 20 })

<DataGrid
data={parts}
columns={columns}
sorting={sorting}
onSortingChange={setSorting}
pagination={pagination}
onPaginationChange={setPagination}
enablePagination
enableSorting
/>

Shared Type Definitions

Export types from one source and import elsewhere. Do not duplicate interfaces:

// CORRECT — single source of truth
// In DesignPhaseIndicator.tsx
export interface DesignStatus { ... }

// In other files
import { type DesignStatus } from '@/components/versioning/DesignPhaseIndicator'

// WRONG — duplicating types
// In FormA.tsx
interface DesignStatus { ... }
// In FormB.tsx
interface DesignStatus { ... } // Can drift

Common Patterns

Loading States

import { Skeleton } from '@/components/ui/Skeleton'
import { LoadingSpinner } from '@/components/ui/LoadingSpinner'

// Skeleton for layout placeholders
<Skeleton className="h-8 w-48" />

// Spinner for async operations
<LoadingSpinner size="sm" />

Confirmation Dialogs

import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/AlertDialog'

<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive">Delete</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Part?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete}>Delete</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>

Status Badges

import { Badge } from '@/components/ui/Badge'

<Badge variant={state === 'Released' ? 'success' : 'default'}>
{state}
</Badge>

Common Pitfalls

Zod v4 + TanStack Form

Always use zodValidator() wrapper. Passing a Zod schema directly does not work:

// WRONG
validators: {
onSubmit: myZodSchema
}

// CORRECT
validators: {
onSubmit: zodValidator(myZodSchema)
}

Error Access

Errors are strings, not objects with .message:

// WRONG
error={field.state.meta.errors?.[0]?.message}

// CORRECT
error={field.state.meta.errors?.[0] as string | undefined}

useStore

form.useStore() does not exist:

// WRONG
const value = form.useStore((state) => state.values.fieldName)

// CORRECT
import { useStore } from '@tanstack/react-form'
const value = useStore(form.store, (state) => state.values.fieldName)

Server-Only Imports in Client Code

Importing database modules in client code causes build failures. Keep database imports in routes/api/, services, and *.server.ts files only. Use import type when you only need the type.

API Response Structure

API responses are wrapped in { data: { ... } }. When consuming from the client:

// CORRECT
const response = await fetch('/api/parts')
const json = await response.json()
const parts = json.data?.items ?? []

// WRONG — skipping the data wrapper
const parts = json.items // undefined