React with TypeScript
TypeScript adds static types to JavaScript, and when you pair it with React you get autocomplete for props, compile-time errors when you pass the wrong data to a component, and self-documenting code that tells you exactly what a hook returns or what an event handler receives. Most production React codebases today are written in TypeScript, usually using the .tsx file extension for files that contain JSX. This lesson assumes you already know function components and hooks — here we layer TypeScript’s type system on top of that mental model rather than re-teaching React itself.
Overview / How it works
TypeScript is a superset of JavaScript: every valid JavaScript file is also valid TypeScript, and the extra syntax you add (interfaces, generics, type annotations) exists only to help the compiler catch mistakes before your code ever runs. When you build a React app, a tool like Vite, Next.js, or the TypeScript compiler (tsc) reads your .tsx files, checks that every prop, state value, and event handler is used consistently with its declared type, and then strips the types out entirely, emitting plain JavaScript. This is important to internalize: types are a compile-time and editor-time tool only. At runtime, React’s render → reconcile → commit cycle, its rules about re-rendering when state changes, and its rule that hooks must run in the same order on every render are completely unaffected by TypeScript. TypeScript does not make React faster or change how the Virtual DOM works — it only catches a category of bugs (wrong prop names, wrong prop types, calling a function that might be undefined) before they become runtime errors.
To write JSX in TypeScript, your build tool needs the jsx compiler option set to react-jsx (the modern setting, matching the automatic JSX runtime used since React 17), and your project needs the @types/react and @types/react-dom packages installed so the compiler knows the shape of React’s own APIs. Tools like create-vite‘s React + TypeScript template configure all of this for you.
TypeScript can often infer types without you writing them explicitly. useState(0) infers number automatically because the initial value is a number, so setCount('five') would immediately be flagged as an error. Inference breaks down in a few common situations: when the initial state is null or an empty array, when a value can be one of several types (a union), or when a function parameter comes from outside your code (like a DOM event). In those cases you provide the type explicitly using a generic, like useState<User | null>(null).
Syntax
The general shapes you will use constantly are: typing props with an interface, typing state with a generic on useState, and typing event handler parameters using React’s built-in event types.
interface ComponentProps {
propName: PropType;
optionalProp?: PropType;
}
function ComponentName({ propName, optionalProp }: ComponentProps) {
// component body
}
const [state, setState] = useState<StateType>(initialValue);
function handleEvent(e: React.EventType<HTMLElementType>) {
// handler body
}
- interface ComponentProps — declares the shape of the props object; use
?to mark a prop optional. - useState<StateType> — the generic tells TypeScript what type the state value (and the argument to the setter) must be; required whenever the initial value alone doesn’t reveal the full type, such as
nullstarting values or arrays. - React.EventType<HTMLElementType> — React ships specific event types (
MouseEvent,ChangeEvent,FormEvent,KeyboardEvent) parameterized by the DOM element the handler is attached to.
Common event and value types you’ll reach for:
| Situation | Type |
|---|---|
| Button click | React.MouseEvent<HTMLButtonElement> |
| Text input change | React.ChangeEvent<HTMLInputElement> |
| Form submit | React.FormEvent<HTMLFormElement> |
| Key press | React.KeyboardEvent<HTMLInputElement> |
| DOM node ref | useRef<HTMLInputElement>(null) |
| children prop | React.ReactNode |
Examples
Example 1: Typed props with an interface
interface GreetingProps {
name: string;
age?: number;
}
function Greeting({ name, age }: GreetingProps) {
return (
<div>
<p>Hello, {name}!</p>
{age !== undefined && <p>You are {age} years old.</p>}
</div>
);
}
export default Greeting;
This renders a paragraph greeting the given name, and a second paragraph showing age only when it was passed. The GreetingProps interface makes name required and age optional. If a caller writes <Greeting age={7} /> without name, or passes age="seven" as a string, TypeScript flags it as an error before the app ever runs.
import { createRoot } from "react-dom/client";
import Greeting from "./Greeting";
createRoot(document.getElementById("root")!).render(<Greeting name="Ava" age={7} />);
This mounts the app using the React 18+ root API. The ! after getElementById("root") is a TypeScript non-null assertion telling the compiler “trust me, this element exists,” since getElementById‘s return type is HTMLElement | null.
Example 2: Typed state and a typed event handler
import { useState } from "react";
function Counter() {
const [count, setCount] = useState<number>(0);
function handleIncrement(e: React.MouseEvent<HTMLButtonElement>) {
setCount((prev) => prev + 1);
}
return (
<div>
<p>Count: {count}</p>
<button onClick={handleIncrement}>Increment</button>
</div>
);
}
export default Counter;
Renders “Count: 0” and an Increment button; each click bumps the displayed count by one. Here useState<number>(0) is technically redundant since TypeScript would infer number from the initial value 0 anyway, but writing it explicitly is a useful habit once state gets more complex. The handler parameter is typed as React.MouseEvent<HTMLButtonElement> so e.currentTarget is correctly typed as an HTMLButtonElement if you needed to read from it.
Example 3: Typed fetch data, useRef, and useEffect
import { useEffect, useRef, useState } from "react";
interface User {
id: number;
name: string;
email: string;
}
function UserList() {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
let cancelled = false;
async function fetchUsers() {
const res = await fetch("https://jsonplaceholder.typicode.com/users");
const data: User[] = await res.json();
if (!cancelled) {
setUsers(data);
setLoading(false);
}
}
fetchUsers();
return () => {
cancelled = true;
};
}, []);
function focusInput() {
inputRef.current?.focus();
}
if (loading) return <p>Loading...</p>;
return (
<div>
<input ref={inputRef} placeholder="Search users" />
<button onClick={focusInput}>Focus search</button>
<ul>
{users.map((user) => (
<li key={user.id}>{user.name} ({user.email})</li>
))}
</ul>
</div>
);
}
export default UserList;
On mount this shows “Loading…”, fetches a list of users, then renders a search input, a Focus button, and a bulleted list of “name (email)” entries once the fetch resolves. User[] types the fetched array so every item in the list has guaranteed id, name, and email fields, and useRef<HTMLInputElement>(null) types the ref so inputRef.current is either null or an actual input element, which is why inputRef.current?.focus() uses optional chaining — the ref is null until React attaches it after the first render.
Under the hood
On mount, TypeScript’s role has already ended by the time your code runs in the browser — the build step already compiled .tsx to plain JavaScript and stripped every type annotation, interface, and generic. React then renders the component function, builds its Virtual DOM tree, and commits real DOM nodes exactly as it would for a JavaScript-only app. On a state update (like setCount or setUsers above), React schedules a re-render, calls the component function again, diffs the new Virtual DOM against the previous one, and patches only what changed — again, identical to plain React, because types never exist at runtime. What TypeScript changes is entirely upstream: your editor shows red squiggles the moment you pass the wrong prop type, autocompletes prop names and hook return values as you type, and the build fails fast if a type error slips through, long before a user could see a broken UI.
Common Mistakes
Mistake 1: Letting state be inferred as null forever
function Profile() {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser().then((data) => setUser(data));
}, []);
return <p>{user.name}</p>; // Error: 'user' is possibly 'null'
}
Without a generic, useState(null) infers the state’s type as null forever, so TypeScript has no idea user will ever hold a User object, and user.name is flagged as an error. Give the state an explicit union type and guard the access with optional chaining:
interface User {
name: string;
}
function Profile() {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
fetchUser().then((data: User) => setUser(data));
}, []);
return <p>{user?.name}</p>;
}
Mistake 2: Typing event handlers as any
function SearchBox() {
const [query, setQuery] = useState("");
function handleChange(e: any) {
setQuery(e.target.value);
}
return <input value={query} onChange={handleChange} />;
}
Typing the event as any disables type checking for everything inside the handler — a typo like e.taget.value would compile silently and crash at runtime. Use React’s specific event type instead:
function SearchBox() {
const [query, setQuery] = useState("");
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
setQuery(e.target.value);
}
return <input value={query} onChange={handleChange} />;
}
Best Practices
- Enable
strictmode intsconfig.json— it catches implicitany, uncheckednull/undefinedaccess, and other bugs that non-strict mode lets through silently. - Prefer letting TypeScript infer types from initial values (
useState(0)) and only add an explicit generic when inference isn’t enough (nullinitial values, unions, empty arrays). - Avoid
anyentirely; if a type is genuinely unknown, useunknownand narrow it before use. - Type
childrenasReact.ReactNode, notJSX.Element, sinceReactNodealso covers strings, numbers, arrays, andnull. - Skip
React.FCfor typing components; typing the props parameter directly (function Card({ title }: CardProps)) is simpler and avoidsReact.FC‘s historical quirks around implicit children. - Put shared interfaces (like API response shapes) in a dedicated
types.tsfile so multiple components can import the same definition instead of redefining it. - Always type
useRefwith the DOM element it will hold, and initialize it withnull(useRef<HTMLInputElement>(null)) so React can safely assign it after mount.
Practice Exercises
- Create a
Productinterface withid,title, andpricefields, then write aProductCardcomponent that accepts a singleproduct: Productprop and renders its title and price. - Write a
Togglecomponent that usesuseState<boolean>(false)to track an on/off state, with a button whose click handler is correctly typed asReact.MouseEvent<HTMLButtonElement>, and that displays “On” or “Off” accordingly. - Write a custom hook
useLocalStorage<T>(key: string, initialValue: T)that returns a typed[T, (value: T) => void]tuple, reading from and writing tolocalStorageunder the given key.
Summary
- TypeScript adds compile-time type checking on top of React; it has no effect on React’s runtime render, reconcile, and commit cycle.
.tsxfiles hold components with JSX, and require thereact-jsxcompiler setting plus@types/reactand@types/react-dom.- Type props with an
interfaceand use?for optional props; type state withuseState<T>whenever inference from the initial value isn’t enough. - Use React’s built-in event types (
React.ChangeEvent<HTMLInputElement>,React.MouseEvent<HTMLButtonElement>, etc.) instead ofanyfor event handlers. - Type
useRefwith the DOM element it targets and initialize it withnull. - Avoid
any, avoidReact.FC, and keep shared types in a dedicated file for reuse across components.
