Write React frontend code following modern patterns. Use when creating features, adding routes, or writing components. Activates for phrases like "create a feature", "add a route", "new component", "frontend", or when working on React/TypeScript code with React Router.
Follow Bulletproof React's feature-based architecture:
src/
├── app/ # Application layer (routes, providers, router)
├── components/ # Shared components
├── config/ # Global configuration
├── features/ # Feature modules (primary organization)
├── hooks/ # Shared hooks
├── lib/ # Preconfigured libraries
├── types/ # Shared TypeScript types
└── utils/ # Shared utilities
Each feature is self-contained:
features/user-profile/
├── api/ # API requests and hooks
├── components/ # Feature-specific components
├── hooks/ # Feature-specific hooks
├── types/ # Feature types
└── utils/ # Feature utilities
Only include folders needed for the feature. Don't create empty folders.
kebab-case (e.g., user-profile/)PascalCase.tsx (e.g., UserCard.tsx)use-kebab-case.ts (e.g., use-user-data.ts)kebab-case.ts (e.g., user-types.ts)Avoid index.ts files that re-export everything. They hurt Vite tree-shaking.
// Bad - barrel file
export * from './UserCard';
export * from './UserList';
// Good - direct imports
import { UserCard } from '@/features/user-profile/components/UserCard';
React Router v7 route modules export three main pieces:
import type { Route } from "./+types/my-route";
// Server-side data loading
export async function loader({ params, request }: Route.LoaderArgs) {
const data = await fetchData(params.id);
return { data };
}
// Server-side mutations
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
await saveData(formData);
return { success: true };
}
// Component receives loader data as props
export default function MyRoute({ loaderData }: Route.ComponentProps) {
return <div>{loaderData.data.title}</div>;
}
For non-critical data, return promises without awaiting:
export async function loader({ params }: Route.LoaderArgs) {
// Critical - await this
const user = await getUser(params.id);
// Non-critical - don't await, stream later
const activity = getRecentActivity(params.id);
return { user, activity };
}
export default function Profile({ loaderData }: Route.ComponentProps) {
const { user, activity } = loaderData;
return (
<div>
<h1>{user.name}</h1>
<Suspense fallback={<ActivitySkeleton />}>
<Await resolve={activity}>
{(data) => <ActivityList items={data} />}
</Await>
</Suspense>
</div>
);
}
Handle route errors gracefully:
import { isRouteErrorResponse, useRouteError } from "react-router";
export function ErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
return <div>{error.status}: {error.statusText}</div>;
}
return <div>Something went wrong</div>;
}
Use flatRoutes from @react-router/fs-routes for file-based routing:
// app/routes.ts
import { type RouteConfig } from "@react-router/dev/routes";
import { flatRoutes } from "@react-router/fs-routes";
export default flatRoutes() satisfies RouteConfig;
| Filename | URL |
|---|---|
_index.tsx | / |
about.tsx | /about |
teams.tsx | /teams (layout) |
teams._index.tsx | /teams (index) |
teams.$id.tsx | /teams/:id |
teams_.$id.tsx | /teams/:id (no layout) |
Use $ prefix for dynamic parameters:
app/routes/
├── users.$userId.tsx → /users/:userId
├── posts.$postId.edit.tsx → /posts/:postId/edit
└── files.$.tsx → /files/* (splat)
Access params in loader:
export async function loader({ params }: Route.LoaderArgs) {
const user = await getUser(params.userId);
return { user };
}
Dots create nesting. The parent renders children via <Outlet />:
app/routes/
├── concerts.tsx → Layout for /concerts/*
├── concerts._index.tsx → /concerts
├── concerts.$city.tsx → /concerts/:city
└── concerts.trending.tsx → /concerts/trending
Underscore prefix creates layouts without URL segments:
app/routes/
├── _auth.tsx → Layout (no URL segment)
├── _auth.login.tsx → /login
└── _auth.register.tsx → /register
Always define explicit prop types:
type UserCardProps = {
user: User;
onSelect?: (id: string) => void;
variant?: 'compact' | 'full';
};
export function UserCard({ user, onSelect, variant = 'full' }: UserCardProps) {
return (
<div className="rounded-lg border p-4">
<h3 className="font-semibold">{user.name}</h3>
{variant === 'full' && <p className="text-gray-600">{user.email}</p>}
</div>
);
}
Use utility classes directly. Compose with template literals for conditionals:
type ButtonProps = {
variant?: 'primary' | 'secondary';
size?: 'sm' | 'md' | 'lg';
children: React.ReactNode;
} & React.ButtonHTMLAttributes<HTMLButtonElement>;
export function Button({
variant = 'primary',
size = 'md',
children,
className,
...props
}: ButtonProps) {
const baseStyles = 'rounded font-medium transition-colors';
const variants = {
primary: 'bg-blue-600 text-white hover:bg-blue-700',
secondary: 'bg-gray-200 text-gray-900 hover:bg-gray-300',
};
const sizes = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-base',
lg: 'px-6 py-3 text-lg',
};
return (
<button
className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${className ?? ''}`}
{...props}
>
{children}
</button>
);
}
Create skeleton components for loading states:
export function UserCardSkeleton() {
return (
<div className="animate-pulse rounded-lg border p-4">
<div className="h-5 w-32 rounded bg-gray-200" />
<div className="mt-2 h-4 w-48 rounded bg-gray-200" />
</div>
);
}
// Usage with Suspense
<Suspense fallback={<UserCardSkeleton />}>
<Await resolve={userData}>
{(user) => <UserCard user={user} />}
</Await>
</Suspense>
| Type | Location |
|---|---|
| Shared (used across features) | src/components/ |
| Feature-specific | src/features/[name]/components/ |
| Route-specific | Same file or folder as route |
Enforce unidirectional dependencies to keep features independent:
shared → features → app
| Module | Can Import From |
|---|---|
components/, hooks/, utils/, types/ | Each other (shared layer) |
features/* | Shared layer only |
app/ | Shared layer + features |
Features never import from other features
// Bad - cross-feature import
import { UserCard } from '@/features/users/components/UserCard';
// in features/dashboard/...
// Good - use shared components or lift to app layer
import { UserCard } from '@/components/UserCard';
Features never import from app
// Bad - importing from app layer
import { router } from '@/app/router';
// Good - pass dependencies via props or context
Compose features at app layer
// app/routes/dashboard.tsx
import { UserStats } from '@/features/users/components/UserStats';
import { RecentActivity } from '@/features/activity/components/RecentActivity';
export default function Dashboard() {
return (
<div>
<UserStats />
<RecentActivity />
</div>
);
}
Use eslint-plugin-import to enforce boundaries:
// .eslintrc.js