Loading...
Loading...
Apply React best practices when writing or reviewing React code. Use when building components, reviewing PRs, refactoring React code, fixing performance issues, debugging re-renders, structuring state and data flow, converting useEffect to loaders, building forms, or asking "how should I structure this component".
npx skill4agent add yonderlab/kota.agent.skills react-best-practicesstrict: trueanyuseTransitionuseDeferredValue// BAD
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);
// GOOD
const fullName = `${firstName} ${lastName}`;// BAD
const [submitted, setSubmitted] = useState(false);
useEffect(() => {
if (submitted) {
submitForm(data);
}
}, [submitted, data]);
// GOOD
function handleSubmit() {
submitForm(data);
}// BAD
const [items, setItems] = useState([]);
useEffect(() => {
setItems(getInitialItems());
}, []);
// GOOD
const [items, setItems] = useState(() => getInitialItems());useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
return () => connection.disconnect(); // Always clean up
}, [roomId]);exhaustive-deps// BAD - suppressing the linter
useEffect(() => {
doSomething(value);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Missing 'value'
// GOOD - fix the actual issue
const stableCallback = useCallback(() => doSomething(value), [value]);
useEffect(() => {
stableCallback();
}, [stableCallback]);useLayoutEffect// useLayoutEffect - runs synchronously after DOM mutations
useLayoutEffect(() => {
const rect = ref.current.getBoundingClientRect();
setPosition({ top: rect.top, left: rect.left });
}, []);
// useEffect - runs after paint (preferred for most cases)
useEffect(() => {
trackPageView();
}, []);useEffectuseLayoutEffectuseEffect(() => {
let cancelled = false;
const controller = new AbortController();
async function fetchData() {
try {
const res = await fetch("/api/data", { signal: controller.signal });
if (!cancelled) setData(await res.json());
} catch (e) {
if (!cancelled && e.name !== "AbortError") setError(e);
}
}
fetchData();
return () => {
cancelled = true;
controller.abort();
};
}, []);// In route definition
{
path: "posts",
element: <Posts />,
loader: async () => {
const posts = await fetch("/api/posts").then(r => r.json());
return { posts };
}
}
// In component
function Posts() {
const { posts } = useLoaderData();
return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>;
}// In route definition
{
path: "posts/new",
element: <NewPost />,
action: async ({ request }) => {
const formData = await request.formData();
// Note: formData.get() returns FormDataEntryValue (string | File) or null
const title = formData.get("title");
if (typeof title !== "string") {
return { error: "Title is required" };
}
const response = await fetch("/api/posts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title })
});
if (!response.ok) {
return { error: "Failed to create post" };
}
return redirect("/posts");
}
}
// In component - use Form, not onSubmit with fetch
function NewPost() {
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
return (
<Form method="post">
<input name="title" required />
<button disabled={isSubmitting}>
{isSubmitting ? "Creating..." : "Create"}
</button>
</Form>
);
}useLoaderData()useActionData()useNavigation()useFetcher()// BAD - modal state lost on refresh/share
const [isOpen, setIsOpen] = useState(false);
// GOOD - modal state in URL
import { useSearchParams } from "react-router";
function ProductPage() {
const [searchParams, setSearchParams] = useSearchParams();
const isModalOpen = searchParams.get("modal") === "open";
function openModal() {
setSearchParams({ modal: "open" });
}
function closeModal() {
setSearchParams({});
}
return (
<>
<button onClick={openModal}>View Details</button>
{isModalOpen && <Modal onClose={closeModal} />}
</>
);
}// Good useReducer candidate - related state
const [state, dispatch] = useReducer(formReducer, {
values: {},
errors: {},
touched: {},
isSubmitting: false
});// BAD - all consumers re-render on any change
<AppContext.Provider value={{ user, theme, settings, cart }}>
// GOOD - separate contexts by domain
<UserContext.Provider value={user}>
<ThemeContext.Provider value={theme}>
<CartContext.Provider value={cart}>// BAD - new object every render
<ThemeContext.Provider value={{ theme, setTheme }}>
// GOOD - memoized value
const value = useMemo(() => ({ theme, setTheme }), [theme]);
<ThemeContext.Provider value={value}>useSyncExternalStore// BAD - configuration via props
<Dialog
title="Edit Profile"
description="Make changes here"
content={<ProfileForm />}
onConfirm={handleSave}
onCancel={handleClose}
/>
// GOOD - composition via children
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">Edit Profile</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Profile</DialogTitle>
<DialogDescription>Make changes here</DialogDescription>
</DialogHeader>
<ProfileForm />
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button onClick={handleSave}>Save</Button>
</DialogFooter>
</DialogContent>
</Dialog>// Custom hook encapsulates complexity
function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
// Component stays simple
function Search() {
const [query, setQuery] = useState("");
const debouncedQuery = useDebounce(query, 300);
// Use debouncedQuery for API calls
}// BAD
{items.map((item, index) => <Item key={index} {...item} />)}
{items.map(item => <Item key={Math.random()} {...item} />)}
// GOOD
{items.map(item => <Item key={item.id} {...item} />)}// Reset form when editing different user
<UserForm key={userId} user={user} />const ExpensiveList = memo(function ExpensiveList({ items }: Props) {
return items.map(item => <ExpensiveItem key={item.id} item={item} />);
});// Use toSorted() or spread to avoid mutating the original array
const sortedItems = useMemo(
() => [...items].sort((a, b) => a.name.localeCompare(b.name)),
[items]
);const handleClick = useCallback((id: string) => {
setSelected(id);
}, []);
return <MemoizedList items={items} onItemClick={handleClick} />;const [isPending, startTransition] = useTransition();
function handleFilter(value: string) {
setInputValue(value); // Urgent: update input immediately
startTransition(() => {
setFilteredItems(expensiveFilter(items, value)); // Non-blocking
});
}
return (
<>
<input value={inputValue} onChange={e => handleFilter(e.target.value)} />
{isPending && <Spinner />}
<ItemList items={filteredItems} />
</>
);useTransitionuseDeferredValueconst [isPending, startTransition] = useTransition();
function handleTabChange(tab: string) {
startTransition(() => {
setActiveTab(tab); // Can be interrupted by more urgent updates
});
}
return (
<>
<TabBar activeTab={activeTab} onChange={handleTabChange} />
{isPending ? <TabSkeleton /> : <TabContent tab={activeTab} />}
</>
);function SearchResults({ query }: { query: string }) {
const deferredQuery = useDeferredValue(query);
const isStale = query !== deferredQuery;
return (
<div style={{ opacity: isStale ? 0.7 : 1 }}>
<ExpensiveList query={deferredQuery} />
</div>
);
}| Scenario | Use |
|---|---|
| You control the state setter | |
| Value comes from props | |
Need | |
| Deferring derived/computed values | |
import { lazy, Suspense } from "react";
const Dashboard = lazy(() => import("./Dashboard"));
function App() {
return (
<Suspense fallback={<DashboardSkeleton />}>
<Dashboard />
</Suspense>
);
}lazyconst router = createBrowserRouter([
{ path: "/", element: <Home /> },
{ path: "/dashboard", lazy: () => import("./Dashboard") },
{ path: "/settings", lazy: () => import("./Settings") }
]);React.lazyReact.lazy<Suspense fallback={<PageSkeleton />}>
<Header />
<Suspense fallback={<ContentSkeleton />}>
<MainContent />
</Suspense>
<Suspense fallback={<SidebarSkeleton />}>
<Sidebar />
</Suspense>
</Suspense>react-error-boundary// Using react-error-boundary (recommended)
import { ErrorBoundary } from "react-error-boundary";
<ErrorBoundary fallback={<ErrorMessage />}>
<RiskyComponent />
</ErrorBoundary>
// Or with React Router 7, use route-level errorElement
{
path: "dashboard",
element: <Dashboard />,
errorElement: <DashboardError />
}// In loader
export async function loader() {
try {
const data = await fetchData();
return { data };
} catch (error) {
throw new Response("Failed to load", { status: 500 });
}
}// GOOD
interface ButtonProps {
variant?: "primary" | "secondary";
children: React.ReactNode;
onClick?: () => void;
}
function Button({ variant = "primary", children, onClick }: ButtonProps) {
return <button className={variant} onClick={onClick}>{children}</button>;
}anyunknown// BAD
function handleError(error: any) {
console.log(error.message);
}
// GOOD
function handleError(error: unknown) {
if (error instanceof Error) {
console.log(error.message);
}
}// Extend HTML element props
type ButtonProps = React.ComponentProps<"button"> & {
variant?: "primary" | "secondary";
};
// Children included
type CardProps = React.PropsWithChildren<{
title: string;
}>;useIdfunction TextField({ label }: { label: string }) {
const id = useId();
return (
<div>
<label htmlFor={id}>{label}</label>
<input id={id} type="text" />
</div>
);
}function Modal({ onClose }: { onClose: () => void }) {
const closeButtonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
closeButtonRef.current?.focus();
}, []);
return (
<div role="dialog" aria-modal="true">
<button ref={closeButtonRef} onClick={onClose}>Close</button>
</div>
);
}/websites/react_dev/remix-run/react-router/websites/ui_shadcnQuery Context7 /websites/react_dev for "you might not need an effect derived state event handlers"vercel-labs/agent-skillsskills/react-best-practices