- name
- loom-react
- description
- Modern client-side React development patterns. Not for: a loom-hooks shell script or a stage's lifecycle state.
- triggers
- ["react","jsx","tsx","component","react hook","useState","useEffect","useContext","useReducer","useMemo","useCallback","useRef","props","render","virtual DOM","reconciliation","single page application","spa","react-router","jotai","vite","bun","oxc","oxlint","oxfmt","rolldown","react compiler","eslint","prettier","client-side routing","accessibility","a11y","[Truncated]"]
# React SPA Development
## Overview
Client-side React 19+ SPAs. Stack: **React Router v7** (routing/loaders), **Jotai** (atomic global state), **Vite** (build — Rolldown bundler, Oxc transforms and minifier, Lightning CSS), **oxlint** + **oxfmt** (lint/format — the Oxc replacements for ESLint and Prettier), **Bun** (package manager/runtime). NOT for SSR frameworks (Next.js/Remix) — those are out of scope.
The single densest section is **Expert Practices** at the end — read it first if you know React basics. The middle sections are reference implementations.
## React 19 Features
### Actions and useActionState
React 19 introduces Actions for handling async state transitions:
```typescript
import { useActionState } from 'react'
interface FormState {
message: string
error?: string
}
async function updateProfile(previousState: FormState, formData: FormData) {
const name = formData.get('name') as string
try {
await fetch('/api/profile', {
method: 'POST',
body: JSON.stringify({ name }),
})
return { message: 'Profile updated successfully' }
} catch (error) {
return { message: '', error: 'Update failed' }
}
}
export function ProfileForm() {
const [state, formAction, isPending] = useActionState(updateProfile, { message: '' })
return (
<form action={formAction}>
<input type="text" name="name" disabled={isPending} />
<button type="submit" disabled={isPending}>
{isPending ? 'Updating...' : 'Update Profile'}
</button>
{state.error && <p className="error">{state.error}</p>}
{state.message && <p className="success">{state.message}</p>}
</form>
)
}
```
### useOptimistic for Instant UI Updates
```typescript
import { useOptimistic, useState } from 'react'
interface Todo {
id: string
title: string
completed: boolean
}
export function TodoList({ todos }: { todos: Todo[] }) {
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
todos,
(state, newTodo: Todo) => [...state, newTodo]
)
async function addTodo(formData: FormData) {
const title = formData.get('title') as string
const tempTodo = { id: crypto.randomUUID(), title, completed: false }
addOptimisticTodo(tempTodo)
await fetch('/api/todos', {
method: 'POST',
body: JSON.stringify({ title }),
})
}
return (
<div>
<ul>
{optimisticTodos.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
<form action={addTodo}>
<input type="text" name="title" />
<button type="submit">Add Todo</button>
</form>
</div>
)
}
```
### use() for Reading Promises and Context
```typescript
import { use, Suspense } from 'react'
import { useLoaderData } from 'react-router'
interface User {
id: string
name: string
}
function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
// use() unwraps the promise. It cannot be wrapped in try/catch —
// a rejected promise surfaces at the nearest Error Boundary.
const user = use(userPromise)
return <div>{user.name}</div>
}
// CRITICAL: the Promise must be created OUTSIDE the render cycle. Promises
// created in client components are recreated on every render, so passing an
// inline fetchUser(userId) to use() re-suspends and re-fetches forever.
// In this React Router v7 SPA stack a route loader is the idiomatic stable
// source (one promise per navigation).
export function UserContainer() {
const userPromise = useLoaderData() as Promise<User>
return (
<Suspense fallback={<div>Loading user...</div>}>
<UserProfile userPromise={userPromise} />
</Suspense>
)
}
```
### Document Metadata
React 19 hoists `<title>`/`<meta>`/`<link>` rendered anywhere in the tree into `<head>` — no helper library needed. Just render them inside the component (e.g. a route page).
**Server/Client Components:** N/A here — a pure SPA has no server, so every component is a client component with full access to browser APIs, hooks, and event handlers. `'use client'`/RSC only matter under Next.js/Remix (out of scope).
## UI Component Patterns
### Design System Foundation
```typescript
// src/components/ui/Button.tsx
import { ComponentPropsWithoutRef } from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
const buttonVariants = cva(
'inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-primary text-white hover:bg-primary/90',
secondary: 'bg-secondary text-white hover:bg-secondary/90',
outline: 'border border-gray-300 bg-transparent hover:bg-gray-100',
ghost: 'hover:bg-gray-100',
danger: 'bg-red-600 text-white hover:bg-red-700',
},
size: {
sm: 'h-8 px-3 text-sm',
md: 'h-10 px-4',
lg: 'h-12 px-6 text-lg',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'md',
},
}
)
export interface ButtonProps
extends ComponentPropsWithoutRef<'button'>,
VariantProps<typeof buttonVariants> {
isLoading?: boolean
ref?: React.Ref<HTMLButtonElement>
}
// React 19: ref is a plain prop — no forwardRef wrapper, no displayName needed.
export function Button({
ref,
className,
variant,
size,
isLoading,
children,
...props
}: ButtonProps) {
return (
<button
ref={ref}
className={buttonVariants({ variant, size, className })}
disabled={isLoading || props.disabled}
{...props}
>
{isLoading ? (
<>
<svg className="animate-spin -ml-1 mr-2 h-4 w-4" />
Loading...
</>
) : (
children
)}
</button>
)
}
```
### Composition (Card slots)
Ship a family of thin primitives that forward `ref` (a plain prop in React 19) and `className`. Callers compose them; no prop explosion.
```typescript
type DivProps = ComponentPropsWithoutRef<'div'> & { ref?: React.Ref<HTMLDivElement> }
export function Card({ ref, className, ...props }: DivProps) {
return <div ref={ref} className={`rounded-lg border bg-white shadow-sm ${className}`} {...props} />
}
export function CardHeader({ ref, className, ...props }: DivProps) {
return <div ref={ref} className={`p-6 ${className}`} {...props} />
}
export function CardContent({ ref, className, ...props }: DivProps) {
return <div ref={ref} className={`p-6 pt-0 ${className}`} {...props} />
}
// <Card><CardHeader>…</CardHeader><CardContent>…</CardContent></Card>
```
### Polymorphic Components
```typescript
// src/components/ui/Text.tsx
import { ElementType, ComponentPropsWithoutRef } from 'react'
type TextProps<E extends ElementType> = {
as?: E
variant?: 'h1' | 'h2' | 'h3' | 'body' | 'small'
} & ComponentPropsWithoutRef<E>
export function Text<E extends ElementType = 'p'>({
as,
variant = 'body',
className,
...props
}: TextProps<E>) {
const Component = as || 'p'
const variantClasses = {
h1: 'text-4xl font-bold',
h2: 'text-3xl font-semibold',
h3: 'text-2xl font-semibold',
body: 'text-base',
small: 'text-sm text-gray-600',
}
return (
<Component
className={`${variantClasses[variant]} ${className || ''}`}
{...props}
/>
)
}
// Usage - flexible element types
<Text variant="h1">Heading</Text>
<Text as="h1" variant="h1">Heading with h1 tag</Text>
<Text as="span" variant="small">Small text in span</Text>
```
### Render Props / Function-as-Children
Generic state-branching component. `children: (data: T) => ReactNode`. In this stack prefer Suspense + async atoms/loaders for data; render props remain useful for non-Suspense state machines.
```typescript
export function DataLoader<T>({ data, isLoading, error, children }: {
data: T | null; isLoading: boolean; error: Error | null; children: (data: T) => ReactNode
}) {
if (isLoading) return <div>Loading…</div>
if (error) return <div>Error: {error.message}</div>
if (!data) return null
return <>{children(data)}</>
}
// <DataLoader {...useFetch<User[]>('/api/users')}>{(u) => …}</DataLoader>
```
## Project Setup
### Initial Setup with Bun and Vite
```bash
# Create new React app with Vite template (Vite runs on Rolldown + Oxc)
bun create vite my-app --template react-ts
cd my-app
# Install dependencies
bun install
# Add React Router and Jotai
bun add react-router jotai
# Add development dependencies: types, oxlint (lint), oxfmt (format)
bun add -D @types/react @types/react-dom oxlint oxfmt
bunx oxlint --init # .oxlintrc.json — then enable the React plugins (next section)
bunx oxfmt --init # .oxfmtrc.json
# If the template scaffolded ESLint, remove it — oxlint replaces it:
# delete eslint.config.js and `bun remove` every eslint*/typescript-eslint devDependency
# Start development server
bun run dev
```
### Lint and Format (oxlint + oxfmt)
oxlint and oxfmt are the Oxc-native replacements for ESLint and Prettier — same job, no plugin graph, no linter/formatter conflict config. Setting `plugins` in `.oxlintrc.json` REPLACES the default set (`eslint`, `typescript`, `unicorn`, `oxc`), so list the defaults you keep next to the React ones. Only the `correctness` category is on by default: `react/rules-of-hooks` is `pedantic`, so it stays off until you name it, and `react/exhaustive-deps` is `correctness` but only fires once `react` is in `plugins` — list both.
```json
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["eslint", "typescript", "unicorn", "oxc", "react", "jsx-a11y", "import", "vitest"],
"categories": { "correctness": "error", "suspicious": "warn" },
"rules": {
"react/rules-of-hooks": "error",
"react/exhaustive-deps": "error",
"react/jsx-key": "error",
"typescript/no-explicit-any": "error"
},
"env": { "browser": true },
"ignorePatterns": ["dist/**", "coverage/**"]
}
```
`package.json` scripts:
```json
{
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"test": "vitest run",
"typecheck": "tsc --noEmit",
"lint": "oxlint --deny-warnings",
"lint:fix": "oxlint --fix",
"format": "oxfmt",
"format:check": "oxfmt --check",
"check": "bun run typecheck && bun run lint && bun run format:check && bun run test"
}
}
```
- `oxfmt` is Prettier-compatible: `.oxfmtrc.json`, honors `.gitignore`/`.prettierignore`, default `printWidth` is 100 where Prettier's is 80 (`oxfmt --migrate=prettier` carries an existing Prettier config over). `oxfmt` writes in place; `oxfmt --check` gates CI.
- `oxlint --fix` applies safe fixes only; `--fix-suggestions`/`--fix-dangerously` may change behavior — review the diff.
- Leave the `react-perf` plugin off while the React Compiler is on: its rules flag the inline handlers and object props the compiler memoizes for you, and under `--deny-warnings` they fail the build.
Ver no GitHub