name: react description: Modern React SPA development with React Router v7, Jotai state management, and Vite tooling. Use for building single-page applications with client-side routing, global state, forms, and async data loading. Triggers: react, spa, single page application, react-router, jotai, vite, bun, client-side routing, react hooks, useState, useEffect.
React SPA Development
Overview
This skill provides guidance for building modern React Single Page Applications (SPAs) using:
- React 19+ for UI components and hooks
- React Router v7 for client-side routing and navigation
- Jotai for atomic global state management
- Vite for fast development and optimized builds
- Bun as the package manager and runtime
This skill focuses exclusively on client-side React applications, NOT server-side rendering frameworks like Next.js or Remix.
Project Setup
Initial Setup with Bun and Vite
# Create new React app with Vite template
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
bun add -D @types/react @types/react-dom
# Start development server
bun run dev
Vite Configuration
// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "path";
export default defineConfig({
plugins: [
react({
// Enable React Refresh for fast refresh during development
fastRefresh: true,
}),
],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
"@components": path.resolve(__dirname, "./src/components"),
"@hooks": path.resolve(__dirname, "./src/hooks"),
"@store": path.resolve(__dirname, "./src/store"),
"@utils": path.resolve(__dirname, "./src/utils"),
},
},
server: {
port: 3000,
open: true,
},
build: {
sourcemap: true,
rollupOptions: {
output: {
manualChunks: {
"react-vendor": ["react", "react-dom"],
router: ["react-router"],
state: ["jotai"],
},
},
},
},
});
TypeScript Configuration
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@components/*": ["./src/components/*"],
"@hooks/*": ["./src/hooks/*"],
"@store/*": ["./src/store/*"],
"@utils/*": ["./src/utils/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
React Router v7 Patterns
Router Setup with createBrowserRouter
// src/main.tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { RouterProvider, createBrowserRouter } from 'react-router'
import './index.css'
// Import route components
import { RootLayout } from './layouts/RootLayout'
import { HomePage } from './pages/HomePage'
import { AboutPage } from './pages/AboutPage'
import { UsersPage } from './pages/users/UsersPage'
import { UserDetailPage } from './pages/users/UserDetailPage'
import { ErrorPage } from './pages/ErrorPage'
import { NotFoundPage } from './pages/NotFoundPage'
// Create router with type-safe route definitions
const router = createBrowserRouter([
{
path: '/',
element: <RootLayout />,
errorElement: <ErrorPage />,
children: [
{
index: true,
element: <HomePage />,
},
{
path: 'about',
element: <AboutPage />,
},
{
path: 'users',
children: [
{
index: true,
element: <UsersPage />,
loader: async () => {
// Data loading for users list
const response = await fetch('/api/users')
return response.json()
},
},
{
path: ':userId',
element: <UserDetailPage />,
loader: async ({ params }) => {
// Data loading for specific user
const response = await fetch(`/api/users/${params.userId}`)
if (!response.ok) {
throw new Response('User not found', { status: 404 })
}
return response.json()
},
},
],
},
{
path: '*',
element: <NotFoundPage />,
},
],
},
])
createRoot(document.getElementById('root')!).render(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>
)
Root Layout with Outlet
// src/layouts/RootLayout.tsx
import { Outlet, Link, useNavigation } from 'react-router'
export function RootLayout() {
const navigation = useNavigation()
const isNavigating = navigation.state === 'loading'
return (
<div className="app">
<header>
<nav>
<Link to="/">Home</Link>
<Link to="/about">About</Link>
<Link to="/users">Users</Link>
</nav>
</header>
<main>
{isNavigating && <div className="loading-bar">Loading...</div>}
<Outlet />
</main>
<footer>
<p>© 2025 My App</p>
</footer>
</div>
)
}
Data Loading with Loaders
// src/pages/users/UsersPage.tsx
import { useLoaderData, Link } from 'react-router'
interface User {
id: string
name: string
email: string
}
export function UsersPage() {
// Type-safe loader data access
const users = useLoaderData() as User[]
return (
<div>
<h1>Users</h1>
<ul>
{users.map((user) => (
<li key={user.id}>
<Link to={`/users/${user.id}`}>
{user.name} ({user.email})
</Link>
</li>
))}
</ul>
</div>
)
}
Navigation Hooks
// src/components/UserForm.tsx
import { useNavigate, useSearchParams } from 'react-router'
import { useState } from 'react'
export function UserForm() {
const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
const [name, setName] = useState('')
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
// Create user
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
})
const user = await response.json()
// Navigate programmatically
navigate(`/users/${user.id}`)
}
const filter = searchParams.get('filter') || ''
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="User name"
/>
<input
type="text"
value={filter}
onChange={(e) => setSearchParams({ filter: e.target.value })}
placeholder="Filter"
/>
<button type="submit">Create User</button>
</form>
)
}
Protected Routes Pattern
// src/components/ProtectedRoute.tsx
import { Navigate, Outlet } from 'react-router'
import { useAtomValue } from 'jotai'
import { userAtom } from '@store/auth'
export function ProtectedRoute() {
const user = useAtomValue(userAtom)
if (!user) {
return <Navigate to="/login" replace />
}
return <Outlet />
}
// Usage in router configuration
const router = createBrowserRouter([
{
path: '/',
element: <RootLayout />,
children: [
{
path: 'dashboard',
element: <ProtectedRoute />,
children: [
{
index: true,
element: <DashboardPage />,
},
{
path: 'settings',
element: <SettingsPage />,
},
],
},
],
},
])
Jotai State Management
Basic Atoms
// src/store/counter.ts
import { atom } from 'jotai'
// Primitive atom
export const countAtom = atom(0)
// Read-only derived atom
export const doubledCountAtom = atom((get) => get(countAtom) * 2)
// Read-write derived atom
export const incrementAtom = atom(
(get) => get(countAtom),
(get, set) => set(countAtom, get(countAtom) + 1)
)
export const decrementAtom = atom(
null,
(get, set) => set(countAtom, get(countAtom) - 1)
)
// Usage in component
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
export function Counter() {
const [count, setCount] = useAtom(countAtom)
const doubled = useAtomValue(doubledCountAtom)
const increment = useSetAtom(incrementAtom)
return (
<div>
<p>Count: {count}</p>
<p>Doubled: {doubled}</p>
<button onClick={increment}>Increment</button>
<button onClick={() => setCount((c) => c - 1)}>Decrement</button>
</div>
)
}
Async Atoms
// src/store/users.ts
import { atom } from 'jotai'
import { atomWithStorage } from 'jotai/utils'
interface User {
id: string
name: string
email: string
}
// Async atom for fetching users
export const usersAtom = atom(async () => {
const response = await fetch('/api/users')
if (!response.ok) {
throw new Error('Failed to fetch users')
}
return response.json() as Promise<User[]>
})
// Atom with refresh capability
export const refreshUsersAtom = atom(0)
export const refreshableUsersAtom = atom(async (get) => {
get(refreshUsersAtom) // Dependency for refreshing
const response = await fetch('/api/users')
return response.json() as Promise<User[]>
})
// Usage in component
import { useAtomValue, useSetAtom } from 'jotai'
import { Suspense } from 'react'
function UsersList() {
const users = useAtomValue(usersAtom)
return (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)
}
export function UsersContainer() {
return (
<Suspense fallback={<div>Loading users...</div>}>
<UsersList />
</Suspense>
)
}
Atom Families
// src/store/todos.ts
import { atom } from 'jotai'
import { atomFamily } from 'jotai/utils'
interface Todo {
id: string
title: string
completed: boolean
}
// Base todos atom
export const todosAtom = atom<Todo[]>([])
// Atom family for individual todos
export const todoAtomFamily = atomFamily((id: string) =>
atom(
(get) => get(todosAtom).find((todo) => todo.id === id),
(get, set, update: Partial<Todo>) => {
const todos = get(todosAtom)
const index = todos.findIndex((todo) => todo.id === id)
if (index !== -1) {
const newTodos = [...todos]
newTodos[index] = { ...newTodos[index]!, ...update }
set(todosAtom, newTodos)
}
}
)
)
// Usage
function TodoItem({ id }: { id: string }) {
const [todo, updateTodo] = useAtom(todoAtomFamily(id))
if (!todo) return null
return (
<div>
<input
type="checkbox"
checked={todo.completed}
onChange={(e) => updateTodo({ completed: e.target.checked })}
/>
<span>{todo.title}</span>
</div>
)
}
Persistent Storage with atomWithStorage
// src/store/auth.ts
import { atom } from "jotai";
import { atomWithStorage } from "jotai/utils";
interface User {
id: string;
name: string;
email: string;
token: string;
}
// Persists to localStorage automatically
export const userAtom = atomWithStorage<User | null>("user", null);
export const isAuthenticatedAtom = atom((get) => {
const user = get(userAtom);
return user !== null;
});
// Login action
export const loginAtom = atom(
null,
async (get, set, credentials: { email: string; password: string }) => {
const response = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(credentials),
});
if (!response.ok) {
throw new Error("Login failed");
}
const user = await response.json();
set(userAtom, user);
return user;
},
);
// Logout action
export const logoutAtom = atom(null, (get, set) => {
set(userAtom, null);
});
Complex State with Atom Composition
// src/store/cart.ts
import { atom } from "jotai";
import { atomWithStorage } from "jotai/utils";
interface CartItem {
productId: string;
quantity: number;
price: number;
}
// Persisted cart items
export const cartItemsAtom = atomWithStorage<CartItem[]>("cart", []);
// Derived: total items count
export const cartCountAtom = atom((get) => {
const items = get(cartItemsAtom);
return items.reduce((sum, item) => sum + item.quantity, 0);
});
// Derived: total price
export const cartTotalAtom = atom((get) => {
const items = get(cartItemsAtom);
return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
});
// Action: add to cart
export const addToCartAtom = atom(
null,
(get, set, item: Omit<CartItem, "quantity"> & { quantity?: number }) => {
const items = get(cartItemsAtom);
const existingIndex = items.findIndex(
(i) => i.productId === item.productId,
);
if (existingIndex !== -1) {
const newItems = [...items];
const existing = newItems[existingIndex]!;
newItems[existingIndex] = {
...existing,
quantity: existing.quantity + (item.quantity ?? 1),
};
set(cartItemsAtom, newItems);
} else {
set(cartItemsAtom, [...items, { ...item, quantity: item.quantity ?? 1 }]);
}
},
);
// Action: remove from cart
export const removeFromCartAtom = atom(null, (get, set, productId: string) => {
const items = get(cartItemsAtom);
set(
cartItemsAtom,
items.filter((item) => item.productId !== productId),
);
});
// Action: clear cart
export const clearCartAtom = atom(null, (get, set) => {
set(cartItemsAtom, []);
});
Component Patterns
Functional Components with TypeScript
// src/components/Button.tsx
import { ComponentPropsWithoutRef, forwardRef } from 'react'
interface ButtonProps extends ComponentPropsWithoutRef<'button'> {
variant?: 'primary' | 'secondary' | 'danger'
size?: 'sm' | 'md' | 'lg'
isLoading?: boolean
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ variant = 'primary', size = 'md', isLoading, children, ...props }, ref) => {
return (
<button
ref={ref}
className={`btn btn-${variant} btn-${size}`}
disabled={isLoading || props.disabled}
{...props}
>
{isLoading ? 'Loading...' : children}
</button>
)
}
)
Button.displayName = 'Button'
Custom Hooks
// src/hooks/useDebounce.ts
import { useEffect, useState } from 'react'
export function useDebounce<T>(value: T, delay: number = 500): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value)
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value)
}, delay)
return () => {
clearTimeout(handler)
}
}, [value, delay])
return debouncedValue
}
// Usage
function SearchInput() {
const [search, setSearch] = useState('')
const debouncedSearch = useDebounce(search, 300)
useEffect(() => {
if (debouncedSearch) {
// Perform search
fetchResults(debouncedSearch)
}
}, [debouncedSearch])
return (
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
)
}
// src/hooks/useLocalStorage.ts
import { useState, useEffect } from "react";
export function useLocalStorage<T>(
key: string,
initialValue: T,
): [T, (value: T | ((val: T) => T)) => void] {
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.error(error);
return initialValue;
}
});
const setValue = (value: T | ((val: T) => T)) => {
try {
const valueToStore =
value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
console.error(error);
}
};
return [storedValue, setValue];
}
// src/hooks/useFetch.ts
import { useState, useEffect } from "react";
interface UseFetchResult<T> {
data: T | null;
error: Error | null;
isLoading: boolean;
refetch: () => void;
}
export function useFetch<T>(url: string): UseFetchResult<T> {
const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<Error | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [refetchIndex, setRefetchIndex] = useState(0);
useEffect(() => {
const controller = new AbortController();
const fetchData = async () => {
setIsLoading(true);
setError(null);
try {
const response = await fetch(url, { signal: controller.signal });
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const json = await response.json();
setData(json);
} catch (err) {
if (err instanceof Error && err.name !== "AbortError") {
setError(err);
}
} finally {
setIsLoading(false);
}
};
fetchData();
return () => {
controller.abort();
};
}, [url, refetchIndex]);
const refetch = () => setRefetchIndex((i) => i + 1);
return { data, error, isLoading, refetch };
}
Compound Components Pattern
// src/components/Tabs/Tabs.tsx
import {
createContext,
useContext,
useState,
ReactNode,
} from 'react'
interface TabsContextValue {
activeTab: string
setActiveTab: (id: string) => void
}
const TabsContext = createContext<TabsContextValue | undefined>(undefined)
function useTabs() {
const context = useContext(TabsContext)
if (!context) {
throw new Error('Tabs components must be used within <Tabs>')
}
return context
}
interface TabsProps {
defaultTab: string
children: ReactNode
}
export function Tabs({ defaultTab, children }: TabsProps) {
const [activeTab, setActiveTab] = useState(defaultTab)
return (
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
<div className="tabs">{children}</div>
</TabsContext.Provider>
)
}
interface TabListProps {
children: ReactNode
}
function TabList({ children }: TabListProps) {
return <div className="tab-list">{children}</div>
}
interface TabProps {
id: string
children: ReactNode
}
function Tab({ id, children }: TabProps) {
const { activeTab, setActiveTab } = useTabs()
return (
<button
className={`tab ${activeTab === id ? 'active' : ''}`}
onClick={() => setActiveTab(id)}
>
{children}
</button>
)
}
interface TabPanelsProps {
children: ReactNode
}
function TabPanels({ children }: TabPanelsProps) {
return <div className="tab-panels">{children}</div>
}
interface TabPanelProps {
id: string
children: ReactNode
}
function TabPanel({ id, children }: TabPanelProps) {
const { activeTab } = useTabs()
if (activeTab !== id) return null
return <div className="tab-panel">{children}</div>
}
// Export compound components
Tabs.TabList = TabList
Tabs.Tab = Tab
Tabs.TabPanels = TabPanels
Tabs.TabPanel = TabPanel
// Usage
export function Example() {
return (
<Tabs defaultTab="profile">
<Tabs.TabList>
<Tabs.Tab id="profile">Profile</Tabs.Tab>
<Tabs.Tab id="settings">Settings</Tabs.Tab>
<Tabs.Tab id="notifications">Notifications</Tabs.Tab>
</Tabs.TabList>
<Tabs.TabPanels>
<Tabs.TabPanel id="profile">
<h2>Profile Content</h2>
</Tabs.TabPanel>
<Tabs.TabPanel id="settings">
<h2>Settings Content</h2>
</Tabs.TabPanel>
<Tabs.TabPanel id="notifications">
<h2>Notifications Content</h2>
</Tabs.TabPanel>
</Tabs.TabPanels>
</Tabs>
)
}
Form Handling
Controlled Forms with Validation
// src/components/LoginForm.tsx
import { FormEvent, useState } from 'react'
import { useNavigate } from 'react-router'
import { useSetAtom } from 'jotai'
import { loginAtom } from '@store/auth'
interface FormErrors {
email?: string
password?: string
}
export function LoginForm() {
const navigate = useNavigate()
const login = useSetAtom(loginAtom)
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [errors, setErrors] = useState<FormErrors>({})
const [isSubmitting, setIsSubmitting] = useState(false)
const validate = (): boolean => {
const newErrors: FormErrors = {}
if (!email) {
newErrors.email = 'Email is required'
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
newErrors.email = 'Invalid email format'
}
if (!password) {
newErrors.password = 'Password is required'
} else if (password.length < 8) {
newErrors.password = 'Password must be at least 8 characters'
}
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
if (!validate()) {
return
}
setIsSubmitting(true)
try {
await login({ email, password })
navigate('/dashboard')
} catch (error) {
setErrors({
email: 'Invalid credentials',
})
} finally {
setIsSubmitting(false)
}
}
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
aria-invalid={!!errors.email}
aria-describedby={errors.email ? 'email-error' : undefined}
/>
{errors.email && (
<span id="email-error" role="alert">
{errors.email}
</span>
)}
</div>
<div>
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
aria-invalid={!!errors.password}
aria-describedby={errors.password ? 'password-error' : undefined}
/>
{errors.password && (
<span id="password-error" role="alert">
{errors.password}
</span>
)}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Logging in...' : 'Log In'}
</button>
</form>
)
}
Form with Custom Hook
// src/hooks/useForm.ts
import { useState, ChangeEvent, FormEvent } from 'react'
interface UseFormOptions<T> {
initialValues: T
validate?: (values: T) => Partial<Record<keyof T, string>>
onSubmit: (values: T) => void | Promise<void>
}
export function useForm<T extends Record<string, any>>({
initialValues,
validate,
onSubmit,
}: UseFormOptions<T>) {
const [values, setValues] = useState<T>(initialValues)
const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({})
const [isSubmitting, setIsSubmitting] = useState(false)
const handleChange = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target
setValues((prev) => ({ ...prev, [name]: value }))
// Clear error for this field
if (errors[name as keyof T]) {
setErrors((prev) => {
const newErrors = { ...prev }
delete newErrors[name as keyof T]
return newErrors
})
}
}
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
if (validate) {
const validationErrors = validate(values)
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors)
return
}
}
setIsSubmitting(true)
try {
await onSubmit(values)
} finally {
setIsSubmitting(false)
}
}
const reset = () => {
setValues(initialValues)
setErrors({})
setIsSubmitting(false)
}
return {
values,
errors,
isSubmitting,
handleChange,
handleSubmit,
reset,
setValues,
setErrors,
}
}
// Usage
interface ContactFormData {
name: string
email: string
message: string
}
export function ContactForm() {
const { values, errors, isSubmitting, handleChange, handleSubmit } =
useForm<ContactFormData>({
initialValues: {
name: '',
email: '',
message: '',
},
validate: (values) => {
const errors: Partial<Record<keyof ContactFormData, string>> = {}
if (!values.name) {
errors.name = 'Name is required'
}
if (!values.email) {
errors.email = 'Email is required'
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(values.email)) {
errors.email = 'Invalid email'
}
if (!values.message) {
errors.message = 'Message is required'
}
return errors
},
onSubmit: async (values) => {
await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(values),
})
},
})
return (
<form onSubmit={handleSubmit}>
<input
name="name"
value={values.name}
onChange={handleChange}
placeholder="Name"
/>
{errors.name && <span>{errors.name}</span>}
<input
name="email"
value={values.email}
onChange={handleChange}
placeholder="Email"
/>
{errors.email && <span>{errors.email}</span>}
<textarea
name="message"
value={values.message}
onChange={handleChange}
placeholder="Message"
/>
{errors.message && <span>{errors.message}</span>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Sending...' : 'Send'}
</button>
</form>
)
}
Best Practices
Component Organization
src/
├── components/ # Reusable UI components
│ ├── Button/
│ │ ├── Button.tsx
│ │ ├── Button.test.tsx
│ │ └── Button.module.css
│ └── Input/
├── pages/ # Route components
│ ├── HomePage.tsx
│ └── users/
│ ├── UsersPage.tsx
│ └── UserDetailPage.tsx
├── layouts/ # Layout components
│ └── RootLayout.tsx
├── hooks/ # Custom hooks
│ ├── useDebounce.ts
│ └── useForm.ts
├── store/ # Jotai atoms
│ ├── auth.ts
│ ├── cart.ts
│ └── users.ts
├── utils/ # Utility functions
│ └── api.ts
├── types/ # TypeScript types
│ └── index.ts
└── main.tsx # Entry point
Performance Optimization
import { memo, useMemo, useCallback, lazy, Suspense } from 'react'
// Memoize expensive components
export const ExpensiveList = memo(function ExpensiveList({
items,
}: {
items: Item[]
}) {
return (
<ul>
{items.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
)
})
// Memoize expensive calculations
function FilteredList({ items, filter }: { items: Item[]; filter: string }) {
const filteredItems = useMemo(() => {
return items.filter((item) => item.name.includes(filter))
}, [items, filter])
return <ExpensiveList items={filteredItems} />
}
// Memoize callbacks to prevent child re-renders
function Parent() {
const [count, setCount] = useState(0)
const handleClick = useCallback(() => {
setCount((c) => c + 1)
}, [])
return <Child onClick={handleClick} />
}
// Code splitting with lazy loading
const DashboardPage = lazy(() => import('./pages/DashboardPage'))
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<DashboardPage />
</Suspense>
)
}
Error Boundaries
// src/components/ErrorBoundary.tsx
import { Component, ReactNode } from 'react'
interface Props {
children: ReactNode
fallback?: (error: Error, reset: () => void) => ReactNode
}
interface State {
error: Error | null
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = { error: null }
}
static getDerivedStateFromError(error: Error): State {
return { error }
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('Error caught by boundary:', error, errorInfo)
}
reset = () => {
this.setState({ error: null })
}
render() {
if (this.state.error) {
if (this.props.fallback) {
return this.props.fallback(this.state.error, this.reset)
}
return (
<div role="alert">
<h2>Something went wrong</h2>
<pre>{this.state.error.message}</pre>
<button onClick={this.reset}>Try again</button>
</div>
)
}
return this.props.children
}
}
// Usage
function App() {
return (
<ErrorBoundary
fallback={(error, reset) => (
<div>
<h1>Error: {error.message}</h1>
<button onClick={reset}>Retry</button>
</div>
)}
>
<YourApp />
</ErrorBoundary>
)
}
Accessibility
// src/components/Modal.tsx
import { useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
interface ModalProps {
isOpen: boolean
onClose: () => void
title: string
children: React.ReactNode
}
export function Modal({ isOpen, onClose, title, children }: ModalProps) {
const dialogRef = useRef<HTMLDivElement>(null)
const previousActiveElement = useRef<HTMLElement | null>(null)
useEffect(() => {
if (isOpen) {
previousActiveElement.current = document.activeElement as HTMLElement
dialogRef.current?.focus()
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose()
}
}
document.addEventListener('keydown', handleEscape)
return () => {
document.removeEventListener('keydown', handleEscape)
previousActiveElement.current?.focus()
}
}
}, [isOpen, onClose])
if (!isOpen) return null
return createPortal(
<div
className="modal-overlay"
onClick={onClose}
role="presentation"
>
<div
ref={dialogRef}
className="modal"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
onClick={(e) => e.stopPropagation()}
tabIndex={-1}
>
<h2 id="modal-title">{title}</h2>
{children}
<button onClick={onClose} aria-label="Close modal">
Close
</button>
</div>
</div>,
document.body
)
}
Anti-Patterns
FORBIDDEN: Never Use These
// FORBIDDEN: Next.js (this is an SPA skill, not SSR)
// DO NOT use Next.js, App Router, Server Components from Next.js
// DO NOT use: next/navigation, next/router, next/link, etc.
// FORBIDDEN: Remix (this is an SPA skill)
// DO NOT use Remix framework
// FORBIDDEN: create-react-app
// ALWAYS use Vite for new projects
// CRA is deprecated and unmaintained
// FORBIDDEN: webpack directly
// ALWAYS use Vite as the bundler
// DO NOT create custom webpack configs
// FORBIDDEN: Redux (when Jotai can be used)
// DO NOT use Redux, Redux Toolkit, or React-Redux
// ONLY use Jotai for global state management
// Exception: Existing projects already using Redux
// FORBIDDEN: Context API for global state
// DO NOT use Context + useContext for application state
// Context is fine for component-level state (themes, etc.)
// Use Jotai atoms for all global application state
Common Mistakes to Avoid
// BAD: Mutation instead of immutability
const [items, setItems] = useState<Item[]>([])
items.push(newItem) // Direct mutation
setItems(items) // React won't detect the change
// GOOD: Immutable updates
setItems([...items, newItem])
setItems((prev) => [...prev, newItem])
// BAD: Missing dependency in useEffect
useEffect(() => {
fetchData(userId)
}, []) // userId not in deps
// GOOD: Include all dependencies
useEffect(() => {
fetchData(userId)
}, [userId])
// BAD: Derived state that should be computed
const [items, setItems] = useState<Item[]>([])
const [filteredItems, setFilteredItems] = useState<Item[]>([])
useEffect(() => {
setFilteredItems(items.filter(filter))
}, [items, filter])
// GOOD: Compute during render
const filteredItems = useMemo(
() => items.filter(filter),
[items, filter]
)
// BAD: Prop drilling through many levels
function App() {
const [user, setUser] = useState<User>()
return <Level1 user={user} setUser={setUser} />
}
// GOOD: Use Jotai for shared state
const userAtom = atom<User | null>(null)
function App() {
return <Level1 />
}
function DeepChild() {
const [user, setUser] = useAtom(userAtom)
// Direct access without prop drilling
}
// BAD: Creating functions in render
function Parent() {
return (
<Child
onClick={() => {
doSomething()
}}
/>
)
}
// GOOD: Use useCallback for stable references
function Parent() {
const handleClick = useCallback(() => {
doSomething()
}, [])
return <Child onClick={handleClick} />
}
// BAD: Fetching in components without Suspense
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
fetchUser(userId).then(setUser).finally(() => setLoading(false))
}, [userId])
if (loading) return <div>Loading...</div>
// ...
}
// GOOD: Use Suspense with async atoms
const userAtomFamily = atomFamily((userId: string) =>
atom(async () => {
const response = await fetch(`/api/users/${userId}`)
return response.json()
})
)
function UserProfile({ userId }: { userId: string }) {
const user = useAtomValue(userAtomFamily(userId))
return <div>{user.name}</div>
}
function UserContainer({ userId }: { userId: string }) {
return (
<Suspense fallback={<div>Loading...</div>}>
<UserProfile userId={userId} />
</Suspense>
)
}
DO NOT Use
- Next.js - Use for SSR projects, not SPAs
- Remix - Use for full-stack projects, not SPAs
- Redux - Use Jotai instead for simpler, more atomic state
- Context API for global state - Use Jotai atoms instead
- create-react-app - Deprecated, use Vite
- webpack - Use Vite bundler
- Class components - Use function components with hooks
- Default exports - Prefer named exports for better refactoring
Testing
Component Testing with Vitest
// vite.config.ts - Add test configuration
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
},
})
// src/test/setup.ts
import { expect, afterEach } from 'vitest'
import { cleanup } from '@testing-library/react'
import * as matchers from '@testing-library/jest-dom/matchers'
expect.extend(matchers)
afterEach(() => {
cleanup()
})
// src/components/Button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { Button } from './Button'
describe('Button', () => {
it('renders children correctly', () => {
render(<Button>Click me</Button>)
expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument()
})
it('calls onClick when clicked', () => {
const handleClick = vi.fn()
render(<Button onClick={handleClick}>Click me</Button>)
fireEvent.click(screen.getByRole('button'))
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('is disabled when isLoading is true', () => {
render(<Button isLoading>Click me</Button>)
expect(screen.getByRole('button')).toBeDisabled()
})
})
Testing with Jotai
// src/store/counter.test.ts
import { renderHook, act } from "@testing-library/react";
import { useAtom } from "jotai";
import { describe, it, expect } from "vitest";
import { countAtom, incrementAtom } from "./counter";
describe("counter atoms", () => {
it("increments count", () => {
const { result } = renderHook(() => ({
count: useAtom(countAtom),
increment: useAtom(incrementAtom),
}));
expect(result.current.count[0]).toBe(0);
act(() => {
result.current.increment[1]();
});
expect(result.current.count[0]).toBe(1);
});
});
This React skill provides comprehensive guidance for building modern SPAs with React Router, Jotai, and Vite, while explicitly avoiding Next.js, Redux, and other tools that don't fit the SPA paradigm.