From web
Best practices and conventions for TypeScript and React development, covering type safety, interfaces, discriminated unions, strict mode, functional components, hooks, state management, CSS modules, and project tooling. Apply this skill whenever writing, reviewing, or refactoring TypeScript (.ts/.tsx) files, React components, custom hooks, or TypeScript configuration -- even if the user does not explicitly mention "TypeScript conventions." Also apply when the user discusses type safety, React component patterns, hook rules, prop drilling, or frontend architecture in a TypeScript context.
npx claudepluginhub atc-net/atc-agentic-toolkit --plugin webThis skill uses the workspace's default tool permissions.
Apply these practices when writing, reviewing, or refactoring TypeScript and React code. The goal is type-safe, readable, maintainable code that leverages TypeScript's type system and React's component model effectively.
Validate TypeScript/React code against style and architectural conventions
Provides React best practices for function components, props interfaces, compound components, useState, useEffect hooks, and state management. Useful for optimizing React code architecture and performance.
Reviews TypeScript and React code for security vulnerabilities (XSS, CSRF, eval), type safety issues (assertions, null handling), and error patterns. Auto-loads for TS/React reviews.
Share bugs, ideas, or general feedback.
Apply these practices when writing, reviewing, or refactoring TypeScript and React code. The goal is type-safe, readable, maintainable code that leverages TypeScript's type system and React's component model effectively.
Enable strict mode in tsconfig.json. Strict mode activates a family of checks (strictNullChecks, strictFunctionTypes, noImplicitAny, etc.) that catch real bugs at compile time rather than at runtime. Treat compiler errors as helpful feedback, not obstacles.
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true
}
}
noUncheckedIndexedAccess makes array/object index access return T | undefined, which prevents a common source of runtime crashesexactOptionalPropertyTypes distinguishes between "missing property" and "property set to undefined"anyThe any type disables type checking entirely for that value, which defeats the purpose of using TypeScript. When the type is genuinely unknown, use unknown instead -- it forces you to narrow the type before using it, which makes the code safer without losing flexibility.
// Problematic: silently passes through without any checking
function processInput(data: any) {
return data.name.toUpperCase(); // runtime crash if data has no name
}
// Better: forces you to verify the shape before using it
function processInput(data: unknown): string {
if (typeof data === "object" && data !== null && "name" in data) {
return String((data as { name: unknown }).name).toUpperCase();
}
throw new Error("Invalid input: expected object with name property");
}
If migrating a codebase and any is temporarily unavoidable, mark it with a comment explaining why and when it can be removed (e.g., // TODO: type properly after API types are generated).
TypeScript's inference is powerful -- let it work for you inside function bodies. Add explicit types at boundaries where they serve as documentation and contracts.
// Let inference handle local variables
const items = [1, 2, 3]; // TypeScript knows this is number[]
const doubled = items.map((x) => x * 2); // inferred as number[]
// Be explicit at function boundaries -- this is the contract callers depend on
function calculateTotal(items: ReadonlyArray<CartItem>): number {
return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
// Be explicit for exported constants and module-level state
export const MAX_RETRIES: number = 3;
The reasoning: inference inside functions reduces noise and adapts automatically when code changes. Explicit types at boundaries catch contract-breaking changes at the call site rather than deep in the implementation.
Use interfaces for data structures and object shapes. They are extendable, produce clearer error messages, and express intent well. Use type aliases for unions, intersections, mapped types, and utility types.
// Interface for data structures -- clearly describes a shape
interface User {
readonly id: string;
name: string;
email: string;
role: UserRole;
}
// Interface for service contracts
interface UserRepository {
findById(id: string): Promise<User | null>;
save(user: User): Promise<void>;
}
// Type alias for unions and composed types
type UserRole = "admin" | "editor" | "viewer";
type AsyncResult<T> = { status: "loading" } | { status: "success"; data: T } | { status: "error"; error: Error };
Discriminated unions model state that can only be in one configuration at a time. This is much safer than having multiple optional fields that can get out of sync, because the compiler enforces exhaustive handling.
type RequestState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: Error };
function renderState<T>(state: RequestState<T>): string {
switch (state.status) {
case "idle":
return "Ready";
case "loading":
return "Loading...";
case "success":
return `Got ${String(state.data)}`;
case "error":
return `Failed: ${state.error.message}`;
}
}
If you add a new variant to the union, the compiler will flag every switch/if-chain that doesn't handle it -- this is the key advantage over stringly-typed status fields with separate optional properties.
Use const for all bindings by default. Use readonly for properties that should not change after construction. Use ReadonlyArray<T> or readonly T[] for arrays that should not be mutated. Immutable data is easier to reason about, especially in React where mutation can silently break rendering.
interface Config {
readonly apiUrl: string;
readonly maxRetries: number;
readonly features: ReadonlyArray<string>;
}
// as const narrows literal types and makes everything readonly
const ROUTES = {
home: "/",
profile: "/profile",
settings: "/settings",
} as const;
// typeof ROUTES.home is "/", not string
type Route = (typeof ROUTES)[keyof typeof ROUTES];
Use optional chaining (?.) to safely access nested properties that might be null or undefined. Use nullish coalescing (??) to provide defaults only when a value is null or undefined -- unlike ||, it does not treat 0, "", or false as missing.
// Optional chaining -- short-circuits to undefined if any link is null/undefined
const city = user?.address?.city;
const firstTag = post?.tags?.[0];
const formatted = date?.toISOString?.();
// Nullish coalescing -- only substitutes for null/undefined
const pageSize = config.pageSize ?? 20; // 0 is a valid page size, not replaced
const label = item.label ?? "Untitled"; // empty string "" would be kept
TypeScript ships with utility types that derive new types from existing ones. Prefer these over manually duplicating type definitions.
// Pick and Omit for selecting/excluding properties
type UserSummary = Pick<User, "id" | "name">;
type CreateUserInput = Omit<User, "id" | "createdAt">;
// Partial and Required for adjusting optionality
type UserUpdate = Partial<User>;
type StrictConfig = Required<Config>;
// Record for typed key-value maps
type FeatureFlags = Record<string, boolean>;
// Extract and Exclude for filtering union members
type SuccessState = Extract<RequestState<unknown>, { status: "success" }>;
Use async/await instead of raw .then() chains. It reads sequentially, makes error handling straightforward with try/catch, and avoids deeply nested callbacks.
async function fetchUserProfile(userId: string): Promise<UserProfile> {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new Error(`Failed to fetch user: ${response.status} ${response.statusText}`);
}
const data: unknown = await response.json();
return parseUserProfile(data);
}
// Parallel execution when requests are independent
async function loadDashboard(userId: string): Promise<DashboardData> {
const [profile, notifications, activity] = await Promise.all([
fetchUserProfile(userId),
fetchNotifications(userId),
fetchRecentActivity(userId),
]);
return { profile, notifications, activity };
}
Handle errors at appropriate boundaries rather than swallowing them silently. Let errors propagate to a place where they can be meaningfully handled (error boundaries in React, global error handlers in APIs).
Prefer string literal union types over enums. They produce no runtime JavaScript, work naturally with type narrowing, and are compatible with JSON serialization.
// Prefer this
type Status = "active" | "inactive" | "pending";
// Over this -- enums generate runtime code and can behave unexpectedly with reverse mapping
enum Status {
Active = "active",
Inactive = "inactive",
Pending = "pending",
}
If you need runtime iteration over the values, use as const with an array:
const STATUSES = ["active", "inactive", "pending"] as const;
type Status = (typeof STATUSES)[number];
// Now STATUSES is iterable and Status is the union type
Use functional components exclusively. Class components are legacy -- they introduce extra complexity without benefits in modern React.
interface UserCardProps {
user: User;
onSelect: (userId: string) => void;
}
const UserCard: React.FC<UserCardProps> = ({ user, onSelect }) => {
return (
<div className={styles.card} onClick={() => onSelect(user.id)}>
<h3>{user.name}</h3>
<p>{user.email}</p>
</div>
);
};
Alternatively, type the function directly if you do not need the implicit children typing that React.FC provides:
function UserCard({ user, onSelect }: UserCardProps) {
return (
<div className={styles.card} onClick={() => onSelect(user.id)}>
<h3>{user.name}</h3>
<p>{user.email}</p>
</div>
);
}
// Instead of this -- too many configuration flags
<DataTable sortable filterable paginated exportable collapsible />
// Compose smaller focused components
<DataTable data={data}>
<SortableHeader />
<FilterBar />
<Pagination />
</DataTable>
React hooks have two rules enforced by the react-hooks/rules-of-hooks ESLint rule. These rules exist because React relies on hook call order being identical between renders to track state correctly.
// Correct: hooks called unconditionally at the top level
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
let cancelled = false;
fetchUser(userId).then((data) => {
if (!cancelled) {
setUser(data);
setIsLoading(false);
}
});
return () => { cancelled = true; };
}, [userId]);
if (isLoading) return <Spinner />;
if (!user) return <NotFound />;
return <UserCard user={user} />;
}
The dependency array in useEffect, useMemo, and useCallback tells React when to re-run the effect or recalculate the value. Getting dependencies wrong causes either stale closures (missing dependencies) or infinite re-render loops (unstable references).
// useEffect: re-runs when userId changes
useEffect(() => {
loadUser(userId);
}, [userId]);
// useMemo: recalculates only when items or filter change
const filteredItems = useMemo(
() => items.filter((item) => item.category === filter),
[items, filter]
);
// useCallback: stable function reference for child components
const handleSubmit = useCallback(
(data: FormData) => {
submitForm(data, userId);
},
[userId]
);
Use the react-hooks/exhaustive-deps ESLint rule. If it flags a dependency you think should be excluded, the fix is almost always to restructure the code, not to suppress the warning.
Extract reusable stateful logic into custom hooks. This keeps components focused on rendering while making the logic testable in isolation.
function useAsync<T>(asyncFn: () => Promise<T>, deps: readonly unknown[]): RequestState<T> {
const [state, setState] = useState<RequestState<T>>({ status: "idle" });
useEffect(() => {
let cancelled = false;
setState({ status: "loading" });
asyncFn().then(
(data) => { if (!cancelled) setState({ status: "success", data }); },
(error) => { if (!cancelled) setState({ status: "error", error: error instanceof Error ? error : new Error(String(error)) }); }
);
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
return state;
}
// Usage -- the component is clean and focused on rendering
function UserList() {
const state = useAsync(() => fetchUsers(), []);
if (state.status === "loading") return <Spinner />;
if (state.status === "error") return <ErrorMessage error={state.error} />;
if (state.status === "success") return <List items={state.data} />;
return null;
}
Naming convention: custom hooks always start with use (e.g., useAuth, useLocalStorage, useDebounce). This is not just a convention -- React's linter uses the prefix to identify hooks and enforce the rules.
Use CSS modules for component-scoped styling. They prevent class name collisions by generating unique names at build time, without the runtime cost of CSS-in-JS.
import styles from "./UserCard.module.css";
function UserCard({ user }: UserCardProps) {
return (
<div className={styles.card}>
<h3 className={styles.name}>{user.name}</h3>
<span className={styles.role}>{user.role}</span>
</div>
);
}
/* UserCard.module.css */
.card {
padding: 1rem;
border: 1px solid var(--border-color);
border-radius: 8px;
}
.name {
font-weight: 600;
margin: 0;
}
.role {
color: var(--text-secondary);
font-size: 0.875rem;
}
For conditional class names, compose them with template literals or a small utility rather than a heavy library:
<div className={`${styles.card} ${isActive ? styles.active : ""}`}>
When multiple components need the same data, avoid passing props through many intermediate layers (prop drilling). Choose the appropriate tool based on scope:
useState): state used by a single component or its direct children.useContext): state shared across a subtree that changes infrequently (theme, locale, auth).// Context for shared, infrequently-changing state
interface AuthContext {
user: User | null;
login: (credentials: Credentials) => Promise<void>;
logout: () => void;
}
const AuthContext = createContext<AuthContext | null>(null);
function useAuth(): AuthContext {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
}
The throw-if-null pattern above gives a clear error message when a component forgets to wrap its tree with the provider, rather than silently getting undefined values.
React error boundaries catch JavaScript errors in the component tree and display a fallback UI instead of crashing the entire application. Since there is no hook equivalent, use a class component (this is one of the few valid uses) or a library like react-error-boundary.
import { ErrorBoundary } from "react-error-boundary";
function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
return (
<div role="alert">
<h2>Something went wrong</h2>
<pre>{error.message}</pre>
<button onClick={resetErrorBoundary}>Try again</button>
</div>
);
}
// Wrap sections of the UI independently so one failure does not take down the whole page
function App() {
return (
<ErrorBoundary FallbackComponent={ErrorFallback}>
<Header />
<ErrorBoundary FallbackComponent={ErrorFallback}>
<MainContent />
</ErrorBoundary>
<Footer />
</ErrorBoundary>
);
}
When rendering lists, provide a stable, unique key prop so React can efficiently reconcile the list when items are added, removed, or reordered. Array indices are not stable keys if the list can change.
// Good: stable unique identifier
{users.map((user) => (
<UserCard key={user.id} user={user} />
))}
// Avoid: index keys break when list order changes
{users.map((user, index) => (
<UserCard key={index} user={user} />
))}
Use React.memo, useMemo, and useCallback to avoid expensive re-computations and unnecessary re-renders. Apply them when profiling shows a performance problem, not preemptively everywhere -- memoization has its own memory cost.
// Memoize a component that receives the same props frequently
const ExpensiveChart = React.memo(function ExpensiveChart({ data }: ChartProps) {
return <canvas>{/* complex rendering */}</canvas>;
});
// Memoize an expensive computation
const sortedData = useMemo(
() => data.slice().sort((a, b) => a.score - b.score),
[data]
);
// Stable callback reference for child that uses React.memo
const handleClick = useCallback((id: string) => {
dispatch({ type: "SELECT", payload: id });
}, [dispatch]);
Use ESLint for code quality rules and Prettier for formatting. Configure them to not conflict with each other.
Recommended ESLint config for TypeScript + React projects:
{
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended",
"prettier"
],
"rules": {
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }],
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn"
}
}
prettier in the extends array disables all ESLint formatting rules so only Prettier handles formatting. This eliminates conflicts between the two tools.
Use index.ts barrel files to provide clean import paths and control the public API of each module.
// components/UserCard/index.ts
export { UserCard } from "./UserCard";
export type { UserCardProps } from "./UserCard";
// Now consumers import from the folder, not the file
import { UserCard } from "@/components/UserCard";
Keep barrel files shallow -- re-export only the public API. Deep barrel chains (barrels importing from other barrels) can create circular dependencies and slow down build tools.
Organize by feature rather than by type when the project grows beyond a handful of components. Feature-based organization keeps related code together.
src/
features/
auth/
components/
LoginForm.tsx
LoginForm.module.css
hooks/
useAuth.ts
types.ts
index.ts
dashboard/
components/
hooks/
types.ts
index.ts
shared/
components/
hooks/
utils/
types/
| Item | Convention | Example |
|---|---|---|
| Components | PascalCase | UserCard.tsx |
| Hooks | camelCase with use prefix | useAuth.ts |
| Utilities | camelCase | formatDate.ts |
| Types/Interfaces | PascalCase | UserProfile, ApiResponse |
| Constants | UPPER_SNAKE_CASE | MAX_RETRIES, API_BASE_URL |
| CSS modules | PascalCase matching component | UserCard.module.css |
| Test files | Same name with .test suffix | UserCard.test.tsx |
| Pitfall | Why it matters | What to do instead |
|---|---|---|
Using any to silence errors | Hides real type problems that surface as runtime bugs | Use unknown and narrow, or define proper types |
| Missing dependency array entries | Stale closures cause bugs that are difficult to reproduce | Trust the exhaustive-deps lint rule |
| Mutating state directly | React will not detect the change and will skip re-rendering | Create new objects/arrays: spread, map, filter |
| Defining components inside components | Creates a new component identity every render, destroying state | Move inner components outside or use useMemo for render functions |
| Huge monolithic components | Hard to test, hard to reuse, hard to reason about | Extract custom hooks and sub-components |
| Index keys on dynamic lists | Causes incorrect DOM reuse when items are reordered or removed | Use stable unique IDs from data |
| Over-memoizing everything | Adds complexity and memory overhead without measurable benefit | Profile first, memoize where it matters |
| Ignoring TypeScript errors | Accumulates tech debt; @ts-ignore spreads silently | Fix the root cause; use @ts-expect-error with a reason if suppression is truly needed |