.continue/checks/react-best-practices.md
You are an agent responsible for evaluating a PR for adherence to the React best practices outlined below. Your goal is to identify any poor practices. If you find a poor practice, address it with a code change. Keep all of your changes constrained to a single commit. In the PR description that you create, be sure to reference the relevant best practice when describing a change that you make. If you do not find any poor practices, do not open a PR.
IMPORTANT: Check the PR diff and only suggest changes adjacent to/within the scope of the PR. Do not just randomly search through the codebase for potential improvements.
<react-best-practices>Below is a comprehensive performance optimization guide for React and Next.js applications, prioritized by impact from critical to incremental. Each rule includes detailed explanations, real-world examples comparing incorrect vs. correct implementations, and specific impact metrics to guide automated refactoring and code generation.
Note: If your project has React Compiler enabled, manual memoization (
memo(),useMemo(),useCallback()) and static JSX hoisting are handled automatically.
Waterfalls are the #1 performance killer. Each sequential await adds full network latency.
Move await into branches where data is actually used.
// ❌ Bad: blocks both branches
async function handleRequest(userId: string, skip: boolean) {
const userData = await fetchUserData(userId);
if (skip) return { skipped: true };
return processUserData(userData);
}
// ✅ Good: fetch only when needed
async function handleRequest(userId: string, skip: boolean) {
if (skip) return { skipped: true };
const userData = await fetchUserData(userId);
return processUserData(userData);
}
Use better-all to maximize parallelism when operations have partial dependencies.
// ❌ Bad: profile waits for config unnecessarily
const [user, config] = await Promise.all([fetchUser(), fetchConfig()]);
const profile = await fetchProfile(user.id);
// ✅ Good: config and profile run in parallel
import { all } from "better-all";
const { user, config, profile } = await all({
async user() {
return fetchUser();
},
async config() {
return fetchConfig();
},
async profile() {
return fetchProfile((await this.$.user).id);
},
});
Start independent operations immediately with Promise.all() or by creating promises early.
// ❌ Bad: sequential execution, 3 round trips
const user = await fetchUser();
const posts = await fetchPosts();
const comments = await fetchComments();
// ✅ Good: parallel execution, 1 round trip
const [user, posts, comments] = await Promise.all([
fetchUser(),
fetchPosts(),
fetchComments(),
]);
// ✅ Good: in API routes, start promises early
export async function GET(request: Request) {
const sessionPromise = auth();
const configPromise = fetchConfig();
const session = await sessionPromise;
const [config, data] = await Promise.all([
configPromise,
fetchData(session.user.id),
]);
return Response.json({ data, config });
}
Use Suspense to show wrapper UI immediately while data loads.
// ❌ Bad: entire page blocked by data fetching
async function Page() {
const data = await fetchData();
return (
<div>
<Sidebar />
<Header />
<DataDisplay data={data} />
<Footer />
</div>
);
}
// ✅ Good: layout renders immediately, data streams in
function Page() {
return (
<div>
<Sidebar />
<Header />
<Suspense fallback={<Skeleton />}>
<DataDisplay />
</Suspense>
<Footer />
</div>
);
}
async function DataDisplay() {
const data = await fetchData();
return <div>{data.content}</div>;
}
Import directly from source files to avoid loading thousands of unused modules. Barrel files can take 200-800ms just to import.
// ❌ Bad: imports entire library
import { Check, X, Menu } from "lucide-react";
// ✅ Good: imports only what you need
import Check from "lucide-react/dist/esm/icons/check";
import X from "lucide-react/dist/esm/icons/x";
// ✅ Alternative: Next.js 13.5+ optimizePackageImports
// next.config.js
module.exports = {
experimental: { optimizePackageImports: ["lucide-react", "@mui/material"] },
};
Load large modules only when features are activated.
function AnimationPlayer({ enabled }: { enabled: boolean }) {
const [frames, setFrames] = useState<Frame[] | null>(null);
useEffect(() => {
if (enabled && !frames && typeof window !== "undefined") {
import("./animation-frames.js").then((mod) => setFrames(mod.frames));
}
}, [enabled, frames]);
if (!frames) return <Skeleton />;
return <Canvas frames={frames} />;
}
Load analytics and logging after hydration.
// ❌ Bad: blocks initial bundle
import { Analytics } from "@vercel/analytics/react";
// ✅ Good: loads after hydration
import dynamic from "next/dynamic";
const Analytics = dynamic(
() => import("@vercel/analytics/react").then((m) => m.Analytics),
{ ssr: false },
);
// ❌ Bad: Monaco bundles with main chunk (~300KB)
import { MonacoEditor } from "./monaco-editor";
// ✅ Good: Monaco loads on demand
import dynamic from "next/dynamic";
const MonacoEditor = dynamic(
() => import("./monaco-editor").then((m) => m.MonacoEditor),
{ ssr: false },
);
Preload heavy bundles on hover/focus to reduce perceived latency.
function EditorButton({ onClick }: { onClick: () => void }) {
const preload = () => {
void import("./monaco-editor");
};
return (
<button onMouseEnter={preload} onFocus={preload} onClick={onClick}>
Open Editor
</button>
);
}
React.cache() only works within one request. Use LRU for cross-request caching.
import { LRUCache } from "lru-cache";
const cache = new LRUCache<string, any>({ max: 1000, ttl: 5 * 60 * 1000 });
export async function getUser(id: string) {
const cached = cache.get(id);
if (cached) return cached;
const user = await db.user.findUnique({ where: { id } });
cache.set(id, user);
return user;
}
Only pass fields the client actually uses across Server/Client boundaries.
// ❌ Bad: serializes all 50 fields
async function Page() {
const user = await fetchUser();
return <Profile user={user} />;
}
// ✅ Good: serializes only needed fields
async function Page() {
const user = await fetchUser();
return <Profile name={user.name} />;
}
Restructure components to parallelize data fetching.
// ❌ Bad: Sidebar waits for Page's fetch
export default async function Page() {
const header = await fetchHeader();
return (
<div>
<div>{header}</div>
<Sidebar />
</div>
);
}
// ✅ Good: both fetch simultaneously
async function Header() {
const data = await fetchHeader();
return <div>{data}</div>;
}
export default function Page() {
return (
<div>
<Header />
<Sidebar />
</div>
);
}
Use for auth and database queries. Note: Next.js automatically deduplicates fetch calls.
import { cache } from "react";
export const getCurrentUser = cache(async () => {
const session = await auth();
if (!session?.user?.id) return null;
return await db.user.findUnique({ where: { id: session.user.id } });
});
Schedule logging/analytics after response is sent.
import { after } from "next/server";
export async function POST(request: Request) {
await updateDatabase(request);
after(async () => {
logUserAction({ userAgent: request.headers.get("user-agent") });
});
return Response.json({ status: "success" });
}
Add { passive: true } to touch/wheel listeners to enable immediate scrolling.
// ✅ Good: allows browser to scroll immediately
document.addEventListener("touchstart", handler, { passive: true });
document.addEventListener("wheel", handler, { passive: true });
// ❌ Bad: each instance fetches separately
const [users, setUsers] = useState([]);
useEffect(() => {
fetch("/api/users")
.then((r) => r.json())
.then(setUsers);
}, []);
// ✅ Good: multiple instances share one request
import useSWR from "swr";
const { data: users } = useSWR("/api/users", fetcher);
Add version prefix and store minimal fields. Always wrap in try-catch (throws in incognito).
const VERSION = "v2";
function saveConfig(config: { theme: string }) {
try {
localStorage.setItem(`config:${VERSION}`, JSON.stringify(config));
} catch {}
}
Don't subscribe to dynamic state if you only read it in callbacks.
// ❌ Bad: subscribes to all searchParams changes
const searchParams = useSearchParams();
const handleShare = () => {
shareChat(searchParams.get("ref"));
};
// ✅ Good: reads on demand
const handleShare = () => {
const ref = new URLSearchParams(window.location.search).get("ref");
shareChat(ref);
};
Enable early returns before expensive computation.
// ✅ Good: skips computation when loading
const UserAvatar = memo(function UserAvatar({ user }: { user: User }) {
const id = useMemo(() => computeAvatarId(user), [user]);
return <Avatar id={id} />;
});
function Profile({ user, loading }: Props) {
if (loading) return <Skeleton />;
return <UserAvatar user={user} />;
}
Use primitives instead of objects; derive booleans from continuous values.
// ❌ Bad: re-runs on any user change
useEffect(() => {
console.log(user.id);
}, [user]);
// ✅ Good: re-runs only when id changes
useEffect(() => {
console.log(user.id);
}, [user.id]);
// ✅ Good: derive boolean from continuous value
const isMobile = width < 768;
useEffect(() => {
if (isMobile) enableMobileMode();
}, [isMobile]);
// ❌ Bad: re-renders on every pixel
const width = useWindowWidth();
const isMobile = width < 768;
// ✅ Good: re-renders only on boolean change
const isMobile = useMediaQuery("(max-width: 767px)");
Functional updates prevent stale closures and create stable callbacks. Lazy initialization avoids computation on every render.
// ❌ Bad: stale closure risk, recreates callback
const addItem = useCallback(
(item: Item) => {
setItems([...items, item]);
},
[items],
);
// ✅ Good: stable callback, always uses latest state
const addItem = useCallback((item: Item) => {
setItems((curr) => [...curr, item]);
}, []);
// ❌ Bad: buildIndex runs every render
const [index] = useState(buildSearchIndex(items));
// ✅ Good: buildIndex runs only once
const [index] = useState(() => buildSearchIndex(items));
import { startTransition } from "react";
useEffect(() => {
const handler = () => {
startTransition(() => setScrollY(window.scrollY));
};
window.addEventListener("scroll", handler, { passive: true });
return () => window.removeEventListener("scroll", handler);
}, []);
Defer off-screen rendering for 10× faster initial render on long lists.
.message-item {
content-visibility: auto;
contain-intrinsic-size: 0 80px;
}
Extract static JSX outside components, especially large SVGs.
// ✅ Good: reuses same element
const skeleton = <div className="h-20 animate-pulse bg-gray-200" />;
function Container({ loading }: { loading: boolean }) {
return <div>{loading && skeleton}</div>;
}
Inject synchronous script to set client-only values before React hydrates.
function ThemeWrapper({ children }: { children: ReactNode }) {
return (
<>
<div id="theme-wrapper">{children}</div>
<script
dangerouslySetInnerHTML={{
__html: `
(function() {
var theme = localStorage.getItem('theme') || 'light';
document.getElementById('theme-wrapper').className = theme;
})();
`,
}}
/>
</>
);
}
Use ternary operators to prevent rendering 0 or NaN.
// ❌ Bad: renders "0" when count is 0
{
count && <Badge>{count}</Badge>;
}
// ✅ Good: renders nothing when count is 0
{
count > 0 ? <Badge>{count}</Badge> : null;
}
Group CSS changes via classes or cssText to minimize reflows.
// ❌ Bad: multiple reflows
element.style.width = "100px";
element.style.height = "200px";
// ✅ Good: single reflow
element.classList.add("highlighted-box");
Convert arrays to Set/Map for repeated membership checks.
// ❌ Bad: O(n) per check
items.filter((item) => allowedIds.includes(item.id));
// ✅ Good: O(1) per check
const allowedSet = new Set(allowedIds);
items.filter((item) => allowedSet.has(item.id));
// ❌ Bad: O(n) per lookup
orders.map((o) => ({ ...o, user: users.find((u) => u.id === o.userId) }));
// ✅ Good: O(1) per lookup
const userById = new Map(users.map((u) => [u.id, u]));
orders.map((o) => ({ ...o, user: userById.get(o.userId) }));
Cache function results and storage API calls.
const cache = new Map<string, string>();
function cachedSlugify(text: string): string {
if (!cache.has(text)) cache.set(text, slugify(text));
return cache.get(text)!;
}
// Also cache localStorage reads
function getLocalStorage(key: string) {
if (!cache.has(key)) cache.set(key, localStorage.getItem(key));
return cache.get(key);
}
Return early when result is determined; check lengths before expensive comparisons.
// ✅ Good: early return
function validateUsers(users: User[]) {
for (const user of users) {
if (!user.email) return { valid: false, error: "Email required" };
}
return { valid: true };
}
// ✅ Good: length check before sort
function hasChanges(current: string[], original: string[]) {
if (current.length !== original.length) return true;
// ... expensive comparison
}
// ❌ Bad: O(n log n)
const sorted = [...projects].sort((a, b) => b.updatedAt - a.updatedAt);
return sorted[0];
// ✅ Good: O(n)
let latest = projects[0];
for (const p of projects) {
if (p.updatedAt > latest.updatedAt) latest = p;
}
return latest;
.sort() mutates arrays, breaking React's immutability model.
// ❌ Bad: mutates original
const sorted = users.sort((a, b) => a.name.localeCompare(b.name));
// ✅ Good: creates new array
const sorted = users.toSorted((a, b) => a.name.localeCompare(b.name));
Store callbacks in refs when effects shouldn't re-subscribe on callback changes.
import { useEffectEvent } from "react";
function useWindowEvent(event: string, handler: () => void) {
const onEvent = useEffectEvent(handler);
useEffect(() => {
window.addEventListener(event, onEvent);
return () => window.removeEventListener(event, onEvent);
}, [event]);
}