TypeScript with React: Props, Children, State, and Events
React is where most developers first feel TypeScript being useful rather than bureaucratic. A component has props, state, events, and increasingly a server and client split, and every one of those is a boundary that used to live in someone's head or a stale comment.
This guide covers the JSX configuration, prop types, children and native element props, hooks and refs, event handlers, and what types can and cannot enforce across the server boundary.
Setting Up JSX in tsconfig.json
Two settings decide how a .tsx file compiles:
{
"compilerOptions": {
"jsx": "react-jsx",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true
}
} "jsx": "react-jsx" selects the automatic runtime, which is why modern React files do not import React just to use JSX. Older projects on "react" still need that import in every file with markup, unless a custom jsxFactory changes which binding the emitted code calls, and mixing the two is a common cause of an unexplained "React is not defined" error.
jsx controls JSX checking and emit. The lib entry matters whenever your code or its declarations use DOM names: without the DOM libraries, types such as HTMLInputElement and Event do not exist, and the errors read as missing names rather than as React problems. Custom non-DOM JSX compiles without them, and frameworks usually set both for you.
Typing Component Props
A component is a function, so its props are a parameter type. Naming that type is nearly always worth it:
type UserCardProps = {
user: { id: string; label: string };
onSelect: (userId: string) => void;
isSelected?: boolean;
};
export function UserCard({ user, onSelect, isSelected = false }: UserCardProps) {
return (
<button type="button" aria-pressed={isSelected} onClick={() => onSelect(user.id)}>
{user.label}
</button>
);
} The return type is left to inference here, which is the usual style for components: the inferred JSX element type is more precise than most annotations people write by hand. The props parameter, by contrast, is a standalone function parameter with no context to infer from, so it carries the annotation.
Optional props pair naturally with defaults. Reads of isSelected? give boolean | undefined, and the default in the destructuring makes it plain boolean inside the body, so no branch has to handle the missing case.
Under exactOptionalPropertyTypes, which the tsconfig guide recommends, callers may leave the prop out but cannot pass undefined explicitly unless the declared type includes it.
Keep prop types next to the component unless they are genuinely shared. A domain type belongs in a domain module, and a UI type belongs with the UI. The failure mode at the other extreme is a single types.ts that everything imports and nobody can navigate.
Children and Native Element Props
Children are an ordinary prop with a type from React. Wrapping a native element is where the ergonomics really show:
import type { ComponentProps, ReactNode } from "react";
type PanelProps = {
title: string;
children: ReactNode;
};
export function Panel({ title, children }: PanelProps) {
return (
<section>
<h2>{title}</h2>
{children}
</section>
);
}
type SubmitButtonProps = ComponentProps<"button"> & {
loading?: boolean;
};
export function SubmitButton({ loading = false, disabled, ...rest }: SubmitButtonProps) {
return <button type="submit" disabled={loading || disabled} {...rest} />;
} Note the order of operations on disabled: it is destructured out and combined with loading, because a bare {...rest} spread after disabled={loading} would let a caller passing disabled={false} re-enable a button that is mid-submit.
ReactNode covers what React can render, including strings, numbers, elements, arrays, null, and undefined. Use it for children unless the component genuinely requires a single element, in which case ReactElement is the narrower choice.
ComponentProps<"button"> pulls in every attribute the DOM button accepts, so callers get aria-label, onClick, and the rest without you listing them. That keeps a design-system wrapper honest as the underlying element gains attributes, and it means a typo in an attribute name is still an error.
State, Hooks, and Refs
Most hooks infer from what you pass them. The exceptions are the cases where the initial value carries no information:
import { useRef, useState } from "react";
const [query, setQuery] = useState("");
const [articles, setArticles] = useState<Article[]>([]);
const [selected, setSelected] = useState<Article | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
function focusSearch() {
inputRef.current?.focus();
} useState("") infers string and needs nothing. useState([]) infers never[] under strictNullChecks, or any[] without it, so the explicit Article[] is what lets you set items later. A null initial value tells the same story: without the type argument, the state can only ever be null.
Refs are typed to the element and start as null, because React has not attached anything on the first render. The optional chain above is the honest way to use one; asserting the null away with ! works until a render happens where the element is absent.
For state with several related fields, a reducer plus a discriminated union usually beats a pile of useState calls, since the union names which combinations are legal. The unions and narrowing guide covers the modelling side of that.
Event Handlers
Inline handlers rarely need annotations, because the JSX attribute already has a type:
import type { ChangeEvent } from "react";
export function SearchInput({ onQueryChange }: { onQueryChange: (value: string) => void }) {
// inline: the event type comes from the onChange attribute
return <input onChange={(event) => onQueryChange(event.target.value)} />;
}
// extracted: the context is gone, so the annotation comes back
function handleChange(event: ChangeEvent<HTMLInputElement>) {
console.log(event.target.value);
} This is contextual typing doing the same job it does anywhere else. The moment a handler moves out of the JSX into a named function, the context disappears and you supply the type yourself, parameterised by the element so event.target.value is known to be a string.
React's event types are its own synthetic events rather than the DOM ones, which is why ChangeEvent is imported from react. Reaching for the global Event type instead produces mismatches that read confusingly, since both names exist.
The Server and Client Boundary
In full-stack frameworks, some components render on the server and some in the browser, and data crossing that line has constraints TypeScript does not model. A prop type says nothing about whether a value can be serialized, so an ordinary function or a class instance passed from a server component to a client one type-checks perfectly and fails elsewhere. Server Functions marked "use server" are the deliberate exception, and React allows them across the boundary.
The practical defence is shape, not annotation: convert domain objects into narrow, plainly serializable props at the boundary.
type ArticleListItem = {
id: string;
title: string;
wordCount: number;
};
function toArticleListItem(article: Article): ArticleListItem {
return { id: article.id, title: article.title, wordCount: article.wordCount };
} That adapter keeps database-shaped objects out of the UI and gives the component exactly the fields it renders. It also makes the boundary visible in the code, which matters because the compiler will not point at it: what the checker can enforce is that the component receives the shape it declared, not that the shape was safe to send.
Pitfalls and Debugging
State typed never[]. The signature of an empty initial value under strictNullChecks; without that flag the same call gives any[], and {} and null infer their own unhelpful types. Pass the type argument to useState in all three cases.
Passing a whole domain object as a prop. It type-checks, and it couples the component to your database schema. Across a serialization boundary it also ships fields the UI never renders and can break outright, while between ordinary components no copy is sent at all. A narrow prop type is the fix.
Asserting on refs and query results. inputRef.current! and document.querySelector(...) as HTMLInputElement both silence a real possibility rather than handling it. Guard with a check; the null case is not hypothetical.
Mismatched JSX settings across a workspace. One package on "jsx": "react" and another on "react-jsx" produces missing-import errors that look like a React problem. The tsconfig guide covers keeping a shared base config.
Frequently Asked Questions
Should you type a component as React.FC?
It is a preference rather than a rule, and the argument that once settled it is gone: @types/react 18 removed the implicit children prop from React.FC, a declaration-package change rather than a React runtime one. Annotating the props parameter directly is the more common style now, because it reads like any other function and keeps generic components simpler.
Why is my useState value typed as never?
Because the initial value gave inference nothing useful to work with. useState([]) infers never[] under strictNullChecks and any[] without it, while infers the near-useless type and null infers null. Pass the type argument explicitly, as in useState<Article[]>([]), in all of those cases.
Why is a ref possibly null?
Because it genuinely is before React attaches the element, and on any render where the element is not mounted. useRef<HTMLInputElement>(null) types current as the element or null, so guard it with a check inside effects and handlers rather than asserting it away.
Do types stop you passing a function to a client component?
Not by themselves. In React Server Components a prop crossing from a server component to a client one must be serializable, and TypeScript has no notion of serializability, so an ordinary function-typed prop type-checks and fails later. Server Functions marked "use server" may cross, and the framework's checks and lint rules cover the rest.
Related
- TypeScript Tooling for the full topic overview
- tsconfig.json for the JSX and library settings React depends on
- API Contracts and Shared Models for the data these components receive
- TypeScript for the language guide and the full learning path
Sources
-
[1]
TypeScript with React(react.dev)
-
[2]
TSConfig Reference(typescriptlang.org)
-
[3]
JSX(typescriptlang.org)
Read Next
Modelling a request and response once, why a shared type is not validation, generating types from an OpenAPI or GraphQL schema, and changing a contract without breaking callers.
What tsconfig.json controls, which files actually get checked, what strict mode turns on, the flags that stay off until you name them, and how to share a config across a workspace.
The half of TypeScript that is not types: tsconfig.json, the split between transpiling and type checking, React components, and API contracts between a frontend and its server.