React with TypeScript
Use TypeScript in a React 19 app: set up Vite's react-ts template, type props, events, refs, context and generic components, and fix the usual errors.
- 01Set up and check a React projectStart a Vite react-ts project, read its tsconfig, and know which command checks your types and which only removes them.
- 02Type components the React 19 wayType props, children, events, refs as props, reducers and context, wrap native elements with ComponentProps, and write generic components.
- 03Fix the common errorsRead the errors React projects hit most, like never[], possibly null and Cannot find namespace JSX, and fix each one.
A cart line that TypeScript checks
In a React project that uses TypeScript, components live in .tsx files: TypeScript with JSX. You give each component's props a type, and TypeScript checks every place the component is used. Here is a small cart. Its result is live under the code, and the list under the code is what the real TypeScript compiler says about it.
type CartLineProps = { name: string; price: number;}; function CartLine({ name, price }: CartLineProps) { return ( <li> {name}: ${price + 2} with delivery </li> );} export default function App() { return ( <ul> <CartLine name="Tea" price={3} /> <CartLine name="Cake" price="4" /> </ul> );}Lines 1 to 4 describe the props: name is a string and price is a number. Line 6 writes : CartLineProps after the props, so TypeScript knows what CartLine gets. Line 9 shows the price plus $2 for delivery. Line 17 passes the number 3, in braces. Line 18 passes "4" in quotes, which is text, and TypeScript marks it, on purpose here:
Type 'string' is not assignable to type 'number'means "you're passing text whereCartLinePropssays a number goes".
Now read the result: Tea: $5 with delivery, and Cake: $42 with delivery. The page still runs. Before the code runs, the types are removed, and "4" + 2 joins two pieces of text into "42". TypeScript doesn't change the page; it tells you about the bug before a customer sees it. Press Edit & run and change line 18 to price={4}: the Cake line says $6. (The lesson's editor shows the result as you type but doesn't check types; the list under the lesson's code does, and so does your editor in a project.)
App.tsx goes two ways. Your editor and tsc -b check the types and report line 18. Vite only removes the types and runs what is left, so the page still shows $42.A warehouse labels every parcel and scans the labels at the packing desk, so a wrong parcel is caught before it leaves. The van doesn't read labels: it drives whatever it's given. A parcel that skipped the scanner still gets delivered, wrong contents and all.
- In real life: A label on each parcel saying what's inside
- In React: A props type, like price: number
- In real life: The scanner at the packing desk that checks every label
- In React: TypeScript, in your editor and in tsc -b
- In real life: The van that drives parcels without reading labels
- In React: Vite, which removes the types and runs the code
- In real life: The customer, who never sees the labels
- In React: The browser, which runs plain JavaScript
React with TypeScript means components in .tsx files whose props, state, events, refs and context have types. TypeScript checks them in your editor and with tsc -b, before anything runs. Vite removes the types and runs plain JavaScript. React 19's types change a few older habits: a ref is a prop, JSX lives inside React, and useRef needs a starting value.
This lesson shows the React side: the project, React 19's types, and the patterns React code uses. The TypeScript course goes deeper into two parts of it: Typing React components (props, children, events, optional props) and Typing state and hooks (useState, useReducer, useRef, context, and your own hooks).
TypeScript in a Vite React project
Vite has a template for React with TypeScript called react-ts. Setting up a React project used the react template; with TypeScript, only the template's name changes:
npm create vite@latest shop -- --template react-ts
cd shop
npm install
npm run devLine 1 makes a folder called shop from the react-ts template. Lines 2 to 4 go into it, install the packages, and start the dev server, as for a JavaScript project. The new project has a few files the JavaScript one doesn't:
shop/
├── index.html
├── package.json
├── tsconfig.json (points at the two files below)
├── tsconfig.app.json (settings for your code in src)
├── tsconfig.node.json (settings for vite.config.ts)
├── vite.config.ts
└── src/
├── App.tsx
├── main.tsx
└── index.cssYour components end in .tsx (lines 9 and 10). There are three TypeScript settings files, because two kinds of code live in the project. tsconfig.app.json (line 5) is for your app in src, which runs in the browser. tsconfig.node.json (line 6) is for vite.config.ts, which runs in Node.js. tsconfig.json (line 4) only points at the other two. Here are the settings for your app:
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}The lines that matter most for React code:
- Line 5,
"lib": ["ES2023", "DOM"], gives TypeScript the browser's types, likeHTMLInputElement. Line 7 adds the types for Vite's extras, likeimport.meta.env. - Line 13,
allowImportingTsExtensions, lets you import"./App.tsx"with its extension. Line 14,verbatimModuleSyntax, wants imports of types markedimport type(ortypeinside the braces), so the tools can remove them safely. That's why this lesson's examples writeimport type { ReactNode }. - Line 16,
noEmit: TypeScript only checks and writes no files. Vite makes the JavaScript. - Line 17,
"jsx": "react-jsx": JSX turns into calls to React's JSX functions, which are imported for you. You don't import React to write JSX. - Line 22,
erasableSyntaxOnly, allows only TypeScript that can simply be deleted, like types. It rejects enums, which turn into real code.
There is no strict line. The template installs TypeScript 6.0 (create-vite 9.3.1, checked in October 2026), and since 6.0 strict checks are on unless a project turns them off; older templates wrote "strict": true. tsconfig and strictness explains each strict check. The scripts in package.json show who checks the types:
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview"
}Line 2, vite, starts the dev server. It removes the types from each file and runs the rest, without checking them. Line 3 builds for release: tsc -b checks every file first, and the && means vite build only runs if there were no errors. How to read the table: each row is a command, whether it checks types, and what it does.
| When you run | Are types checked? | What happens |
|---|---|---|
npm run dev | No. | Vite removes the types from each file and runs the JavaScript. A type error doesn't stop the page. |
| Your editor | Yes, as you type. | It runs TypeScript in the background and marks errors in the files you open. |
npm run build | Yes, first. | tsc -b checks every file. If it finds an error, the build stops before vite build runs. |
npx tsc -b | Yes. | The same check as the build, without building. Useful before you commit. |
The entry file is the JavaScript template's, with one extra character:
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)Line 6 ends document.getElementById('root') with !. getElementById can return null, and createRoot needs an element. The ! tells TypeScript "this one is never null", which is true because index.html has <div id="root">. (Type assertions explains !.) Line 4 imports ./App.tsx with its extension, which line 13 of the settings allows.
To add TypeScript to a Vite project that started in JavaScript, install the compiler and React's types:
npm install --save-dev typescript @types/react @types/react-dom
npx tsc -bLine 1 installs typescript and the type packages for react and react-dom. Keep @types/react on the same major version as React: 19 for React 19. Then copy the three tsconfig files from a new react-ts project, add tsc -b && to the build script, and rename files from .jsx to .tsx one at a time. Line 2 checks them. Migrating to TypeScript shows how to move a project a file at a time.
Props and children with React 19's types
Props types work as the overview showed: an object type, written after the props. Content between a component's tags arrives as the children prop, and its type is ReactNode: anything React can show.
import type { ReactNode } from "react"; type PanelProps = { title: string; children: ReactNode;}; function Panel({ title, children }: PanelProps) { return ( <section> <h2>{title}</h2> {children} </section> );} export default function App() { return ( <Panel title="Your cart"> <p>2 items, $7</p> <button>Checkout</button> </Panel> );}Line 1 imports the ReactNode type, with import type. Line 5 says children can be anything React shows: elements, text, numbers, lists of them, or nothing (null). Line 8 takes title and children out of the props. Line 12 puts the children where they belong. Lines 20 and 21, a paragraph and a button, are the children. The result shows the heading, then both.
Notice what isn't there: a return type. TypeScript works out what Panel returns, and checks at each tag that React can show it. You may meet React.FC<PanelProps> in other code. It works too, but adds nothing here, and it hasn't added children by itself since version 18 of React's types. Typing React components compares ReactNode, ReactElement, and PropsWithChildren.
Older code and tutorials often write the return type as JSX.Element. React 19's types removed the global JSX, so that line is now an error (on purpose here):
function Price({ amount }: { amount: number }): JSX.Element { return <strong>${amount}</strong>;} export default function App() { return <Price amount={3} />;}Cannot find namespace 'JSX'means "there is noJSXanywhere this file can see". The result still shows$3, because types never change what runs.
JSX now lives inside React, so other tools' JSX types can't clash with React's. Import it, write React.JSX.Element, or leave the return type off:
import type { JSX } from "react"; function Price({ amount }: { amount: number }): JSX.Element { return <strong>${amount}</strong>;} export default function App() { return <Price amount={3} />;}Line 1 imports the type JSX from react, and line 3 uses it. No errors. The same move fixes custom element types: older code added them to the global JSX, and React 19 code adds them inside declare module "react" (Globals and module augmentation shows how).
Events, refs, and ref as a prop
An inline handler, like onChange={(event) => …} on an <input>, gets its event type from the element. A handler written as its own function needs the type written out. Refs need the type of the element they will hold. And in React 19, a component receives a ref like any other prop. This search box does all three:
import { useRef, useState, type ChangeEvent, type Ref } from "react"; type SearchInputProps = { value: string; onChange: (event: ChangeEvent<HTMLInputElement>) => void; ref?: Ref<HTMLInputElement>;}; function SearchInput({ value, onChange, ref }: SearchInputProps) { return <input ref={ref} value={value} onChange={onChange} placeholder="Search products" />;} export default function App() { const [query, setQuery] = useState(""); const inputRef = useRef<HTMLInputElement>(null); // ^? function handleChange(event: ChangeEvent<HTMLInputElement>) { setQuery(event.target.value); } return ( <> <SearchInput ref={inputRef} value={query} onChange={handleChange} /> <button onClick={() => inputRef.current?.focus()}>Focus search</button> <p>Searching for: {query || "nothing yet"}</p> </> );}Line by line:
- Lines 3 to 7 are
SearchInput's props. Line 5 typesonChangeas a function that takes aChangeEvent<HTMLInputElement>: a change event from an input. Line 6 is the ref:Ref<HTMLInputElement>, optional, because a parent may not pass one. - Line 9 takes
refout of the props like any other prop, and line 10 puts it on the<input>. - Line 15 makes the ref, with
nullas its start. The query on line 16 shows its type,RefObject<HTMLInputElement | null>: it holds the input, ornullbefore React attaches it. - Lines 18 to 20 are a named handler, so line 18 writes the event's type.
event.targetis then anHTMLInputElement, with avaluestring. - Line 24 passes the ref down. Line 25 focuses the input on click;
?.skips the call whilecurrentisnull.
Type in the box: the sentence follows. Click elsewhere, then press Focus search: the cursor jumps into the box, because inputRef.current is the real input.
App passes ref={inputRef} like any prop. SearchInput takes ref and puts it on the <input>, so inputRef.current is that input. Older code needed forwardRef for this.Before React 19, React kept ref out of a component's props. To pass one through, you wrapped the component in forwardRef. You'll still find it in older code; it works in React 19, and React plans to deprecate it:
import { forwardRef, type ChangeEvent } from "react";
type SearchInputProps = {
value: string;
onChange: (event: ChangeEvent<HTMLInputElement>) => void;
};
const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
function SearchInput({ value, onChange }, ref) {
return <input ref={ref} value={value} onChange={onChange} placeholder="Search products" />;
},
);Line 8 wraps the component, with the element type first and the props second; note the order is the reverse of the call's arguments. The ref arrives as a separate second argument on line 9. With React 19 you delete the wrapper and add ref to the props, as searchBox does.
Two more changes in React 19's ref types:
useRefneeds a starting value:useRef<number>()is an error, so writeuseRef<number | undefined>(undefined). Every ref'scurrentcan be changed by your code; older types made some read-only.- A ref callback,
ref={(node) => …}, may return a cleanup function, so it must return nothing else. Manipulating the DOM with refs covers ref callbacks, and the errors playground below shows the mistake.
Typing events lists the DOM's event types, and Typing state and hooks covers useRef for values as well as elements.
Wrapping a native element with ComponentProps
Apps are full of components that wrap one element: a Button around <button>, a TextField around <input>. Your wrapper should accept everything the element accepts, like onClick, disabled, type and aria-label, without listing them all. ComponentProps<"button"> is that list: the type of a <button>'s props.
import { useState, type ComponentProps } from "react"; type ButtonProps = ComponentProps<"button"> & { variant?: "primary" | "ghost";}; function Button({ variant = "primary", className = "", ...rest }: ButtonProps) { return <button className={`btn ${variant} ${className}`} {...rest} />;} export default function App() { const [added, setAdded] = useState(0); return ( <> <Button onClick={() => setAdded(added + 1)}>Add to cart</Button> <Button variant="ghost" type="button" disabled> Save for later </Button> <Button variant="danger">Remove</Button> <p>Added {added} times</p> </> );}- Lines 3 to 5 make
ButtonProps: every<button>prop, joined with&to one of your own,variant(Intersection types explains&). - Line 7 takes out
variantandclassName, with defaults....restcollects every other prop (Spread and rest). - Line 8 builds the class name and spreads
restonto the real<button>, soonClick,type,disabledand the children all reach it. - Lines 16 and 17 use
onClick,typeanddisabled, whichButtonPropsnever names. TypeScript accepts them becauseComponentProps<"button">has them. - Line 20 passes
variant="danger", and TypeScript marks it, on purpose here:Type '"danger"' is not assignable to type '"primary" | "ghost" | undefined'means only those two words, or nothing, may go there.
Press Add to cart: the count goes up, because onClick travelled through rest. The styles are in the styles.css tab.
ButtonProps is every prop a <button> takes, plus your variant. Button turns variant into a class name and spreads the rest onto the real <button>, including ref.ComponentProps<"button"> includes ref. In React 19 a ref is an ordinary prop of your component, so the spread on line 8 passes it to the <button> too, with no extra code. When a wrapper shouldn't accept a ref, use ComponentPropsWithoutRef:
import type { ComponentProps, ComponentPropsWithoutRef } from "react"; type WithRef = ComponentProps<"button">["ref"];// ^?type WithoutRef = ComponentPropsWithoutRef<"button">; const props: WithoutRef = { type: "button", disabled: true }; export default function App() { return <button {...props}>Save for later</button>;}The query on line 4 shows that ComponentProps<"button">["ref"] is Ref<HTMLButtonElement> | undefined. Line 5 makes the same props without ref, and line 7 uses them for a plain object that line 10 spreads onto a button. To copy another component's props instead of an element's, write ComponentProps<typeof Button>.
Reducers and context the React 19 way
useState, useReducer and context work as Typing state and hooks shows. Two of them read a little differently with React 19's types. Here is a cart in context, changed by a reducer:
import { createContext, useContext, useReducer, type ReactNode } from "react"; type Item = { name: string; price: number };type CartAction = { type: "add"; item: Item } | { type: "clear" }; function cartReducer(items: Item[], action: CartAction): Item[] { switch (action.type) { case "add": return [...items, action.item]; case "clear": return []; }} type CartValue = { items: Item[]; dispatch: (action: CartAction) => void };const CartContext = createContext<CartValue | null>(null); function useCart() { const cart = useContext(CartContext); if (!cart) throw new Error("useCart must be used inside CartProvider"); return cart;} function CartProvider({ children }: { children: ReactNode }) { const [items, dispatch] = useReducer(cartReducer, []); return <CartContext value={{ items, dispatch }}>{children}</CartContext>;} function AddTea() { const { dispatch } = useCart(); const tea = { name: "Tea", price: 3 }; return <button onClick={() => dispatch({ type: "add", item: tea })}>Add tea</button>;} function CartTotal() { const { items, dispatch } = useCart(); const total = items.reduce((sum, item) => sum + item.price, 0); return ( <p> {items.length} items, ${total} <button onClick={() => dispatch({ type: "clear" })}>Clear</button> </p> );} export default function App() { return ( <CartProvider> <AddTea /> <CartTotal /> </CartProvider> );}- Line 4 lists every action as a union, one shape per
type(a discriminated union). Line 6 types the reducer's state, action, and result. Insidecase "add", line 9 can readaction.item, because only that action has one. - Line 25 calls
useReducer(cartReducer, [])with no type arguments. React 19's types read the state and action types fromcartReducer. Older code wroteuseReducer<Reducer<State, Action>>(…), which React 19's types no longer accept. - Line 16 creates the context with
nullas its default, so its type isCartValue | null. Lines 18 to 22 are a small hook that throws if there's no provider; after line 20, TypeScript knowscartisn'tnull, so every component that callsuseCart()gets aCartValue. - Line 26 renders
<CartContext value={…}>: in React 19 the context itself is the provider.<CartContext.Provider>still works, and React plans to deprecate it.
Press Add tea twice: 2 items, $6. Clear sends the other action. How to read the table: each row is something you do in React code, how older code wrote it, and how it reads with React 19's types.
| What you're doing | Older code | With React 19's types |
|---|---|---|
| Pass a ref to your component | Wrap the component in forwardRef. | Take ref as a prop, typed Ref<HTMLInputElement>. |
| Name the type of JSX | JSX.Element, from a global namespace. | React.JSX.Element, or import type { JSX } from "react". |
| Make a ref with no value yet | useRef<number>(), with no argument. | useRef<number | undefined>(undefined) or useRef<number | null>(null). Every ref's current can be changed. |
Type useReducer | useReducer<Reducer<State, Action>>(reducer, start). | No type arguments: useReducer(reducer, start) reads them from the reducer. |
| Provide a context | <CartContext.Provider value={cart}>. | <CartContext value={cart}>. |
| Return from a ref callback | Anything: React ignored it. | Nothing, or a cleanup function. |
Extracting state logic into a reducer and Scaling up with reducer and context teach the patterns themselves.
Generic components
A component can be generic, like a generic function. A List<T> shows any kind of item, and TypeScript works out T from the items you pass, so the functions you give it know the item's type:
import type { ReactNode } from "react"; type ListProps<T> = { items: T[]; getKey: (item: T) => string; renderItem: (item: T) => ReactNode;}; function List<T>({ items, getKey, renderItem }: ListProps<T>) { return ( <ul> {items.map((item) => ( <li key={getKey(item)}>{renderItem(item)}</li> ))} </ul> );} const products = [ { id: "tea", name: "Tea", price: 3 }, { id: "cake", name: "Cake", price: 4 },];const reviews = [{ email: "asha@example.com", stars: 5 }]; export default function App() { return ( <> <List items={products} getKey={(product) => product.id} renderItem={(product) => `${product.name}: $${product.price}`} // ^? /> <List items={reviews} getKey={(review) => review.email} renderItem={(review) => `${review.stars} stars`} /> </> );}- Lines 3 to 7 describe the props in terms of
T: an array ofT, a function that gives eachTa key, and one that shows aT. - Line 9,
function List<T>, makesTa type parameter.Listnever looks inside an item; it only passes items togetKeyandrenderItemon line 13. - Lines 28 to 33 use
Listwithproducts. TypeScript decidesTis the product type, and the query on line 32 showsproducthasid,nameandprice. - Line 34 uses the same
Listwith reviews: there,Tis the review type.
items={products} and decides T is the product type. Every other place that says T, like getKey and renderItem, gets that same type.The type flows all the way. Read a property products don't have, and TypeScript marks it (on purpose here):
import type { ReactNode } from "react"; type ListProps<T> = { items: T[]; getKey: (item: T) => string; renderItem: (item: T) => ReactNode;}; function List<T>({ items, getKey, renderItem }: ListProps<T>) { return ( <ul> {items.map((item) => ( <li key={getKey(item)}>{renderItem(item)}</li> ))} </ul> );} const products = [ { id: "tea", name: "Tea", price: 3 }, { id: "cake", name: "Cake", price: 4 },];const reviews = [{ email: "asha@example.com", stars: 5 }]; export default function App() { return ( <> <List items={products} getKey={(product) => product.id} renderItem={(product) => product.title} // ^? /> <List items={reviews} getKey={(review) => review.email} renderItem={(review) => `${review.stars} stars`} /> </> );}Property 'title' does not exist on type '{ id: string; name: string; price: number; }'on line 31: products have notitle. In the result, the first list's two items are empty, becauseproduct.titleisundefined.
With an arrow function, a .tsx file reads <T> as the start of a JSX tag. Write <T,> instead; the comma tells TypeScript it's a type parameter:
const List = <T,>({ items, getKey, renderItem }: ListProps<T>) => (
<ul>
{items.map((item) => (
<li key={getKey(item)}>{renderItem(item)}</li>
))}
</ul>
);Line 1 is the only difference from function List<T>. Generics in practice shows more generic patterns.
Common errors and how to fix them
These six errors come up in almost every React project with TypeScript. Pick one, read the message and why it happens, then press Fixed to see the line that fixes it. Each time, the real compiler checks the code with React's types.
import { useState } from "react"; export default function App() { const [items, setItems] = useState([]); return <button onClick={() => setItems([...items, "Tea"])}>Add tea</button>;}line 5An empty array gives TypeScript nothing to go on, so it decides items is never[]: an array that can never hold anything. The error shows where you first put something in.
How to read the table: each row is a message TypeScript prints, what it means in plain words, and the fix. Keep it for when you meet one in your own project.
| TypeScript says | What it means | The fix |
|---|---|---|
Type 'string' is not assignable to type 'never'. | An empty array gives TypeScript nothing to go on, so it decides items is never[]: an array that can never hold anything. The error shows where you first put something in. | Say what goes in where the state starts, on line 4: useState<string[]>([]). |
'inputRef.current' is possibly 'null'. | useRef<HTMLInputElement>(null) holds null until React attaches the input, and again after React removes it, so current is HTMLInputElement | null. | Check it first: inputRef.current?.focus() does nothing while it's null. |
Expected 1 arguments, but got 0. | React 19's types need a starting value for useRef, the way createContext does. Older types allowed useRef(). | Pass the starting value, and put it in the type: useRef<number | undefined>(undefined). |
Cannot find namespace 'JSX'. | React 19's types removed the global JSX namespace. It lives inside React now, as React.JSX. | Write React.JSX.Element, import it with import type { JSX } from "react", or leave the return type off and let TypeScript work it out. |
Parameter 'event' implicitly has an 'any' type. | A handler written apart from the JSX doesn't know which element it's for, so its parameter has no type. An inline onChange={(event) => …} gets its type from the input. | Give the parameter the event's type: event: React.ChangeEvent<HTMLInputElement>. |
Type '(node: HTMLInputElement | null) => HTMLInputElement | null' is not assignable to type 'Ref<HTMLInputElement> | undefined'. | In React 19 a ref callback may return a cleanup function. The arrow without braces returns node, which isn't one, so the types reject it. | Add braces, so the arrow returns nothing: (node) => { saved = node; }. |
Long messages read from the top: the first line says what went wrong, and the lines under it say why. Reading type errors walks through longer ones.
A product search, typed end to end
Here are the pieces together, split into files the way a project splits them. Click the tabs to read SearchInput.tsx, Button.tsx and List.tsx.
import { useRef, useState } from "react";import Button from "./Button.tsx";import List from "./List.tsx";import SearchInput from "./SearchInput.tsx"; type Product = { id: string; name: string; price: number }; const products: Product[] = [ { id: "tea", name: "Green tea", price: 3 }, { id: "cake", name: "Carrot cake", price: 4 }, { id: "lemonade", name: "Lemonade", price: 2 },]; export default function App() { const [query, setQuery] = useState(""); const inputRef = useRef<HTMLInputElement>(null); const shown = products.filter((product) => product.name.toLowerCase().includes(query.toLowerCase())); function clear() { setQuery(""); inputRef.current?.focus(); } return ( <> <SearchInput ref={inputRef} value={query} onChange={(event) => setQuery(event.target.value)} /> <Button variant="ghost" onClick={clear}> Clear </Button> <List items={shown} getKey={(product) => product.id} renderItem={(product) => `${product.name}: $${product.price}`} /> </> );}SearchInput.tsxtakesComponentProps<"input">and spreads it onto an<input>. It never mentionsref, yet line 26 ofApp.tsxpasses one and it arrives: in React 19,refis one of the props the spread passes on.- On line 26 the inline
onChangegets itseventtype fromSearchInput's props, soevent.target.valueis a string with no type written. - Line 27 uses
ButtonfromButton.tsx, the wrapper from earlier, withvariant="ghost". - Line 30 uses the generic
ListfromList.tsx.TisProduct(line 6), soproduct.priceis a number.
Type ca in the search box: only Carrot cake stays. Press Clear: the box empties and gets the focus, through the ref. Type xyz: No products match.
const [items, setItems] = useState<string[]>([]);const timer = useRef<number>();<Button variant="ghost" disabled>Save</Button>;function Badge(): JSX.Element { return <strong>New</strong>; }<input ref={(node) => { saved = node; }} />;<input ref={(node) => (saved = node)} />;const [cart, dispatch] = useReducer(cartReducer, []);<CartContext value={null}>{null}</CartContext>;
Each line sits inside a React 19 project with a Button like the one above, a cartReducer, a CartContext, and a saved variable. Sort it: does TypeScript mark it, or accept it?
Common mix-ups
- "The dev server stops me if my types are wrong." No.
npm run devremoves the types without checking them. Your editor andtsc -b, whichnpm run buildruns first, do the checking. - "Types check my props while the app runs." No. Types are gone before the code runs. Data from outside, like a server's answer, still needs checking with code.
- "I need forwardRef to pass a ref to my component." No, not in React 19. Add
refto the props and put it on the element. - "Every component needs React.FC or a JSX.Element return type." No. A plain function with typed props is enough, and TypeScript works out the return type.
JSX.Elementisn't even global any more. - "useRef<HTMLInputElement>(null) gives a ref I can't change." No. In React 19's types, every
RefObject'scurrentcan be changed. - "useReducer needs type arguments." No. Type the reducer's parameters, and
useReducerreads the types from it.
Practice exercises
Each exercise shows its result under the code, and the list of TypeScript errors under the starter. Press Edit & run to change the code: the result updates as you type. Check your types with the solution's list, or in a project of your own.
This code has no error list, on purpose. Which line does TypeScript mark? Read the result first: what does the Cake line say, and is it right?
type BadgeProps = { name: string; inStock: boolean;}; function StockBadge({ name, inStock }: BadgeProps) { return <p>{name}: {inStock ? "in stock" : "sold out"}</p>;} export default function App() { return ( <> <StockBadge name="Tea" inStock={true} /> <StockBadge name="Cake" inStock="no" /> </> );}type BadgeProps = { name: string; inStock: boolean;}; function StockBadge({ name, inStock }: BadgeProps) { return <p>{name}: {inStock ? "in stock" : "sold out"}</p>;} export default function App() { return ( <> <StockBadge name="Tea" inStock={true} /> <StockBadge name="Cake" inStock={false} /> </> );}TypeScript marks line 14: Type 'string' is not assignable to type 'boolean'. The result shows why it matters: any non-empty text counts as true, so the Cake said in stock. With inStock={false} it says sold out.
TypeScript marks line 1 twice. Give ProductBadge a props type. Before your fix, what type does TypeScript give price?
function ProductBadge({ name, price }) { return ( <span> {name}: ${price} </span> );} export default function App() { return <ProductBadge name="Tea" price={3} />;}type ProductBadgeProps = { name: string; price: number;}; function ProductBadge({ name, price }: ProductBadgeProps) { return ( <span> {name}: ${price} </span> );} export default function App() { return <ProductBadge name="Tea" price={3} />;}Without a type, each prop is any, and strict mode reports it: Binding element 'name' implicitly has an 'any' type. Lines 1 to 4 describe the props, and line 6 uses them. Now a wrong price is caught at every use.
Click Focus search in the result: nothing happens. Make SearchInput take the ref the React 19 way. Before your fix, what is inputRef.current after you click?
import { useRef } from "react"; type SearchInputProps = { placeholder: string;}; function SearchInput({ placeholder }: SearchInputProps) { return <input placeholder={placeholder} />;} export default function App() { const inputRef = useRef<HTMLInputElement>(null); return ( <> <SearchInput ref={inputRef} placeholder="Search products" /> <button onClick={() => inputRef.current?.focus()}>Focus search</button> </> );}import { useRef, type Ref } from "react"; type SearchInputProps = { placeholder: string; ref?: Ref<HTMLInputElement>;}; function SearchInput({ placeholder, ref }: SearchInputProps) { return <input ref={ref} placeholder={placeholder} />;} export default function App() { const inputRef = useRef<HTMLInputElement>(null); return ( <> <SearchInput ref={inputRef} placeholder="Search products" /> <button onClick={() => inputRef.current?.focus()}>Focus search</button> </> );}Before the fix, React passes ref to SearchInput, which ignores it, so inputRef.current stays null and Focus search does nothing. Lines 5, 8 and 9 take the ref and put it on the input. Now the button focuses the box. No forwardRef needed.
The result works, but TypeScript marks line 8. Fix it where the state starts. Before your fix, what type does TypeScript give items?
import { useState } from "react"; export default function App() { const [items, setItems] = useState([]); return ( <> <button onClick={() => setItems([...items, "Tea"])}>Add tea</button> <p> {items.length} items: {items.join(", ")} </p> </> );}import { useState } from "react"; export default function App() { const [items, setItems] = useState<string[]>([]); return ( <> <button onClick={() => setItems([...items, "Tea"])}>Add tea</button> <p> {items.length} items: {items.join(", ")} </p> </> );}TypeScript decided items was never[], an array nothing fits in, so adding "Tea" on line 8 was an error. useState<string[]>([]) says what goes in. The page worked all along: Add tea twice shows 2 items: Tea, Tea.
Line 14 passes type, placeholder and required, which TextField doesn't accept. Make it accept every <input> prop and pass them on. After your fix, what type does the real input get?
type TextFieldProps = { label: string;}; function TextField({ label }: TextFieldProps) { return ( <label> {label} <input /> </label> );} export default function App() { return <TextField label="Email" type="email" placeholder="asha@example.com" required />;}import type { ComponentProps } from "react"; type TextFieldProps = ComponentProps<"input"> & { label: string;}; function TextField({ label, ...rest }: TextFieldProps) { return ( <label> {label} <input {...rest} /> </label> );} export default function App() { return <TextField label="Email" type="email" placeholder="asha@example.com" required />;}Line 3 makes the props every <input> prop plus label. Line 7 takes label out, and line 10 spreads the rest onto the input, so type, placeholder and required reach it. The input is now an email field with its placeholder.
Line 27 has two errors: getLabel can't read product.name, and setPicked doesn't fit onPick. Make Picker generic. With your fix, what does the page say after you click Cake?
import { useState } from "react"; type PickerProps = { options: unknown[]; getLabel: (option: unknown) => string; onPick: (option: unknown) => void;}; function Picker({ options, getLabel, onPick }: PickerProps) { return options.map((option) => ( <button key={getLabel(option)} onClick={() => onPick(option)}> {getLabel(option)} </button> ));} type Product = { name: string; price: number };const products: Product[] = [ { name: "Tea", price: 3 }, { name: "Cake", price: 4 },]; export default function App() { const [picked, setPicked] = useState<Product | null>(null); return ( <> <Picker options={products} getLabel={(product) => product.name} onPick={setPicked} /> <p>{picked ? `You picked ${picked.name}: $${picked.price}` : "Pick a product"}</p> </> );}import { useState } from "react"; type PickerProps<T> = { options: T[]; getLabel: (option: T) => string; onPick: (option: T) => void;}; function Picker<T>({ options, getLabel, onPick }: PickerProps<T>) { return options.map((option) => ( <button key={getLabel(option)} onClick={() => onPick(option)}> {getLabel(option)} </button> ));} type Product = { name: string; price: number };const products: Product[] = [ { name: "Tea", price: 3 }, { name: "Cake", price: 4 },]; export default function App() { const [picked, setPicked] = useState<Product | null>(null); return ( <> <Picker options={products} getLabel={(product) => product.name} onPick={setPicked} /> <p>{picked ? `You picked ${picked.name}: $${picked.price}` : "Pick a product"}</p> </> );}With T, TypeScript reads options={products} and decides T is Product. Then product.name is fine, and onPick passes a Product to setPicked. Click Cake: You picked Cake: $4.
Check your understanding
For code questions, find where each type comes from: a type you wrote, a starting value, or a prop TypeScript read first. Press Edit & run on a question's code to run it.
Question 1 of 7The overview's cart still passes
price="4"on line 18, and you runnpm run dev. What happens?Choose an answer to see the explanation.
Question 2 of 7What type does
inputRefhave in this component?Read the code, then predictimport { useRef } from "react"; export function SearchBox() { const inputRef = useRef<HTMLInputElement>(null); return <input ref={inputRef} />; }Choose an answer to see the explanation.
Question 3 of 7In React 19, how does a parent's ref reach the
<input>inside yourSearchInputcomponent?Choose an answer to see the explanation.
Question 4 of 7Which
Buttonin this toolbar does TypeScript mark?Read the code, then predictimport type { ComponentProps } from "react"; type ButtonProps = ComponentProps<"button"> & { variant?: "primary" | "ghost" }; function Button({ variant = "primary", ...rest }: ButtonProps) { return <button className={variant} {...rest} />; } export function Toolbar() { return ( <> <Button variant="ghost" disabled>Save</Button> <Button type="submit">Pay</Button> <Button variant="outline">Share</Button> </> ); }Choose an answer to see the explanation.
Question 5 of 7What type does TypeScript work out for
reviewinrenderItem?Read the code, then predicttype ListProps<T> = { items: T[]; renderItem: (item: T) => string }; function List<T>({ items, renderItem }: ListProps<T>) { return <ul>{items.map((item) => <li key={renderItem(item)}>{renderItem(item)}</li>)}</ul>; } export function Reviews() { return <List items={[{ stars: 5, by: "Asha" }]} renderItem={(review) => review.by} />; }Choose an answer to see the explanation.
Question 6 of 7Your React 19 project reports
Cannot find namespace 'JSX'onfunction Badge(): JSX.Element. What's the fix?Choose an answer to see the explanation.
Question 7 of 7Why does
useState([])cause an error as soon as you add an item?Choose an answer to see the explanation.
Key takeaways
- Start with
npm create vite@latest -- --template react-ts. Vite removes types without checking them; your editor andtsc -b(run first bynpm run build) check them. - Type props with an object type after the props, children as
ReactNode, and let TypeScript work out return types.JSXlives in React now:React.JSX.Element. - In React 19 a ref is a prop:
ref?: Ref<HTMLInputElement>, noforwardRef.useRefneeds a starting value, anduseRef<T>(null)holdsT | null. - Wrap native elements with
ComponentProps<"button">and spread the rest; it includesref. UseComponentPropsWithoutRefwhen it shouldn't. - Type the reducer, not
useReducer. Create context withnull, remove it with a hook that throws, and provide it with<Context value>. - Generic components,
function List<T>, carry the item type from one prop to the others.
Remember the one-liner.
Type what goes in, let TypeScript work out the rest, and remember that the checker, not the dev server, catches mistakes.
Coming next: Rendering strategies. It starts the React on the server stage: where your components render (in the browser, on a server, or at build time) and what each choice costs.