What you'll learn
Quick Answer
Type props with a plain type or interface and destructure them in the parameter list rather than using React.FC. Declare children explicitly as React.ReactNode. Give useState an explicit generic whenever the initial value is an empty array or null, because inference produces never[] or null. Type event handlers by writing them inline first and copying the inferred type. For DOM refs, always pass null to useRef and null-check current before use.
Typing props without React.FC
The simplest correct way to type a component is to type its parameter object. Nothing else is required.
type ButtonProps = {
label: string;
variant?: "primary" | "ghost";
onClick: () => void;
};
export function Button({ label, variant = "primary", onClick }: ButtonProps) {
return (
<button className={variant} onClick={onClick}>
{label}
</button>
);
}Notice variant as a union of two string literals rather than string. That is where TypeScript actually earns its place in a React codebase: the editor now autocompletes the two valid values and a typo becomes a compile error instead of a button with no styling.
You will see React.FC<ButtonProps> in older tutorials. Avoid it, and understand why the advice is contradictory online. React.FC used to include children implicitly, so a component that accepted no children still type-checked when someone passed children into it. The @types/react 18 typings removed that implicit children, so tutorials written before and after that change describe genuinely different behaviour. The plain function signature avoids the whole question.
Declare children explicitly when you want them:
type CardProps = {
title: string;
children: React.ReactNode;
};
export function Card({ title, children }: CardProps) {
return (
<section>
<h3>{title}</h3>
{children}
</section>
);
}Use React.ReactNode, not JSX.Element. ReactNode covers elements, strings, numbers, arrays, null and undefined. JSX.Element covers a single element only, so typing children that way rejects <Card>Hello</Card> and rejects {isOpen && <Panel />}, because that expression can evaluate to false. You will spend an hour on that error before realising the type was too narrow all along.
For components wrapping a native element, inherit its props so className, disabled, aria-label and the rest pass through without you listing them:
type ButtonProps = React.ComponentProps<"button"> & {
variant?: "primary" | "ghost";
};
export function Button({ variant = "primary", ...rest }: ButtonProps) {
return <button data-variant={variant} {...rest} />;
}
useState and the never[] problem
This is the first error nearly everyone hits, and the message is baffling until you know the rule.
const [items, setItems] = useState([]);
setItems([{ id: 1, name: "DSA" }]);
// Error: Type '{ id: number; name: string; }' is not assignable to type 'never'You never wrote never anywhere. TypeScript inferred the state type from the initial value, and an empty array literal has no elements to infer an element type from, so it settles on never[]: an array that can never contain anything. Every subsequent update is then an error.
The fix is an explicit generic argument:
type Course = { id: number; name: string };
const [items, setItems] = useState<Course[]>([]);
setItems([{ id: 1, name: "DSA" }]); // fineThe same rule produces the second most common version of this. useState(null) infers the type null, so assigning a real value later fails. Write the union you actually mean:
const [user, setUser] = useState<User | null>(null);
// and now the compiler forces the check you would have forgotten
if (!user) return <p>Loading...</p>;
return <p>{user.name}</p>;That forced null check is the entire benefit. In plain JavaScript, rendering user.name before the fetch resolves is a blank screen and a console error that only some users see.
When the initial value is a non-empty literal, inference is usually fine and an explicit generic just adds noise. useState(0), useState("") and useState(false) all infer correctly. Be aware of one exception: useState("free") infers string, not the literal "free", so if the state is meant to hold a union you must say so with useState<Plan>("free").
One more sharp edge. useState treats a function argument as a lazy initialiser, so storing a function in state needs an extra wrapper:
const [value, setValue] = useState(expensiveSetup); // calls it once, stores the result
const [handler, setHandler] = useState(() => expensiveSetup); // stores the function itselfThe first line is the correct pattern for expensive initial computation and the second is what you need for a callback in state. TypeScript infers different types for the two, which is often the first clue that you wrote the wrong one.
Event types: stop guessing them
Nobody memorises React event types, and you do not have to. Write the handler inline, where TypeScript infers the parameter from the JSX attribute, then hover it and copy what you see.
<input onChange={(e) => setQuery(e.target.value)} />
// e is React.ChangeEvent<HTMLInputElement>, inferred for freeOnly when you extract the handler does the annotation become necessary, because a standalone function has no context to infer from:
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
setQuery(e.target.value);
}
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
// ...
}The handful worth recognising are React.ChangeEvent for inputs, selects and textareas, React.FormEvent<HTMLFormElement> for submit, React.MouseEvent<HTMLButtonElement> for clicks, and React.KeyboardEvent for key handlers. The generic parameter is the element the handler is attached to.
Two traps. The first is target versus currentTarget. On a form submit, e.currentTarget is the form element and is typed as such, while e.target is typed as a generic EventTarget with no value or elements property. This is not the type authors being awkward: at runtime e.target is whatever element the event originated on, which for a click inside a button could be the icon <span> rather than the button. The loose type reflects a real uncertainty. Read e.currentTarget when you want the element you attached the handler to.
The second is that React events are synthetic wrappers, not DOM events. A function typed (e: Event) => void will not fit an onClick slot, and a React.MouseEvent will not fit addEventListener. If you need the underlying DOM event, it is available as e.nativeEvent.
function onKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === "Enter") {
e.preventDefault();
search(e.currentTarget.value);
}
}Note e.key === "Enter" rather than the deprecated keyCode. The typings still expose keyCode for compatibility, which is exactly why people copying old Stack Overflow answers keep using it.
useRef has two jobs and two different types
useRef is used for two unrelated things, and its typings distinguish them by what you pass as the initial value. Getting this wrong produces errors that look like the type system being pedantic.
Job one is holding a DOM element. Always pass null explicitly:
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus();
}, []);
return <input ref={inputRef} />;The optional chaining is not optional. current is typed HTMLInputElement | null because on the first render, before React attaches the ref, it genuinely is null. Writing inputRef.current.focus() gives Object is possibly 'null', and that error is correct: run the same code in a render path where the input is conditionally hidden and it will throw.
If you write useRef<HTMLInputElement>() with no argument, the @types/react 18 typings give you a mutable ref whose type includes undefined rather than null, which does not match what React's ref attribute expects and produces a long, unhelpful assignability error. The React 19 typings dropped the zero-argument overload entirely, so the same line becomes Expected 1 arguments, but got 0. Either way, pass null.
Job two is holding a mutable value that survives re-renders without causing one. Timer handles are the usual case, and they have a genuine cross-platform wrinkle:
// browser setTimeout returns number; Node's returns a Timeout object
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
function debounceSearch(q: string) {
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => search(q), 300);
}Hardcoding useRef<number | null>(null) works in the browser and breaks the moment the file is type-checked with Node types in scope, which happens in tests and in server-rendered projects. ReturnType<typeof setTimeout> is correct on both.
One thing TypeScript cannot warn you about: refs are not reactive. Assigning to .current does not re-render the component. If your UI is not updating after you change a ref, the types are all fine and the concept is wrong. That value belongs in state.
The React and TypeScript errors you will actually hit
Type 'string | undefined' is not assignable to type 'string'. This comes from anything that might not be there: process.env.API_URL, a route parameter from useParams, or a query string value. The compiler is right, because the environment variable really can be missing. Validate it once at startup and export a checked value, rather than adding ! at fifty call sites.
Property 'children' does not exist on type 'CardProps'. You passed children to a component that never declared them. Add children: React.ReactNode to the props type.
Type '{ size: number; }' is not assignable to type 'IntrinsicAttributes & ButtonProps'. You passed a prop the component does not accept, usually after a rename. TypeScript applies excess property checking to object literals and JSX attributes, which is what makes this catchable at all.
Object is possibly 'null'. Refs before mount, and document.getElementById, which is declared as returning HTMLElement | null because the element genuinely might not exist.
Generic components have their own trap that is specific to .tsx files:
// In a .tsx file, <T> on an arrow function parses as a JSX tag
const List = <T,>({ items, render }: {
items: T[];
render: (item: T) => React.ReactNode;
}) => {
return <ul>{items.map((item, i) => <li key={i}>{render(item)}</li>)}</ul>;
};The trailing comma in <T,> is what tells the parser this is a type parameter and not the opening of a JSX element. Writing <T extends unknown> has the same effect. A plain function declaration has no such ambiguity, which is one small reason to prefer it for generic components.
Finally, when an error message is a wall of nested generics, read it bottom up. The last line is usually the actual mismatch and everything above it is the path the compiler took to get there. Copying the whole thing into a search box rarely helps; copying the final Type X is not assignable to type Y line almost always does.
