Apr 18, 2026
React TypeScript Patterns Every Developer Should Know
A practical guide to React TypeScript patterns covering component props typing, generics, discriminated unions, utility types, and hooks typing for scalable React applications.
8 min read
React TypeScript Patterns Every Developer Should Know
Most React teams adopt TypeScript early and then plateau. They type props with React.FC, sprinkle any when the compiler pushes back, and call it a day. The result is a codebase that compiles but offers little more safety than plain JavaScript. The real value of React TypeScript patterns comes from narrowing types precisely enough that the compiler catches logic errors before tests ever run.
This guide covers the patterns that move TypeScript from ceremony to safety: how to type component props without leaking implementation details, how generics eliminate repetitive type declarations in reusable components, how discriminated unions replace fragile boolean prop combinations, and how to type custom hooks so their return values drive downstream inference. If you already rely on custom hooks, Advanced React Hooks Explained for Performance and Scalable Apps covers the runtime behaviour that complements these typing strategies. For state management, React State Management with Zustand: A Practical Guide for Next.js Apps demonstrates how Zustand stores benefit from the same strict typing principles explored here.
1. Prefer Explicit Props Over React.FC
React.FC (or React.FunctionComponent) was once the default way to type functional components. The community has largely moved away from it because it adds implicit children in older React versions, forces the return type to JSX.Element | null, and makes prop inference noisier than it needs to be.
The cleaner pattern is to define a named props interface and type the function directly:
interface ButtonProps {
label: string;
variant?: "primary" | "secondary" | "ghost";
isLoading?: boolean;
onClick: () => void;
}
export function Button({ label, variant = "primary", isLoading = false, onClick }: ButtonProps) {
return (
<button
className={`btn btn-${variant}`}
disabled={isLoading}
onClick={onClick}
>
{isLoading ? "Loading…" : label}
</button>
);
}
The props interface becomes the single source of truth. Consumers get autocomplete, wrong prop shapes produce errors at the call site, and there's nothing hidden inside React.FC's structural type to surprise you during refactors.
2. Use ComponentPropsWithoutRef to Extend HTML Elements
Wrapper components often need to accept every native HTML attribute the underlying element supports, plus a few custom ones. Manually listing every attribute is impractical. React.ComponentPropsWithoutRef solves this cleanly:
type InputProps = React.ComponentPropsWithoutRef<"input"> & {
label: string;
error?: string;
};
export function Input({ label, error, id, ...rest }: InputProps) {
const inputId = id ?? `input-${label.toLowerCase().replace(/\s+/g, "-")}`;
return (
<div className="field">
<label htmlFor={inputId}>{label}</label>
<input id={inputId} aria-invalid={!!error} {...rest} />
{error && <p className="error-text">{error}</p>}
</div>
);
}
Spreading ...rest passes through placeholder, disabled, onChange, and every other native attribute without declaring them explicitly. If you use ref forwarding, switch to ComponentPropsWithRef and wrap the component in React.forwardRef with the correct type parameters.
3. Generic Components for Reusable Lists and Selects
Rendering a list of items or building a generic Select component always tempts developers toward any[]. Generics preserve type information through the component boundary so the parent retains full knowledge of what it passed in:
interface ListProps<T> {
items: T[];
getKey: (item: T) => string | number;
renderItem: (item: T) => React.ReactNode;
emptyMessage?: string;
}
export function List<T>({ items, getKey, renderItem, emptyMessage = "No items." }: ListProps<T>) {
if (items.length === 0) {
return <p className="empty">{emptyMessage}</p>;
}
return (
<ul>
{items.map((item) => (
<li key={getKey(item)}>{renderItem(item)}</li>
))}
</ul>
);
}
TypeScript infers T from the items array at the call site:
<List
items={users} // User[]
getKey={(u) => u.id} // TypeScript knows u is User
renderItem={(u) => <UserCard user={u} />}
/>
The compiler now validates getKey and renderItem against the real item shape. Rename a field on User and every List<User> usage that touches that field fails to compile immediately.
4. Discriminated Unions for Conditional Props
A common anti-pattern is gating sets of props behind booleans:
// Fragile — nothing prevents passing both or neither
interface BadCardProps {
isLink?: boolean;
href?: string;
onClick?: () => void;
}
Discriminated unions express the intent clearly and let the compiler enforce it:
type CardProps =
| { variant: "button"; onClick: () => void; href?: never }
| { variant: "link"; href: string; onClick?: never };
type BaseCardProps = {
title: string;
description: string;
};
export function Card(props: BaseCardProps & CardProps) {
const { title, description, variant } = props;
return (
<div className="card">
<h3>{title}</h3>
<p>{description}</p>
{variant === "link" ? (
<a href={props.href}>Open</a>
) : (
<button onClick={props.onClick}>Open</button>
)}
</div>
);
}
Using href?: never on the button variant and onClick?: never on the link variant means TypeScript raises an error when a caller mixes them. The variant discriminant also narrows the type inside conditional branches, so props.href is only accessible when variant === "link".
5. Typing Custom Hooks Precisely
The react hooks TypeScript best practices most worth adopting centre on making custom hook return types explicit and narrow. When a hook returns a tuple, TypeScript infers (string | (() => void))[] unless you annotate it, which breaks destructuring:
// Without annotation — TypeScript infers (string | Dispatch<...>)[]
function useToggle(initial: boolean) {
const [state, setState] = useState(initial);
const toggle = useCallback(() => setState((s) => !s), []);
return [state, toggle]; // Inferred as (boolean | (() => void))[]
}
// With const assertion — TypeScript infers [boolean, () => void]
function useToggle(initial: boolean) {
const [state, setState] = useState(initial);
const toggle = useCallback(() => setState((s) => !s), []);
return [state, toggle] as const;
}
For object-returning hooks, an explicit return type interface prevents the hook's internals from leaking structural details:
interface UsePaginationReturn {
page: number;
totalPages: number;
goToNext: () => void;
goToPrev: () => void;
goToPage: (n: number) => void;
canGoNext: boolean;
canGoPrev: boolean;
}
function usePagination(total: number, perPage: number): UsePaginationReturn {
const [page, setPage] = useState(1);
const totalPages = Math.ceil(total / perPage);
return {
page,
totalPages,
goToNext: () => setPage((p) => Math.min(p + 1, totalPages)),
goToPrev: () => setPage((p) => Math.max(p - 1, 1)),
goToPage: (n) => setPage(Math.min(Math.max(n, 1), totalPages)),
canGoNext: page < totalPages,
canGoPrev: page > 1,
};
}
The return type interface becomes a contract. Internal refactors that preserve that contract don't touch callers; changes that break it fail immediately at the hook definition.
6. Utility Types in Practice
TypeScript ships with utility types that reduce repetition in React codebases. The ones that appear most often in component work are:
Partial<T> — Makes all properties optional, useful for update payloads and default-heavy props:
type UserUpdatePayload = Partial<Pick<User, "name" | "email" | "avatarUrl">>;
Pick<T, Keys> and Omit<T, Keys> — Derive component props from a data model without duplicating fields:
// Derive display props from the full User type
type UserCardProps = Pick<User, "name" | "avatarUrl" | "role">;
ReturnType<T> — Infer the type returned by a hook or function without importing a separate type:
type PaginationState = ReturnType<typeof usePagination>;
Parameters<T> — Extract argument types from a function signature, handy when wrapping third-party callbacks:
type FetchOptions = Parameters<typeof fetch>[1]; // RequestInit
These composing utilities keep types DRY and aligned with the actual source of truth — the function or model they derive from — rather than hand-written duplicates that drift over time.
7. Typing Context Correctly
React context is a place where any tends to sneak in. The two safe patterns are an explicit default value or a deliberately typed undefined default with a guard hook:
interface ThemeContextValue {
theme: "light" | "dark";
toggleTheme: () => void;
}
const ThemeContext = React.createContext<ThemeContextValue | undefined>(undefined);
export function useTheme(): ThemeContextValue {
const ctx = React.useContext(ThemeContext);
if (!ctx) {
throw new Error("useTheme must be used within ThemeProvider");
}
return ctx;
}
The guard hook surfaces a clear error message at runtime if someone forgets the provider, and it returns ThemeContextValue (not ThemeContextValue | undefined), so consumers don't need optional chaining on every access. This pattern pairs well with the folder structure conventions in React Folder Structure for Scalable Apps, where context files typically live in a contexts/ or providers/ directory at the feature level.
8. Event Handler Types
Inline event handlers in JSX carry their own typing requirements. React exports ChangeEvent, MouseEvent, FormEvent, and others under the React namespace:
function SearchInput() {
const [query, setQuery] = useState("");
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setQuery(e.target.value);
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
// trigger search
};
return (
<form onSubmit={handleSubmit}>
<input value={query} onChange={handleChange} placeholder="Search…" />
<button type="submit">Go</button>
</form>
);
}
Using the specific event type narrows e.target to the correct element, giving you access to value, checked, and other element-specific properties without a cast.
Putting It Together
The TypeScript React component types that matter most share a trait: they encode intent, not just shape. Discriminated unions say "exactly one of these variants is valid." Generic components say "whatever type you pass in, it flows through unchanged." Explicit hook return types say "this is the contract, implementation details are hidden."
Taken together these patterns mean that a renamed field, a removed prop, or a wrong variant produces a compiler error instead of a runtime bug. That's the shift from TypeScript as documentation to TypeScript as a safety net — and it's the main reason teams that invest in precise types ship fewer regressions as codebases grow.
For further reading on the security implications of the data flowing through these components, React Form Security Best Practices for Safer User Input covers how TypeScript types complement runtime validation when accepting user input. For the one place where class components remain essential — error boundaries — React Suspense and Error Boundaries: A Complete Practical Guide covers how to type boundary props, fallback callbacks, and the reset function so the compiler enforces the contract.