TypeScript with React: Props, Children, State, and Events
TypeScript checks React props, state, refs, and event handlers before the app runs. A component has props, state, events, and increasingly a server and client split, and types make those expectations explicit. This guide builds on TypeScript tooling and the TypeScript language guide.
How Do You Set Up TypeScript for React?
To use TypeScript with React, save JSX files as .tsx, install the React type packages, and choose JSX settings that match your framework. For an existing React project, install typescript, @types/react, and @types/react-dom as development dependencies if they are missing. These settings support the automatic JSX runtime and browser APIs:
{
"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.
How Do You Type React Component Props?
Type React component props with an object type on the function’s props parameter. 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 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 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 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 wrapper’s accepted props aligned with the underlying element’s types, 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 handles that missing element; 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 modeling 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, parameterized 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.
Before passing data from a server component to a client component, create an object containing only the serializable fields the component needs.
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?
React.FC is optional when typing a React component. You can instead annotate its props parameter directly. @types/react 18 removed the implicit children prop from React.FC, a declaration-package change rather than a React runtime one. Direct props annotations also keep generic components simpler.
Why does React useState infer never[] in TypeScript?
React useState can infer never[] in TypeScript when an empty array provides no element type. 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 can a React DOM ref be null in TypeScript?
A React DOM ref can be null before React attaches its element and after the element is removed. 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.
Does TypeScript prevent invalid React Server Component props?
TypeScript alone does not enforce every React Server Component prop restriction. 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.
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.