Cursor Rules for React: The Complete Guide to AI-Assisted React Development
React is the framework where "it renders" hides the longest lie. The component mounts, the screen looks fine, and nothing in the build pipeline tells you the parent re-renders the whole tree on every keystroke, that a useEffect is fetching twice in dev and you "fixed" it by removing a dependency, or that the context you reached for so a leaf could read a flag is re-rendering forty siblings every time a token refreshes. The app works. A profiler trace six months later reveals that half your tree mounts whenever the URL changes and three "memoized" callbacks change reference on every render anyway.
Then you add an AI assistant.
Cursor and Claude Code were trained on a planet's worth of React. Most of it is class components, half of it predates hooks, and a lot of the modern half learned from blog posts that wrap every callback in useCallback "for performance" without measuring. Ask for "a form that loads a user and lets you edit them," and you get a 250-line component with five useStates, a useEffect with no AbortController, props typed as any, a useMemo around a string concatenation, and a context provider three layers up because it was easier than threading the prop. The code renders. It's not the React you would ship.
The fix is .cursorrules — one file in the repo that tells the AI what idiomatic modern React looks like. Eight rules below, each with the failure mode, the rule, and a before/after. Copy-paste .cursorrules at the end.
How Cursor Rules Work for React Projects
Cursor reads project rules from two locations: .cursorrules (a single file at the repo root, still supported) and .cursor/rules/*.mdc (modular files with frontmatter, recommended for anything bigger than an SPA). For React I recommend modular rules so a Next.js app's server-component conventions don't bleed into a Vite SPA's client-only patterns in the same monorepo:
.cursor/rules/
react-core.mdc # composition, props, file layout
react-hooks.mdc # useEffect, useMemo, useCallback discipline
react-state.mdc # local vs global, server state, context scope
react-perf.mdc # memo, render scope, list keys
react-testing.mdc # RTL, behavior-first
Frontmatter controls activation: globs: ["**/*.tsx", "**/*.jsx"] with alwaysApply: false. Now the rules.
Rule 1: Composition Over Configuration — Children and Slots, Not Boolean Props
The most common AI failure in React is the boolean explosion. Cursor sees Card used in three places and "generalizes" it with hasHeader, iconLeft, iconRight, variant, size, and an actions array. Six months later that Card has thirty props, four of them mutually exclusive, and every change to one consumer requires touching the shared component.
The rule:
Prefer composition over configuration. Reusable components accept
children and named ReactNode slots (header, footer, actions) — not
a growing list of boolean toggles, icon names, or content arrays.
Boolean props are reserved for genuine state (disabled, loading,
selected). Never to toggle whether a sub-element renders.
For polymorphic wrappers, use the `as` prop or render-prop pattern —
never parallel `linkTo` / `buttonHref` props that do the same job.
Before — boolean explosion:
interface CardProps {
title: string;
hasHeader?: boolean;
hasFooter?: boolean;
iconLeft?: 'user' | 'cart' | 'star';
actions?: { label: string; onClick: () => void; variant: string }[];
variant?: 'default' | 'bordered' | 'elevated';
collapsible?: boolean;
}
Every consumer adds another flag. The component grows forever.
After — composition with slots:
interface CardProps {
header?: React.ReactNode;
footer?: React.ReactNode;
children: React.ReactNode;
}
function Card({ header, footer, children }: CardProps) {
return (
<section className="rounded-lg border bg-white shadow-sm">
{header && <header className="border-b p-4">{header}</header>}
<div className="p-4">{children}</div>
{footer && <footer className="border-t p-4">{footer}</footer>}
</section>
);
}
<Card header={<h3><UserIcon /> Profile</h3>} footer={<Button onClick={save}>Save</Button>}>
<ProfileForm user={user} />
</Card>
Ten lines, never needs to change. Icons, variants, and actions live at the call site.
Rule 2: Hook Discipline — No Conditional Calls, Custom Hooks for Shared Logic
A typical AI-generated component has five useState calls that should be one useReducer, two useEffects that fight each other, a useMemo around a tuple literal, and a "custom hook" that thin-wraps useState. The Rules of Hooks (no conditionals, top of the function) get violated the moment Cursor tries to be clever with an early return.
The rule:
Hooks called unconditionally at the top — never inside if/else, loops,
try/catch, or after an early return. Lint react-hooks/rules-of-hooks
as error.
Group correlated state in one useReducer (loading + data + error).
Don't fan out useState calls that always change together.
Extract any hook combo used in 2+ components into a custom useX.
Custom hooks return a stable object or 2-tuple.
useEffect synchronizes with external systems (network, DOM, subscriptions).
Never use it to compute derived state — derive inline or via useMemo.
Before — derived state in an effect, early return that breaks hook order:
function UserCard({ userId }: { userId: string | null }) {
if (!userId) return null;
const [user, setUser] = useState<User | null>(null);
const [fullName, setFullName] = useState('');
const [initials, setInitials] = useState('');
useEffect(() => {
if (user) {
setFullName(`${user.first} ${user.last}`);
setInitials(`${user.first[0]}${user.last[0]}`);
}
}, [user]);
useEffect(() => { fetchUser(userId).then(setUser); }, [userId]);
return <div>{fullName} — {initials}</div>;
}
The early return makes hook order conditional — React crashes on the next render where userId toggles. fullName and initials are derived state that should never have hit useState.
After — custom hook, no derived state, hooks at the top:
function useUser(userId: string | null) {
const [state, dispatch] = useReducer(userReducer, { status: 'idle', user: null });
useEffect(() => {
if (!userId) return;
const ctrl = new AbortController();
dispatch({ type: 'fetch' });
fetchUser(userId, { signal: ctrl.signal })
.then(user => dispatch({ type: 'success', user }))
.catch(err => { if (err.name !== 'AbortError') dispatch({ type: 'error', error: err.message }); });
return () => ctrl.abort();
}, [userId]);
return state;
}
function UserCard({ userId }: { userId: string | null }) {
const state = useUser(userId);
if (state.status !== 'success') return null;
const { user } = state;
return <div>{user.first} {user.last} — {user.first[0]}{user.last[0]}</div>;
}
Hooks always run. Derived values are derived. The fetch is cancelled when userId changes. The hook is reusable.
Rule 3: State at the Lowest Common Ancestor — Server State Belongs in a Query Cache
Cursor defaults to lifting every piece of state to the top of the tree "in case anyone needs it later," then reaches for a global store for state that two siblings share. The result: a top-level component that re-renders on every keystroke in a search box six layers down, and a Redux slice holding server data Cursor will never invalidate properly.
Two distinctions to encode: local vs lifted (lift only as far as the lowest common ancestor) and client vs server state (server state goes in a query library, not a store).
The rule:
State lives at the lowest common ancestor of components that read it.
Don't lift "in case." Don't put state in a global store unless it is
genuinely cross-cutting (auth, theme, feature flags).
Server state — anything fetched from a network — goes in a query
library (TanStack Query, SWR, RTK Query). Never useState + useEffect
for new fetches. Never duplicated into a global client store.
Form state stays local. URL state (filters, pagination, tabs) belongs
in the URL via the router. Start local; lift only when a second
consumer appears.
Before — global store for server data, lifted state nobody else reads:
const useStore = create<Store>(set => ({
user: null, posts: [], searchQuery: '',
setUser: u => set({ user: u }),
setPosts: p => set({ posts: p }),
setSearchQuery: q => set({ searchQuery: q }),
}));
function App() {
const setUser = useStore(s => s.setUser);
useEffect(() => { fetchUser().then(setUser); }, []);
return <Layout><SearchBar /><PostList /></Layout>;
}
function SearchBar() {
const q = useStore(s => s.searchQuery);
return <input value={q} onChange={e => useStore.getState().setSearchQuery(e.target.value)} />;
}
Search is read by exactly one component. Posts and user are server state with no caching, no invalidation.
After — server state in a query, search local, URL for filters:
function PostList() {
const [search, setSearch] = useState('');
const [params] = useSearchParams();
const tag = params.get('tag') ?? undefined;
const { data, isLoading } = useQuery({
queryKey: ['posts', tag], queryFn: () => fetchPosts({ tag }),
});
const visible = (data ?? []).filter(p =>
p.title.toLowerCase().includes(search.toLowerCase()));
if (isLoading) return <Skeleton />;
return (<>
<input value={search} onChange={e => setSearch(e.target.value)} placeholder="Search…" />
<ul>{visible.map(p => <li key={p.id}>{p.title}</li>)}</ul>
</>);
}
The query cache handles refetching. search is local — typing doesn't re-render the rest of the app. tag lives in the URL so the back button works.
Rule 4: Memoize for Reasons, Not Reflexes
The advice "wrap every function in useCallback for performance" is the most common bad advice in Cursor's training data. The inverse is true: every memoization adds a dependency array, an allocation, and a bug surface. You memoize when a stable reference is required by a downstream memo boundary or a hook dependency that would otherwise loop — not as a reflex.
The rule:
useMemo / useCallback exist for one reason: keeping a reference stable
so a downstream React.memo'd child or a hook dep array doesn't see
"new" inputs every render. They are not free.
useMemo only when computation is measured-expensive OR the result is
passed to a memo'd child / hook dep array.
useCallback only when the callback is passed to a memo'd child, a hook
dep array, or a profiled re-rendering subtree.
React.memo for leaf components in big lists, or expensive renders under
a parent that re-renders for unrelated reasons. Don't wrap every
component in memo by default.
Never useCallback inside useMemo. Never useMemo around a primitive
literal or single-key object.
Before — useMemo around literals, useCallback for handlers nothing memoized consumes:
function Toolbar({ onSave, onCancel }: Props) {
const buttonStyle = useMemo(() => ({ padding: 8 }), []);
const title = useMemo(() => 'Toolbar', []);
const handleSave = useCallback(() => { onSave(); }, [onSave]);
const handleCancel = useCallback(() => { onCancel(); }, [onCancel]);
return (
<div style={buttonStyle}>
<h2>{title}</h2>
<button onClick={handleSave}>Save</button>
<button onClick={handleCancel}>Cancel</button>
</div>
);
}
Five hook calls, zero benefit. handleSave thin-wraps onSave for nothing.
After — memoize where it matters:
const RowItem = memo(function RowItem({
row, onSelect,
}: { row: Row; onSelect: (id: string) => void }) {
return <li onClick={() => onSelect(row.id)}>{row.name}</li>;
});
function RowList({ rows, onSelect }: { rows: Row[]; onSelect: (id: string) => void }) {
// Stable reference required because RowItem is memoized.
const handleSelect = useCallback((id: string) => onSelect(id), [onSelect]);
return <ul>{rows.map(r => <RowItem key={r.id} row={r} onSelect={handleSelect} />)}</ul>;
}
function Toolbar({ onSave, onCancel }: Props) {
return (
<div className="p-2">
<button onClick={onSave}>Save</button>
<button onClick={onCancel}>Cancel</button>
</div>
);
}
RowItem is memo'd because it's a leaf in a long list; handleSelect is useCallback'd so it doesn't re-render every row. Toolbar has zero memoization because nothing downstream needs it.
Rule 5: TypeScript Props — Discriminated Unions, ReactNode Over JSX.Element, Never React.FC
The default Cursor output for typed props is a flat interface where everything is optional and behavior depends on which combination you pass. That is the type system telling you "this should be a discriminated union." React.FC adds an implicit children you may not want. JSX.Element rejects strings, numbers, and arrays — three valid React children.
The rule:
Props typed with `interface` or `type`. Optionality reflects real
optionality. If behavior diverges by prop combo, model as a
discriminated union with a literal `kind` field — consumers get
exhaustive checking.
children: React.ReactNode. Single rendered element: React.ReactElement.
Render prop: (args) => React.ReactNode. Never JSX.Element for a prop
that could be a string, number, or array.
Never React.FC — implicit children, breaks generics. Type the function
directly: function Foo(props: FooProps) { ... }
forwardRef<Element, Props> with explicit element type.
Before — flat interface, mutually exclusive optionals, React.FC:
interface ButtonProps {
label: string;
onClick?: () => void;
href?: string;
to?: string;
loading?: boolean;
icon?: JSX.Element;
}
const Button: React.FC<ButtonProps> = ({ label, onClick, href, to, loading, icon }) => {
if (href) return <a href={href}>{icon}{label}</a>;
if (to) return <Link to={to}>{icon}{label}</Link>;
return <button onClick={onClick} disabled={loading}>{icon}{label}</button>;
};
Three "kinds" of button squeezed through one type. The compiler does not stop you from passing both href and to.
After — discriminated union, no React.FC, ReactNode for icon:
type ButtonProps =
| { kind: 'button'; label: string; onClick: () => void; loading?: boolean; icon?: React.ReactNode }
| { kind: 'link'; label: string; href: string; icon?: React.ReactNode }
| { kind: 'route'; label: string; to: string; icon?: React.ReactNode };
function Button(props: ButtonProps) {
const inner = <>{props.icon}{props.label}</>;
switch (props.kind) {
case 'link': return <a href={props.href}>{inner}</a>;
case 'route': return <Link to={props.to}>{inner}</Link>;
case 'button': return <button onClick={props.onClick} disabled={props.loading}>{inner}</button>;
}
}
The compiler enforces exhaustiveness. You cannot pass both href and to. icon accepts a string, an SVG, or a fragment.
Rule 6: Test Behavior, Not Implementation — RTL Queries by Role, No Snapshot Spam
Cursor's default React tests are snapshots and enzyme-style implementation tests: expect(component.state.count).toBe(1), expect(wrapper.find('Button').props().onClick), and a thousand-line __snapshots__ directory that fails on every CSS change. None catch a real bug. They catch refactors.
The rule:
Tests use React Testing Library and target user behavior. Queries by
role first (getByRole), then label, then text. getByTestId is a last
resort with a comment.
Never test internals: no shallow rendering, no inspecting state, no
checking which props a child received. Render, interact like a user,
assert on the DOM.
No snapshot tests for components — they fail on cosmetic changes
nobody reads. Snapshots only for stable serialized data (API
contracts, config exports).
Mock at the network boundary (MSW) — never mock a hook the component
calls, never mock a child component. If a test only passes when half
the tree is mocked, you're testing the mock.
Async assertions use findBy / waitFor — never setTimeout.
Before — implementation snapshot, mocking the component's own hook:
jest.mock('./useUser', () => ({
useUser: () => ({ data: { name: 'Ada' }, isLoading: false }),
}));
test('UserCard renders', () => {
const tree = renderer.create(<UserCard userId="1" />).toJSON();
expect(tree).toMatchSnapshot();
});
Snapshot fails on CSS changes. The mock means the test passes even if useUser is broken.
After — RTL, MSW for network, behavior assertions:
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
const server = setupServer(
http.get('/api/users/:id', () =>
HttpResponse.json({ id: '1', first: 'Ada', last: 'Lovelace' })),
);
beforeAll(() => server.listen());
afterAll(() => server.close());
test('shows the user name once loaded and saves edits', async () => {
render(<UserCard userId="1" />);
expect(await screen.findByRole('heading', { name: /ada lovelace/i })).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: /edit/i }));
await userEvent.clear(screen.getByLabelText(/first name/i));
await userEvent.type(screen.getByLabelText(/first name/i), 'Grace');
await userEvent.click(screen.getByRole('button', { name: /save/i }));
expect(await screen.findByRole('status')).toHaveTextContent(/saved/i);
});
Reads like a user story. Refactoring internals — extract a hook, rename state — does not break it. Breaking the network contract does.
Rule 7: Kill Prop Drilling With Composition First, Context Second
When Cursor sees a prop threaded three layers deep, its instinct is "wrap it in a context." Two months later you have UserContext, ThemeContext, ToastContext, FeatureFlagContext, SettingsContext — and every one re-renders its entire subtree every time any value inside changes. Composition (passing the rendered child as children so the parent never sees the prop) usually beats both prop drilling and context.
The rule:
Two layers of prop drilling is fine. Three: try component composition
first — have the leaf rendered by the consumer and passed as
`children`, so intermediate components never see the prop.
Reach for Context only when a value is genuinely cross-cutting and
read by many leaves at unrelated depths (auth, theme, feature flags,
i18n, router state). Don't create a Context for a value used by two
siblings — lift the state.
Split Context per independently-changing concern. A single
{ user, theme, toasts } object re-renders every consumer when any
field changes.
For high-frequency updates (mouse position, animation frames) don't
use Context — use ref subscriptions or a selector-based store
(Zustand, Jotai, Valtio).
Before — drilling, then a context that re-renders the world:
function App({ user }) { return <Layout user={user} />; }
function Layout({ user }) { return <Sidebar user={user} />; }
function Sidebar({ user }) { return <UserMenu user={user} />; }
const AppContext = createContext<{
user: User; setUser: (u: User) => void;
theme: Theme; setTheme: (t: Theme) => void;
toasts: Toast[]; pushToast: (t: Toast) => void;
}>(null!);
Toast updates re-render every consumer of useAppContext, including the user menu that doesn't care.
After — composition where it fits, narrowly-scoped context where it doesn't:
function App({ user }: { user: User }) {
return (
<Layout sidebar={<Sidebar><UserMenu user={user} /></Sidebar>}>
<Routes />
</Layout>
);
}
function Layout({ sidebar, children }: { sidebar: React.ReactNode; children: React.ReactNode }) {
return <div className="grid grid-cols-[240px_1fr]">{sidebar}<main>{children}</main></div>;
}
const AuthUserContext = createContext<User | null>(null);
const ThemeContext = createContext<Theme>('light');
Layout and Sidebar no longer need the user prop. Auth and theme are split, so a theme toggle doesn't re-render every consumer of the auth user.
Rule 8: Lists, Keys, and Controlled Inputs — The Three Bugs Cursor Ships Most
Three bugs appear in Cursor-generated React more than any others: key={index} on a list whose items can reorder, an <input> with value= and no onChange (generating React's "controlled to uncontrolled" warning nobody investigates), and lifting controlled input state into a parent two layers up, re-rendering an entire form on every character.
The rule:
Keys: every list element has a stable key derived from data — never
the array index unless the list is provably append-only. If items
have no natural id, generate one (crypto.randomUUID() at creation,
not on every render).
Controlled inputs: every <input>/<textarea>/<select> with a `value`
prop has an `onChange`. Default to '' is fine; default to undefined
silently switches the input to uncontrolled.
Prefer uncontrolled (defaultValue + ref or react-hook-form) when you
only need the value on submit. Controlled is for inputs whose value
drives other UI on every keystroke (live search, character count,
validation hints).
Never use `index` as a key in a list with checkboxes, drag-and-drop,
or animations — DOM state binds to keys, not rendered position.
Before — index keys, omitted onChange, controlled form lifted to parent:
function TodoForm({ todos, setTodos }: Props) {
const [draft, setDraft] = useState({ title: '', priority: 'low' });
return (<>
<input value={draft.title} onChange={e => setDraft({ ...draft, title: e.target.value })} />
<input value={draft.priority} />
<button onClick={() => setTodos([...todos, draft])}>Add</button>
<ul>{todos.map((t, i) => <li key={i}><input type="checkbox" /> {t.title}</li>)}</ul>
</>);
}
draft re-renders the whole form on every keystroke. The priority input is controlled with no onChange — React warns, then drops the value. Sorting or removing a todo unticks the wrong checkbox because the key is the index.
After — uncontrolled form, stable ids, keys that match identity:
type Todo = { id: string; title: string; priority: 'low' | 'high'; done: boolean };
function TodoForm({ onAdd }: { onAdd: (t: Todo) => void }) {
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const data = new FormData(e.currentTarget);
onAdd({
id: crypto.randomUUID(),
title: String(data.get('title') ?? ''),
priority: (data.get('priority') as 'low' | 'high') ?? 'low',
done: false,
});
e.currentTarget.reset();
}
return (
<form onSubmit={handleSubmit}>
<input name="title" required />
<select name="priority" defaultValue="low"><option>low</option><option>high</option></select>
<button type="submit">Add</button>
</form>
);
}
function TodoList({ todos, onToggle }: { todos: Todo[]; onToggle: (id: string) => void }) {
return (
<ul>{todos.map(t => (
<li key={t.id}>
<input type="checkbox" checked={t.done} onChange={() => onToggle(t.id)} /> {t.title}
</li>
))}</ul>
);
}
The form re-renders zero times during input. Keys are stable. Reordering or removing a todo never moves a checkbox state to the wrong row.
The Complete .cursorrules File
Drop this in the repo root. Cursor and Claude Code both pick it up.
# React — Production Patterns
## Composition
- children + named ReactNode slots over boolean toggles and content
arrays. Booleans only for real state (disabled, loading, selected).
- Polymorphic wrappers use `as` or render-prop, never parallel props.
## Hooks
- Hooks called unconditionally at the top. Lint
react-hooks/rules-of-hooks as error.
- Group correlated state in useReducer; don't fan out useState calls.
- Extract any hook combo used 2+ times into a custom useX returning a
stable object/2-tuple.
- useEffect synchronizes with external systems only. Derived state is
computed inline or via useMemo.
## State Scope
- State at the lowest common ancestor. Lift only when a second consumer
appears.
- Server state in TanStack Query / SWR. Never useState + useEffect for
new fetches. Never duplicated into a global client store.
- Form state local. URL state in the URL via the router.
## Memoization
- useMemo / useCallback only when downstream React.memo or a hook dep
array requires a stable reference, or computation is measured-
expensive. Never reflexively.
- React.memo for leaf components in big lists.
- Never useCallback inside useMemo. Never useMemo around primitive
literals or single-key objects.
## Props & Types
- Discriminated union with literal `kind` when behavior diverges.
- children: React.ReactNode. Never JSX.Element for string-or-array
children. No React.FC.
- forwardRef<Element, Props> with explicit element type.
## Testing
- React Testing Library only. Query by role > label > text > testid.
- No shallow render, no state inspection, no child-prop checks.
- Mock at the network boundary (MSW). Never mock the component's own
hooks or children.
- No component snapshots. Async waits via findBy / waitFor.
## Context & Drilling
- Two layers of drilling is fine. Three: try composition before Context.
- Context for genuinely cross-cutting values (auth, theme, flags, i18n).
- Split Context per independently-changing concern. Never omnibus.
- High-frequency updates: ref subscriptions or a selector-based store.
## Lists, Keys, Inputs
- Stable data-derived keys; never index keys when items can reorder.
- Generate ids at creation (crypto.randomUUID()).
- Every controlled input has both `value` and `onChange`. Default to ''.
- Prefer uncontrolled (defaultValue + FormData) when the parent doesn't
need the value between keystrokes.
End-to-End Example: A Search Box Filtering a List of Users
Without rules: global store for local state, an effect with no dep array, key={i}, useMemo around a microsecond filter.
const useStore = create<any>(set => ({
q: '', users: [], setQ: q => set({ q }), setUsers: u => set({ users: u }),
}));
function App() {
const { q, users, setQ, setUsers } = useStore();
useEffect(() => { fetch('/api/users').then(r => r.json()).then(setUsers); });
const filtered = useMemo(() => users.filter(u => u.name.includes(q)), [users, q]);
return (<>
<input value={q} onChange={e => setQ(e.target.value)} />
<ul>{filtered.map((u, i) => <li key={i}>{u.name}</li>)}</ul>
</>);
}
With rules: local state, query cache, stable keys, no premature memoization, accessible label.
function UserSearch() {
const [q, setQ] = useState('');
const { data: users = [], isLoading } = useQuery({
queryKey: ['users'], queryFn: fetchUsers,
});
const visible = q
? users.filter(u => u.name.toLowerCase().includes(q.toLowerCase()))
: users;
if (isLoading) return <Skeleton />;
return (<>
<input value={q} onChange={e => setQ(e.target.value)} aria-label="Search users" />
<ul>{visible.map(u => <li key={u.id}>{u.name}</li>)}</ul>
</>);
}
Get the Full Pack
These eight rules cover the React patterns where AI assistants consistently reach for the wrong idiom. Drop them into .cursorrules and the next prompt you write will look different — composed, hook-disciplined, scoped state, surgical memoization, properly-typed props, behavior-tested React, without having to re-prompt.
If you want the expanded pack — these eight plus rules for Next.js (App Router, Server Components, route handlers), Suspense and streaming, Server Actions, error boundaries, accessibility patterns, and the React Query conventions I use on production apps — it is bundled in Cursor Rules Pack v2 ($27, one payment, lifetime updates). Drop it in your repo, stop fighting your AI, ship React you would actually merge.
Top comments (0)