react-patterns
SKILL.md
React Patterns
When to Use
Use this skill when building React components with TypeScript in strict mode. Covers React 19 changes, component architecture, custom hooks, performance optimization, error boundaries, code splitting, and accessibility patterns.
1. React 19 Changes
ref as Regular Prop
forwardRef is no longer needed. Components accept ref directly:
// React 19 — ref is a regular prop
function Input({ ref, className, ...props }: React.ComponentProps<"input">) {
return (
<input
ref={ref}
className={cn("border rounded px-3 py-2", className)}
{...props}
/>
);
}
// Usage
const inputRef = useRef<HTMLInputElement>(null);
<Input ref={inputRef} placeholder="Search..." />;
React.ComponentProps<typeof Component> already includes ref in React 19.
use() Hook
Read promises and context directly in render:
import { use } from "react";
function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
const user = use(userPromise); // Suspends until resolved
return <div>{user.name}</div>;
}
// Must be wrapped in Suspense
<Suspense fallback={<Skeleton />}>
<UserProfile userPromise={fetchUser(id)} />
</Suspense>;
useActionState
Replace manual form state management:
import { useActionState } from "react";
function LoginForm() {
const [state, submitAction, isPending] = useActionState(
async (_prevState: { error?: string }, formData: FormData) => {
const result = await login(
formData.get("email"),
formData.get("password"),
);
if (!result.success) return { error: result.message };
return {};
},
{},
);
return (
<form action={submitAction}>
<Input name="email" type="email" />
<Input name="password" type="password" />
{state.error && <p className="text-destructive">{state.error}</p>}
<Button type="submit" disabled={isPending}>
{isPending ? "Signing in..." : "Sign in"}
</Button>
</form>
);
}
useOptimistic
Show optimistic UI while an async action is in progress:
import { useOptimistic } from "react";
function NodeList({ nodes }: { nodes: Node[] }) {
const [optimisticNodes, addOptimisticNode] = useOptimistic(
nodes,
(current, newNode: Node) => [...current, newNode],
);
const handleAdd = async (node: Node) => {
addOptimisticNode(node); // Immediately show in UI
await saveNodeToDatabase(node); // Actually save
};
return optimisticNodes.map((n) => <NodeCard key={n.id} node={n} />);
}
2. TypeScript Strict Patterns
Extending Component Props
// Extend native HTML element props
interface SearchInputProps extends React.ComponentProps<"input"> {
onSearch: (term: string) => void;
}
// Extend a shadcn/ui component
interface CustomCardProps extends React.ComponentProps<typeof Card> {
title: string;
riskScore?: number;
}
Discriminated Unions for Variants
type NodeDetailProps =
| { type: "person"; person: Person; accounts: Account[] }
| { type: "account"; account: Account; transactions: Transaction[] }
| { type: "transaction"; transaction: Transaction };
function NodeDetail(props: NodeDetailProps) {
switch (props.type) {
case "person":
return <PersonDetail person={props.person} accounts={props.accounts} />;
case "account":
return (
<AccountDetail
account={props.account}
transactions={props.transactions}
/>
);
case "transaction":
return <TransactionDetail transaction={props.transaction} />;
}
}
Generic Components
interface DataTableProps<T> {
data: T[];
columns: ColumnDef<T>[];
onRowClick?: (row: T) => void;
}
function DataTable<T extends { id: string }>({
data,
columns,
onRowClick,
}: DataTableProps<T>) {
// Table implementation
}
satisfies for Type-Safe Configs
const nodeStyleConfig = {
Customer: { icon: "User", baseColor: "#3B82F6", baseSize: 30 },
Account: { icon: "Landmark", baseColor: "#10B981", baseSize: 28 },
Transaction: { icon: "ArrowLeftRight", baseColor: "#F59E0B", baseSize: 24 },
} satisfies Record<
string,
{ icon: string; baseColor: string; baseSize: number }
>;
3. Component Architecture
Functional Components Only
// ✅ Functional component with hooks
const NodesTable = ({ nodes, onSelect }: Props) => {
const [sortField, setSortField] = useState('name');
return (/* ... */);
};
// ❌ Class components — do not use
Single Responsibility
// ✅ Focused components
const InvestigationPanel = () => (
<div>
<NodesTable /> {/* Table logic only */}
<GraphCanvas /> {/* Graph rendering only */}
<CaseTimeline /> {/* Timeline logic only */}
</div>
);
// ❌ One massive component doing everything
Composition Over Prop Drilling
Use Zustand stores or context instead of threading props through many layers:
// ✅ Components access state directly from Zustand
const GraphCanvas = () => {
const nodes = useStore((s) => s.tabStates[s.activeTabId]?.nodes ?? []);
return <NVL nodes={nodes} />;
};
// ❌ Drilling props through 4+ levels
<App nodes={nodes}>
<Layout nodes={nodes}>
<Panel nodes={nodes}>
<Graph nodes={nodes} />
4. Custom Hooks
useTabState — Access Active Tab State
const useTabState = () => {
const activeTabId = useStore((s) => s.activeTabId);
const tabState = useStore((s) =>
s.activeTabId ? (s.tabStates[s.activeTabId] ?? null) : null,
);
return { activeTabId, tabState };
};
useDebounce — Debounce a Value
const 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;
};
// Usage
const [search, setSearch] = useState("");
const debouncedSearch = useDebounce(search, 300);
useEffect(() => {
fetchNodes(debouncedSearch);
}, [debouncedSearch]);
useLocalStorage — Persist UI State
const useLocalStorage = <T,>(key: string, initial: T) => {
const [value, setValue] = useState<T>(() => {
try {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : initial;
} catch {
return initial;
}
});
const setAndPersist = useCallback(
(newValue: T | ((prev: T) => T)) => {
setValue((prev) => {
const resolved =
newValue instanceof Function ? newValue(prev) : newValue;
localStorage.setItem(key, JSON.stringify(resolved));
return resolved;
});
},
[key],
);
return [value, setAndPersist] as const;
};
5. Performance
React.memo for Expensive List Items
export const NodesRow = memo(({ node, onSelect }: Props) => (
<tr onClick={() => onSelect(node.id)}>
<td>{node.name}</td>
<td>{node.riskScore}</td>
</tr>
));
useCallback for Stable Event Handlers
const NodesTable = ({ nodes }: Props) => {
// ✅ Stable reference — doesn't change between renders
const handleRowClick = useCallback(
(id: string) => {
useStore.getState().updateTabState(activeTabId, {
selectedNodeIds: new Set([id]),
});
},
[activeTabId],
);
return nodes.map((n) => (
<NodesRow key={n.id} node={n} onClick={handleRowClick} />
));
};
useMemo for Expensive Computations
const filteredNodes = useMemo(
() =>
nodes.filter((n) => !removedNodeIds.has(n.id) && matchesFilter(n, filter)),
[nodes, removedNodeIds, filter],
);
useTransition for Non-Blocking Updates
Keep the UI responsive while filtering large datasets:
const [isPending, startTransition] = useTransition();
const handleFilterChange = (newFilter: string) => {
setFilterInput(newFilter); // Urgent: update the input immediately
startTransition(() => {
setAppliedFilter(newFilter); // Non-urgent: filter the table in background
});
};
return (
<>
<Input
value={filterInput}
onChange={(e) => handleFilterChange(e.target.value)}
/>
{isPending && <Spinner />}
<DataTable data={filteredByAppliedFilter} />
</>
);
Code Splitting with React.lazy
const GraphCanvas = lazy(() => import("@/components/graph/GraphCanvas"));
const CaseTimeline = lazy(
() => import("@/components/investigation/CaseTimeline"),
);
const InvestigationPage = () => (
<Suspense fallback={<Skeleton className="h-full w-full" />}>
<GraphCanvas />
</Suspense>
);
6. Error Boundaries
Isolate failures per tab — one crashing tab shouldn't take down others.
import { Component, type ReactNode } from "react";
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error?: Error;
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error("Error boundary caught:", error, errorInfo);
}
render() {
if (this.state.hasError) {
return (
this.props.fallback ?? (
<div className="flex flex-col items-center justify-center p-8">
<AlertCircle className="h-12 w-12 text-destructive" />
<h2 className="mt-4 text-lg font-semibold">Something went wrong</h2>
<p className="mt-2 text-sm text-muted-foreground">
{this.state.error?.message}
</p>
</div>
)
);
}
return this.props.children;
}
}
// Usage: wrap each tab's content
<ErrorBoundary>
<InvestigationTab tabId={tab.id} />
</ErrorBoundary>;
7. Accessibility
Required Additions to shadcn/ui Components
// 1. aria-label on icon-only buttons
<Button variant="ghost" size="icon" aria-label="Close tab">
<X className="h-4 w-4" />
</Button>
// 2. DialogDescription (required by Radix, can be visually hidden)
<DialogHeader>
<DialogTitle>Confirm Delete</DialogTitle>
<DialogDescription className="sr-only">
Confirm deletion of the selected nodes
</DialogDescription>
</DialogHeader>
// 3. data-testid on interactive elements
<Button data-testid="submit-investigation">Submit</Button>
<Input data-testid="search-input" />
Keyboard Navigation for Custom Interactive Elements
const NodeRow = ({ node, onSelect }: Props) => {
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelect(node.id);
}
};
return (
<tr
tabIndex={0}
role="button"
onClick={() => onSelect(node.id)}
onKeyDown={handleKeyDown}
aria-label={`Select ${node.name}`}
>
{/* ... */}
</tr>
);
};
Anti-Patterns
| Anti-Pattern | Why It Fails | Fix |
|---|---|---|
useEffect for data fetching on mount |
Missing cleanup, race conditions on rapid re-mounts | Use a service layer or React Query; call from event handlers |
| Props drilling through 4+ levels | Fragile, every intermediate component must forward props | Use Zustand selectors or React context |
| Class components | Cannot use hooks, more boilerplate | Functional components only (error boundaries are the one exception) |
dangerouslySetInnerHTML without sanitization |
XSS vulnerability | Sanitize with DOMPurify first, or avoid entirely |
| Inline object/array props on memoized children | memo() is bypassed — new object reference every render |
Extract to useMemo or module-level constant |
Overusing useEffect |
Effects run after render, cause waterfalls, hard to reason about | Prefer event handlers for user actions, useMemo for derived values |
| Not wrapping lazy components in Suspense | Runtime error: "A component suspended while responding to synchronous input" | Always pair React.lazy() with a <Suspense> boundary |
Missing key prop on list items |
React can't track which items changed, causing incorrect re-renders or stale state | Always use a stable unique key (element ID, not array index) |
Weekly Installs
4
Repository
michaelkeevildo…s-skillsGitHub Stars
1
First Seen
11 days ago
Security Audits
Installed on
opencode4
mcpjam2
claude-code2
junie2
windsurf2
zencoder2