TypeScript with React
Props describe the component call
A function component receives one props object. Give that object a type so JSX callers are checked against the component's public API.
type ProfileCardProps = {
name: string;
compact?: boolean;
};
function ProfileCard({ name, compact = false }: ProfileCardProps) {
return <article data-compact={compact}>{name}</article>;
}
<ProfileCard name="Ada" />;
name is required. The ? makes compact optional, and the parameter default supplies the value used when the caller omits it.
Wrong "A component needs React.FC before TypeScript can check its props."
Right A typed function parameter is enough. React's TypeScript guide shows components declared as ordinary functions whose parameters use an object type.
Choose a children type from the accepted values
React.ReactNode is the broad choice for a component that renders whatever a caller places between its tags. It includes JSX elements and primitives such as strings and numbers. React.ReactElement is narrower and represents JSX elements rather than those primitives.
type PanelProps = {
heading: string;
children: React.ReactNode;
};
function Panel({ heading, children }: PanelProps) {
return (
<section>
<h2>{heading}</h2>
{children}
</section>
);
}
<Panel heading="Account">Plain text is valid here.</Panel>;
If a component requires one JSX element and does not accept text, encode the narrower contract.
type OneElementProps = {
children: React.ReactElement;
};
The React documentation also calls out a limit: TypeScript cannot express that children must be a particular JSX element type, such as accepting only <li> elements.
Let hooks infer simple values
React's type definitions infer many hook types from the code you pass. useState(false) produces boolean state, and its setter accepts a boolean or a function that returns a boolean.
const [enabled, setEnabled] = useState(false);
setEnabled(true);
setEnabled(previous => !previous);
With strict null checking enabled, add a type argument when the initial value does not describe every valid later value. An initially empty selection is a common case.
type User = { id: string; name: string };
const [selected, setSelected] = useState<User | null>(null);
Wrong "The setter widens useState(null) when I later pass a user."
Right The initial value participates in inference. Under strict null checking, state that may hold either value needs the User | null union in its declaration.
React events carry the element type
An inline handler is often inferred from the JSX prop. Once the handler is extracted, annotate the React event and the element that receives it.
function SearchBox() {
const [query, setQuery] = useState("");
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
setQuery(event.currentTarget.value);
}
return <input value={query} onChange={handleChange} />;
}
The generic argument is HTMLInputElement, not the type of the input's value. Hovering the JSX handler in an editor is a practical way to find the event type documented by React.
DOM refs start empty
For a DOM ref, name the DOM element type and pass null as the initial value. React sets current after it places the node on the screen and sets it back to null when the node is removed.
function FocusButton() {
const inputRef = useRef<HTMLInputElement>(null);
function focusInput() {
inputRef.current?.focus();
}
return (
<>
<input ref={inputRef} />
<button type="button" onClick={focusInput}>Focus</button>
</>
);
}
As of React 19's TypeScript types, useRef requires an argument. Changing ref.current does not trigger a render, so values that appear in the UI belong in state.
Model valid prop combinations
Several optional fields often describe states that should be separate. This type permits status: "loading" alongside stale data, and it permits status: "success" without data:
type User = { id: string; name: string };
type LooseResultProps = {
status: "loading" | "success" | "error";
data?: User[];
error?: Error;
};
A discriminated union lists the valid shapes. TypeScript recognizes the shared literal property and narrows the props after a check.
type ResultProps =
| { status: "loading" }
| { status: "success"; data: User[] }
| { status: "error"; error: Error };
function Result(props: ResultProps) {
if (props.status === "success") {
return <ul>{props.data.map(user => <li key={user.id}>{user.name}</li>)}</ul>;
}
if (props.status === "error") {
return <p>{props.error.message}</p>;
}
return <p>Loading</p>;
}
Wrong "Optional properties are equivalent because the component checks them at runtime."
Right Optional properties widen the set of accepted calls. The union gives each branch its own fields, rejects mismatched fresh object literals and direct JSX props, and narrows after a status check. It does not validate untyped data arriving at runtime.
Reducer types belong at the reducer boundary
React's TypeScript guide recommends typing the initial state and the reducer's parameters and return value. A union of action objects lets the type field narrow each payload.
type State = { count: number };
type Action =
| { type: "increment"; by: number }
| { type: "reset" };
const initialState: State = { count: 0 };
function reducer(state: State, action: Action): State {
switch (action.type) {
case "increment":
return { count: state.count + action.by };
case "reset":
return initialState;
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<button onClick={() => dispatch({ type: "increment", by: 1 })}>
{state.count}
</button>
);
}
Here useReducer can infer from the reducer and initial state. The annotations also check the reducer outside the hook call, including its return type.
Generic components preserve a relationship
A reusable list does not need to know the item type in advance. It does need to keep the array item, key callback, and render callback on the same type parameter.
type ListProps<T> = {
items: T[];
getKey: (item: T) => React.Key;
renderItem: (item: T) => React.ReactNode;
};
function List<T>({ items, getKey, renderItem }: ListProps<T>) {
return (
<ul>
{items.map(item => (
<li key={getKey(item)}>{renderItem(item)}</li>
))}
</ul>
);
}
const users: User[] = [{ id: "1", name: "Ada" }];
<List
items={users}
getKey={user => user.id}
renderItem={user => user.name}
/>;
TypeScript can infer T as User from items. Both callbacks then receive User. Replacing T with any would accept the values but discard this relationship, which is the information generics preserve.
Context needs an honest default
createContext requires a default value. React uses that static fallback only when there is no matching provider above the reader. When no meaningful default exists, include null in the type and remove it with a runtime check in a custom hook.
type SessionContextValue = {
user: User;
signOut: () => void;
};
const SessionContext = createContext<SessionContextValue | null>(null);
function useSession(): SessionContextValue {
const value = useContext(SessionContext);
if (value === null) {
throw new Error("useSession must be used inside SessionProvider");
}
return value;
}
This check turns a missing provider into a direct runtime error. It also narrows the hook's return type to SessionContextValue, so every consumer does not repeat a null check.
Extracted handlers and refs
React documents both an event object type and a handler function type. Choose the shape that matches what you are annotating.
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
console.log(event.currentTarget.value);
}
const handleClick: React.MouseEventHandler<HTMLButtonElement> = event => {
console.log(event.currentTarget.disabled);
};
For stored mutable values, the generic describes current. For DOM nodes, the node can be absent, so initialize with null and guard before use.
const requestIdRef = useRef<number | null>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
buttonRef.current?.focus();
Mutating either ref does not ask React to render again.
A component type should preserve its invariants
The difficult part of typing a component is usually a relationship between values. This API accepts unrelated callback inputs, so a caller can provide users and accidentally render products:
type Product = { sku: string; title: string };
type BrokenTableProps = {
rows: any[];
renderRow: (row: Product) => React.ReactNode;
};
A type parameter captures the row type once and carries it through every related prop.
type TableProps<T> = {
rows: T[];
getKey: (row: T) => React.Key;
renderRow: (row: T) => React.ReactNode;
};
function Table<T>({ rows, getKey, renderRow }: TableProps<T>) {
return (
<div>
{rows.map(row => (
<div key={getKey(row)}>{renderRow(row)}</div>
))}
</div>
);
}
TypeScript generics preserve type information across inputs and outputs. In JSX, inference often gets T from rows; callers can also pass an explicit type argument when inference lacks enough information.
type User = { id: string; name: string };
<Table<User>
rows={[]}
getKey={user => user.id}
renderRow={user => <span>{user.name}</span>}
/>;
Wrong "any is as expressive as a generic because both accept every item type."
Right any discards the type information. A generic captures the type chosen for this call and reuses that same type wherever the relationship matters.
Model mutually exclusive prop modes
Discriminated unions belong in component APIs when one prop controls which other props exist. TypeScript narrows a union when every member has a shared property with distinct literal values.
type DialogProps =
| {
mode: "controlled";
open: boolean;
onOpenChange: (open: boolean) => void;
}
| {
mode: "trigger";
trigger: React.ReactElement;
};
function Dialog(props: DialogProps) {
switch (props.mode) {
case "controlled":
return props.open ? (
<div role="dialog">
<button onClick={() => props.onOpenChange(false)}>Close</button>
</div>
) : null;
case "trigger":
return <div>{props.trigger}</div>;
default: {
const unreachable: never = props;
return unreachable;
}
}
}
The never assignment is an exhaustiveness check. If another union member is added without a matching branch, that member is no longer assignable to never, and TypeScript reports an error.
The union is not an exact-object guarantee. TypeScript applies excess-property checks to fresh object literals, while its structural assignability rules can admit a pre-existing value with additional fields. The discriminant still controls which fields the component may read after narrowing.
Children types cannot inspect JSX identity
React.ReactNode describes the broad set of renderable children. React.ReactElement narrows that to JSX elements. React's TypeScript documentation states that the type system cannot enforce a contract such as "only <li> children."
If a component needs structured input with a known type, use an ordinary data prop and let the component create the elements.
type MenuItem = {
id: string;
label: string;
};
type MenuProps = {
items: MenuItem[];
};
function Menu({ items }: MenuProps) {
return (
<ul>
{items.map(item => <li key={item.id}>{item.label}</li>)}
</ul>
);
}
This API checks the structure TypeScript can observe directly instead of claiming it can recover a rendered child's component identity.
React 19 ref types changed the boundary
React 19 lets a function component receive ref as a prop. A DOM exposing component can state the element type with React.Ref<HTMLInputElement> and pass the ref through.
type SearchInputProps = {
label: string;
ref?: React.Ref<HTMLInputElement>;
};
function SearchInput({ label, ref }: SearchInputProps) {
return (
<label>
{label}
<input ref={ref} />
</label>
);
}
forwardRef remains relevant to React 18 and older component code. The current React documentation marks it as unnecessary in React 19 and says it will be deprecated in a future release.
React 19's TypeScript types also require an initial argument for useRef. Passing null for a DOM node produces a ref whose current value includes null, matching the period before React attaches the node and after it removes the node.
function Form() {
const inputRef = useRef<HTMLInputElement>(null);
function focus() {
inputRef.current?.focus();
}
return <SearchInput label="Query" ref={inputRef} />;
}
Changing current does not cause a render. That contract makes refs suitable for imperative DOM access and values that do not participate in the visual output.
Context types must match the fallback
The value passed to createContext determines the fallback used when no provider exists. That fallback is static. A provider with value={undefined} does not activate it; consumers receive undefined from that provider.
When the application has no valid fallback, a fabricated object or assertion hides a missing provider:
type Auth = { user: User; signOut: () => void };
const AuthContext = createContext<Auth | null>(null);
function useAuth(): Auth {
const auth = useContext(AuthContext);
if (auth === null) {
throw new Error("useAuth must be used inside AuthProvider");
}
return auth;
}
The runtime check and the type agree. Consumers get Auth, while an invalid tree fails at the point where context is read.
dig deeper
Primary sources behind these notes - the specs and official docs worth reading in full.
beta
The interviewer part is in the works.
The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.