Typing Props: Start Here
React components with TypeScript live in .tsx files — the extra x tells the compiler to parse JSX. Everything else you have learned applies unchanged; a component is a function, and its props are an object, so a props type is just an interface.
That interface is the highest-value type in a React codebase. It documents what the component needs, gives every user of it autocomplete inside the JSX tag, and turns a forgotten required prop into a red underline instead of an undefined rendered on the page. Optional props with ? and unions of string literals for variants together cover most of what a component needs.
You will see two styles for attaching that interface. The older one is const Button: React.FC<ButtonProps>. The plainer one annotates the parameter directly: function Button(props: ButtonProps). Prefer the plain form. It reads like ordinary TypeScript, it works with generic components without extra ceremony, and it avoids the historical confusion around whether React.FC silently adds a children prop — which older versions did and current versions do not. Declaring children yourself when you want it is clearer either way.
interface ButtonProps {
label: string;
onClick: () => void;
variant?: "primary" | "secondary" | "danger";
disabled?: boolean;
}
function Button({ label, onClick, variant = "primary", disabled = false }: ButtonProps) {
return (
<button className={`btn btn-${variant}`} onClick={onClick} disabled={disabled}>
{label}
</button>
);
}
// Every mistake is caught at the tag
<Button label="Save" onClick={handleSave} /> // OK
<Button label="Save" /> // ERROR: onClick missing
<Button label="Save" onClick={handleSave} variant="blue" /> // ERROR: not a variant
<Button lable="Save" onClick={handleSave} /> // ERROR: unknown prop 'lable' - The excess property check from the interfaces lesson is what catches
lable. JSX attributes are checked like an object literal, which is exactly the behaviour you want here.
children, and Extending Native Elements
If your component wraps content, declare children explicitly. Its type is React.ReactNode, which covers everything React can render: elements, strings, numbers, arrays of those, null and undefined. Do not type it as JSX.Element — that rejects a plain string, and someone will pass one on the first day.
The second pattern is more useful than it looks. A button component that wraps a real <button> should accept the native attributes too — type, aria-label, onFocus — without you listing several hundred of them. React.ComponentProps<"button"> gives you exactly that set, and you intersect it with your own props.
This is where React and TypeScript fit together best. You add the props your design system needs, inherit everything the platform already offers, spread the rest onto the element, and every attribute is still checked. It is also how you avoid the common alternative — typing the spread as any — which quietly disables checking for every attribute a caller passes.
Where two prop combinations are genuinely incompatible, a discriminated union of props expresses that better than a pile of optional fields, exactly as it did for state.
import type { ReactNode, ComponentProps } from "react";
interface CardProps {
title: string;
children: ReactNode; // strings, elements, arrays, null — all valid
}
function Card({ title, children }: CardProps) {
return (
<section className="card">
<h2>{title}</h2>
{children}
</section>
);
}
// Inherit every native button attribute, then add your own
type ButtonProps = ComponentProps<"button"> & {
variant?: "primary" | "secondary";
};
function Button({ variant = "primary", ...rest }: ButtonProps) {
return <button className={`btn btn-${variant}`} {...rest} />;
}
<Button type="submit" aria-label="Save" onFocus={() => {}}>Save</Button>
// Props that cannot coexist: a union, not four optional fields
type AlertProps =
| { kind: "info"; message: string }
| { kind: "error"; message: string; retry: () => void }; ComponentPropsWithoutRef<"button">is the variant to use when your component does not forward aref. It is the same set of props withrefremoved, which prevents callers passing one that silently goes nowhere.
useState: When Inference Is Not Enough
useState is a generic function, so it usually infers the state type from the initial value and you write nothing. useState(0) gives you a number and a setter that only accepts numbers; useState("") gives you a string.
Inference fails in exactly one common situation, and it is the one you hit on your first real screen: state that starts empty and fills in later. useState(null) infers the type null, so the setter will not accept a student object, and the error appears at the line where you set the data rather than at the declaration. The same applies to useState([]), which infers an array of nothing useful.
The fix is to supply the type argument yourself: useState<Student | null>(null) and useState<Student[]>([]). Once you have done it twice it becomes automatic.
There is a better option than several related pieces of state. A screen with data, loading and error as three separate useState calls can represent combinations that make no sense. One state variable holding a discriminated union cannot, and the JSX that renders it becomes a single switch with no defensive checks.
import { useState } from "react";
// Inferred — nothing to write
const [count, setCount] = useState(0); // number
const [query, setQuery] = useState(""); // string
// Inference fails: this infers null, and nothing else can ever be set
const [student, setStudent] = useState(null);
// setStudent({ id: 1, name: "Ananya" }); ERROR
// Say what it will hold
const [student2, setStudent2] = useState<Student | null>(null);
const [list, setList] = useState<Student[]>([]);
// Better than three separate booleans and optionals
type LoadState =
| { status: "loading" }
| { status: "error"; message: string }
| { status: "ready"; students: Student[] };
function StudentList() {
const [state, setState] = useState<LoadState>({ status: "loading" });
if (state.status === "loading") return <p>Loading...</p>;
if (state.status === "error") return <p>{state.message}</p>;
return <ul>{state.students.map(s => <li key={s.id}>{s.name}</li>)}</ul>;
} - The same rule applies to
useReducer: type the action as a discriminated union and the reducer'sswitchnarrows each case automatically, including the payload fields that only exist on some actions.
Refs and Effects
useRef has two distinct jobs and the typing differs between them. For a DOM reference, write useRef<HTMLInputElement>(null) and attach it to the element. The current property is then typed HTMLInputElement | null, because on the very first render React has not attached anything yet. That null is not TypeScript being awkward — it is describing a real moment in the component's life, and the check you are forced to write is the check you actually need.
For the other job — holding a mutable value across renders, such as a timer id or a previous value — pass a real initial value and the ref is simply mutable from the start.
Effects need less typing than people expect. The callback takes no arguments, and its return value is either nothing or a cleanup function, which TypeScript already knows. What TypeScript will not do is check your dependency array; that is what the ESLint rule react-hooks/exhaustive-deps is for, and it is worth enabling.
One typing detail catches people in effects: the callback passed to useEffect must not be async, because an async function returns a promise and React expects either nothing or a cleanup function. Declare the async function inside and call it.
import { useEffect, useRef, useState } from "react";
function SearchBox() {
const inputRef = useRef<HTMLInputElement>(null);
const timerRef = useRef<number | undefined>(undefined);
useEffect(() => {
// current is possibly null on the first render — check it
inputRef.current?.focus();
}, []);
// Wrong: an async callback returns a Promise, not a cleanup function
// useEffect(async () => { await load(); }, []);
// Right: declare inside, then call
useEffect(() => {
let cancelled = false;
async function load() {
const res = await fetch("/api/students");
const data: unknown = await res.json();
if (!cancelled && isStudentArray(data)) setList(data);
}
load();
return () => { cancelled = true; }; // cleanup
}, []);
return <input ref={inputRef} />;
} - In the browser,
setTimeoutreturns a number, while Node's version returns aTimeoutobject. If a timer ref gives you a confusing type error, the cause is usually that@types/nodeis in scope and the Node overload has been picked.
Events, Forms, and target versus currentTarget
React's event types are generic over the element they came from: React.ChangeEvent<HTMLInputElement>, React.FormEvent<HTMLFormElement>, React.MouseEvent<HTMLButtonElement>. You need them only when you write the handler as a separate named function. Write the handler inline in the JSX and contextual typing supplies the event type for free — which is a good reason to prefer inline handlers for short ones.
The distinction worth learning is between currentTarget and target. currentTarget is the element the handler is attached to, and it is typed as that element. target is whatever the event originated on, which for a click inside nested markup may be a child element — so for mouse events React types it only as a general EventTarget, with no value and no className.
Change events are the friendly exception: ChangeEvent<HTMLInputElement> types target as the input, so the familiar e.target.value works and is fully checked. When you are on a mouse event and reach for e.target.value, use e.currentTarget instead rather than reaching for a type assertion.
Form submission is the usual place beginners meet all of this at once, and the pattern is small: type the event, call preventDefault, read your state.
import { useState } from "react";
function SearchForm() {
const [query, setQuery] = useState("");
// Named handler: the event type is required
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
setQuery(e.target.value); // target is the input here
}
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
console.log("Searching for", query);
}
function handleClick(e: React.MouseEvent<HTMLButtonElement>) {
// e.target.value; ERROR: 'value' does not exist on type 'EventTarget'
console.log(e.currentTarget.name); // the button itself — typed
}
return (
<form onSubmit={handleSubmit}>
<input value={query} onChange={handleChange} />
{/* Inline: no annotation needed, e is inferred */}
<input onChange={e => setQuery(e.target.value)} />
<button type="submit" name="go" onClick={handleClick}>Search</button>
</form>
);
} - If you cannot remember an event type, write the handler inline first, hover over the parameter to read the type your editor infers, then copy it into a named function. That trick works for every React type, not just events.
The Trap Every React Codebase Falls Into
Look again at the line almost every React tutorial contains: const data: User[] = await res.json();. It compiles, it looks careful, and it checks nothing at all. res.json() produces any, and the annotation is a declaration of intent, not a verification. Every component downstream is now fully typed on the basis of a guess.
This matters more in React than almost anywhere else, because the guess spreads. The data flows into state, into props, into a dozen components, each of which is typed and autocompleted and looks safe. The day the API renames a field, the compiler stays silent and a component crashes at render.
The fix is the boundary discipline from the type-guards lesson, applied at the one place data enters: type the parsed body as unknown, validate it, and only then put it into state. Do it once per endpoint, in a data-fetching function, and the rest of the component tree inherits real safety instead of assumed safety.
- Type props with an interface; prefer a plain function over
React.FC - Declare
childrenyourself, asReactNode - Use
ComponentProps<"element">to inherit native attributes instead of listing or ignoring them - Give
useStatean explicit type argument whenever the initial value isnullor an empty array - Model screen state as one discriminated union rather than several booleans
- Validate API responses at the fetch, not by annotating
res.json() - Let inline handlers infer their event types; annotate only named handlers
- React's own types live in the
@types/reactpackage and are updated alongside React itself. If a hook or a prop type behaves differently from a tutorial you are reading, check which major version of React — and of its types — the tutorial was written for.
