#TypeScript with React
A React component is a function that takes one argument — the props object — and returns JSX. So typing a component is just typing a function's parameter. The props type is the component's public API: it says which attributes callers must pass, which are optional, and what values each one accepts. When you write <Button variant="primary">, the compiler checks that JSX exactly as if you'd called Button({ variant: "primary", … }).
Two sources of types meet in a .tsx file. Your own components are checked against their parameter types. Built-in elements like <button> and <input> are checked against React.JSX.IntrinsicElements from @types/react, which lists every HTML element and its attributes. Hooks such as useState are generic functions, and events are typed objects (ChangeEvent<HTMLInputElement>) — all ordinary TypeScript.
Props and children
import type { ReactNode } from "react";
type ButtonProps = {
variant?: "primary" | "secondary";
onClick: () => void;
children: ReactNode;
};
export function Button({ variant = "primary", onClick, children }: ButtonProps) {
return (
<button className={`btn btn-${variant}`} onClick={onClick}>
{children}
</button>
);
}
const save = <Button onClick={() => console.log("saved")}>Save</Button>;
const bad = <Button variant="danger" onClick={() => {}}>Delete</Button>; // ❌ TS2322: Type '"danger"' is not assignable to type '"primary" | "secondary" | undefined'.ButtonPropsis a plain object type.variant?is optional and limited to two string literals;onClickis required;children: ReactNodeaccepts anything React can render — strings, numbers, elements, arrays,null,undefined.- The parameter is destructured with a default:
variant = "primary". Inside the function,variantis"primary" | "secondary"— the default removedundefined— so the template literal buildsbtn btn-primaryorbtn btn-secondary. - For
<Button onClick={…}>Save</Button>, the compiler builds the props object{ onClick: …, children: "Save" }— the text between the tags becomes thechildrenprop — and checks it againstButtonProps. Leaving outonClickwould fail because it's required. variant="danger"isn't one of the allowed literals, so the JSX fails with TS2322, pointing at the attribute. The caller sees the mistake in the editor, before anything renders.- No
React.FC, no special wrapper — a typed parameter is all a component needs. The return type is inferred asReact.JSX.Element.
React.FCState, refs and events
import { useRef, useState, type ChangeEvent, type SubmitEvent } from "react";
type User = { id: number; name: string };
export function SearchForm({ onSearch }: { onSearch: (query: string) => void }) {
const [query, setQuery] = useState(""); // string — inferred from ""
const [user, setUser] = useState<User | null>(null); // explicit: null alone would be useState<null>
const [tags, setTags] = useState<string[]>([]); // explicit: [] alone would be never[]
const inputRef = useRef<HTMLInputElement>(null); // RefObject<HTMLInputElement | null>
function handleChange(e: ChangeEvent<HTMLInputElement>) {
setQuery(e.target.value);
}
function handleSubmit(e: SubmitEvent<HTMLFormElement>) {
e.preventDefault();
onSearch(query.trim());
setTags((prev) => [...prev, query]);
inputRef.current?.focus();
}
return (
<form onSubmit={handleSubmit}>
<input ref={inputRef} value={query} onChange={handleChange} />
<button onClick={(e) => console.log(e.clientX)}>Search</button>
{user && <p>Signed in as {user.name}</p>}
<p>{tags.length} searches</p>
</form>
);
}useStateis a generic function:useState<S>(initialState: S | (() => S)). With"",Sis inferred asstring, andsetQueryonly accepts strings. Inference is enough when the initial value already has the full type.- With
null, inference would giveS = null, sosetUser({ id: 1, name: "Ann" })later fails with TS2353 "Object literal may only specify known properties, and 'id' does not exist in type '(prevState: null) => null'". Passing<User | null>explicitly fixes the state's type up front. Same with[]: on its own it infersnever[], andsetTags(["a"])fails with TS2322 "Type 'string' is not assignable to type 'never'". useRef<HTMLInputElement>(null)returnsRefObject<HTMLInputElement | null>in React 19 types..currentisnulluntil React attaches the element, soinputRef.current?.focus()needs the?..ChangeEvent<HTMLInputElement>typese.targetas the input element, soe.target.valueis astring.SubmitEvent<HTMLFormElement>is the form-submit event;e.preventDefault()stops the browser's full-page submit. (Older code usesFormEvent; current@types/reactmarks it deprecated because there's no such DOM event.)- The inline
onClick={(e) => …}needs no annotation:onClickon a<button>is declared asMouseEventHandler<HTMLButtonElement>, soeis contextually typed as aMouseEventande.clientXis anumber(see Contextual typing). Annotate handlers only when you define them separately, likehandleChange. {user && <p>…</p>}narrowsusertoUserinside the expression, souser.nameis safe.
import type { ComponentProps, ReactNode } from "react";
type LabeledInputProps = ComponentProps<"input"> & { label: string };
export function LabeledInput({ label, id, ...rest }: LabeledInputProps) {
return (
<label htmlFor={id}>
{label}
<input id={id} {...rest} />
</label>
);
}
type ListProps<T> = {
items: T[];
getKey: (item: T) => string | number;
render: (item: T) => ReactNode;
};
export function List<T>({ items, getKey, render }: ListProps<T>) {
return <ul>{items.map((item) => <li key={getKey(item)}>{render(item)}</li>)}</ul>;
}
const email = <LabeledInput label="Email" id="email" type="email" required />;
const people = (
<List
items={[{ id: 1, name: "Ann" }, { id: 2, name: "Raj" }]}
getKey={(p) => p.id}
render={(p) => <strong>{p.name}</strong>}
/>
);ComponentProps<"input">looks up"input"inReact.JSX.IntrinsicElementsand returns every prop a native<input>accepts:type,value,onChange,required,aria-*, evenref(a normal prop in React 19). Intersecting with{ label: string }adds yours. Without it, you'd copy dozens of attribute types by hand.{ label, id, ...rest }pulls out the two props this component uses itself;restholds everything else, typed as the remaining input props, and{...rest}forwards them to the real<input>. Sotype="email"andrequiredreach the DOM and are type-checked on the way.ComponentPropsalso works on components:ComponentProps<typeof Button>givesButtonProps— handy when the props type isn't exported.List<T>is a generic component. At<List items={[…]} …>, the compiler infersTfromitemsas{ id: number; name: string }, exactly as it would for a generic function call.- Because
Tis fixed byitems, the callbacks are contextually typed:pingetKeyandrenderis{ id: number; name: string }, sop.nameautocompletes andp.nmaewould fail. This is the caseReact.FCcan't express.