TypeScript catches the bugs that are tedious to find by hand: a prop renamed in one place but not another, undefined where a component expected a string, an API field that's a number in one response and a string in the next. In React projects most of the benefit comes from a small set of patterns. You don't need advanced type gymnastics to get it.
PropsLink to this section
Describe props with a type alias and destructure them in the signature:
type ButtonProps = {
label: string;
variant?: "primary" | "ghost"; // optional, limited to two values
onClick: () => void;
};
export function Button({ label, variant = "primary", onClick }: ButtonProps) {
return (
<button type="button" className={`btn btn--${variant}`} onClick={onClick}>
{label}
</button>
);
}Union types like "primary" | "ghost" are the most useful feature here: a typo becomes a compile error and the editor autocompletes the valid values.
Children and native attributesLink to this section
Use React.ReactNode for anything renderable. To accept all the normal attributes of an element, extend React's built-in prop types:
type CardProps = { title: string; children: React.ReactNode } & React.HTMLAttributes<HTMLElement>;
export function Card({ title, children, ...rest }: CardProps) {
return (
<section {...rest}>
<h2>{title}</h2>
{children}
</section>
);
}
// A link component that accepts every <a> attribute
type LinkProps = React.ComponentPropsWithoutRef<"a"> & { external?: boolean };Avoid React.FC. It adds little, and plain functions with typed props read better and work the same.
State and refsLink to this section
TypeScript infers state types from the initial value, which is fine for primitives:
const [count, setCount] = useState(0); // number
const [query, setQuery] = useState(""); // stringWhen the initial value doesn't cover every possibility, say so explicitly:
type User = { id: number; name: string };
const [user, setUser] = useState<User | null>(null);
const [status, setStatus] = useState<"idle" | "loading" | "error" | "done">("idle");Refs to DOM elements start as null and are filled by React:
const inputRef = useRef<HTMLInputElement>(null);
// later: inputRef.current?.focus();The optional chaining (?.) isn't decoration: the ref really is null before the element mounts.
EventsLink to this section
React wraps native events in its own types. Using them gives you correct target types without casting:
function SearchBox({ onSearch }: { onSearch: (q: string) => void }) {
const [q, setQ] = useState("");
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => setQ(e.target.value);
const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
onSearch(q.trim());
};
return (
<form role="search" onSubmit={onSubmit}>
<label htmlFor="q">Search</label>
<input id="q" value={q} onChange={onChange} />
<button type="submit">Go</button>
</form>
);
}If you write the handler inline (onChange={(e) => setQ(e.target.value)}), TypeScript infers the event type for you.
Reusable components with genericsLink to this section
A list or select that works with any item type is where generics pay off. Without them you end up casting with as:
type SelectProps<T> = {
items: T[];
value: T | null;
getLabel: (item: T) => string;
getKey: (item: T) => string | number;
onChange: (item: T) => void;
};
export function Select<T>({ items, value, getLabel, getKey, onChange }: SelectProps<T>) {
return (
<select
value={value ? String(getKey(value)) : ""}
onChange={(e) => {
const item = items.find((i) => String(getKey(i)) === e.target.value);
if (item) onChange(item);
}}
>
<option value="" disabled>
Choose…
</option>
{items.map((i) => (
<option key={getKey(i)} value={String(getKey(i))}>
{getLabel(i)}
</option>
))}
</select>
);
}
// Usage: T is inferred as Category, and onChange receives a Category
<Select items={categories} value={selected} getLabel={(c) => c.name} getKey={(c) => c.id} onChange={setSelected} />;Custom hooksLink to this section
Type a hook's inputs and let its return type be inferred, or name it when other code depends on it:
export function useDebounced<T>(value: T, delay = 300): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const t = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(t);
}, [value, delay]);
return debounced;
}Return objects rather than arrays from hooks with more than two values; { data, error, isLoading } is easier to use correctly than a tuple.
API data: types are not validationLink to this section
This is the most important part. A TypeScript type is a promise that disappears when the code compiles. If you write:
const post = (await res.json()) as Post; // trust meTypeScript believes you, but the server doesn't have to. A missing field, a null, or a number sent as a string will break the UI at runtime with no warning.
Validate untrusted data at the boundary with a schema library such as Zod, and infer the TypeScript type from the schema so the two can never drift apart:
import { z } from "zod";
const PostSchema = z.object({
id: z.number(),
slug: z.string(),
title: z.object({ rendered: z.string() }),
date_gmt: z.string(),
featured_media: z.number().optional(),
});
export type WpPost = z.infer<typeof PostSchema>;
export async function getPosts(): Promise<WpPost[]> {
const res = await fetch(`${process.env.WORDPRESS_API_URL}/wp-json/wp/v2/posts?_fields=id,slug,title,date_gmt,featured_media`);
if (!res.ok) throw new Error(`WordPress returned ${res.status}`);
return z.array(PostSchema).parse(await res.json());
}If WordPress changes shape, you get a clear error naming the field, at the point the data enters your app, instead of Cannot read properties of undefined somewhere deep in a component. Paste a real response into the JSON formatter when writing the schema, so it matches what the API actually returns.
The same applies to anything outside your control: form submissions, localStorage, URL parameters and decoded tokens. A JWT's payload is just JSON; validate it before trusting a claim.
Next.js specificsLink to this section
- Page props in the App Router receive
paramsandsearchParamsas promises:{ params }: { params: Promise<{ slug: string }> }, thenconst { slug } = await params. - Server Actions receive
FormData; validate it with a schema the same way as API data. - Environment variables are
string | undefined. Read and validate them once in a config module rather than sprinklingprocess.env.X!through the code.
Settings worth turning onLink to this section
In tsconfig.json, "strict": true is the baseline. Two more options catch real bugs:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true
}
}noUncheckedIndexedAccess makes items[0] typed as Item | undefined, which forces you to handle empty arrays, a very common source of crashes.
Typing data from a headless WordPress API end to endLink to this section
Putting the pieces together, here is the shape of a typed data flow on a headless WordPress site, from the API to the page:
- Schema at the boundary. A Zod schema describes the raw REST or GraphQL response, only the fields you request, and
z.infergives you the raw type. - Mapping function. A function converts the raw type into your own clean type, for example turning
title.renderedintotitleand parsing dates into ISO strings. Its signature,(raw: WpPost) => Post, documents the conversion. - Data functions.
getPost(slug): Promise<Post | null>fetches, validates and maps. Returningnullfor "not found" makes the page handle that case explicitly withnotFound(). - Components receive
Postand never see the raw API shape.
export type Post = { slug: string; title: string; date: string; html: string };
export function toPost(raw: WpPost): Post {
return { slug: raw.slug, title: raw.title.rendered, date: new Date(raw.date_gmt + "Z").toISOString(), html: raw.content.rendered };
}With this structure, a change in the API is caught in one place: either the schema rejects the response, or the mapping function fails to compile. Components don't change unless your own Post type changes, which you control.
Typing the rest of the appLink to this section
The same idea applies to other inputs. Route parameters are strings that may not match any record; search parameters may be missing or repeated; form data is always strings. Parse each into a typed value once, close to where it enters, and the rest of the code can trust its types.
Common mistakesLink to this section
anyto silence errors. Useunknownand narrow it, or fix the type.anyswitches off checking for everything it touches.- Non-null assertions everywhere.
user!.namehides a realnullcase; handle it instead. - Casting API responses with
as. Validate them. - Over-engineering types. If a type takes longer to read than the component, simplify it.
Frequently asked questionsLink to this section
Should a WordPress theme's React code use TypeScript?Link to this section
It can. @wordpress/scripts supports TypeScript through Babel, which strips types without checking them; add tsc --noEmit to your build or CI to actually check them.
type or interface?Link to this section
Either works for props. type is slightly more flexible (unions, intersections), so many React codebases use it everywhere for consistency.
Does TypeScript slow down the app?Link to this section
No. Types are removed at build time. The only runtime cost is validation you add deliberately, such as Zod, which is small and well worth it at API boundaries.
How do I migrate an existing JavaScript project?Link to this section
Rename files one at a time from .jsx to .tsx, starting with leaf components and data utilities, with strict on from the beginning for new files.
What should I learn after these patterns?Link to this section
Discriminated unions for component states ({ status: "loading" } | { status: "error"; message: string } | { status: "done"; data: Post[] }), utility types such as Pick, Omit and Partial for deriving prop types, and satisfies for checking configuration objects without widening their types. Each removes a whole category of bugs with very little extra code.
Moving a WordPress frontend to React? Start with jQuery to React, then accessible components. Need help on a React or Next.js codebase? Get in touch.
Test yourself
3 quick questions on this lesson.
Finished this lesson?
Mark it complete to track your progress through the learning paths.