Skip to content
Laviyx Learn

Try

React course menu

React with TypeScript: Typing Props, State and Events

Use TypeScript in React: creating a react-ts project, typing component props with types and interfaces, optional and children props, typing useState, event handlers, refs, useReducer actions with discriminated unions, context, and custom hooks.

Advanced12 min readUpdated
On this page

TypeScript catches mistakes in React code before you run it: a missing prop, a misspelt property, passing a string where a number is expected. Most professional React projects use it. This lesson assumes you know TypeScript basics.

Start a TypeScript project with the react-ts template:

Terminal
npm create vite@latest my-app -- --template react-ts

Component files then use the .tsx extension.

Typing props#

Describe the props with a type (or interface) and annotate the parameter:

TSX
type ProductCardProps = {
  name: string;
  price: number;
  inStock?: boolean;          // optional
  tags?: string[];
};

function ProductCard({ name, price, inStock = true, tags = [] }: ProductCardProps) {
  return (
    <div style={{ border: "1px solid #ddd", padding: 8, marginBottom: 6 }}>
      <strong>{name}</strong> — ₹{price.toFixed(2)} {inStock ? "" : "(out of stock)"}
      {tags.length > 0 && <div><small>{tags.join(" · ")}</small></div>}
    </div>
  );
}

export default function App() {
  return (
    <div>
      <ProductCard name="Steel bottle" price={349} tags={["eco", "1 litre"]} />
      <ProductCard name="Yoga mat" price={899} inStock={false} />
    </div>
  );
}
Result

With these types, the editor autocompletes props, and these mistakes are compile errors:

TSX
<ProductCard name="Bottle" />                  // error: Property 'price' is missing
<ProductCard name="Bottle" price="349" />      // error: Type 'string' is not assignable to type 'number'
<ProductCard name="Bottle" price={349} colour="red" />   // error: Property 'colour' does not exist

(The previews strip the types and run the JavaScript; the TypeScript compiler or your editor reports the errors.)

children and other React types#

  • children: React.ReactNode accepts anything React can render: text, elements, arrays, null.
  • React.ComponentProps<"button"> gives all the props of a <button>, useful for wrapping native elements.
TSX
import type { ComponentProps, ReactNode } from "react";

type CardProps = { title: string; children: ReactNode };

function Card({ title, children }: CardProps) {
  return <section><h3>{title}</h3>{children}</section>;
}

type ButtonProps = ComponentProps<"button"> & { variant?: "primary" | "secondary" };

function Button({ variant = "primary", ...rest }: ButtonProps) {
  return <button className={`btn btn-${variant}`} {...rest} />;
}

Button now accepts every normal button prop (onClick, disabled, type...) plus variant, which is limited to two values.

Older code often writes components as const Card: React.FC<CardProps> = .... Annotating the props parameter directly, as above, is simpler and now the common style.

Typing state#

TypeScript infers the type from the initial value. Give a type argument when the initial value does not show the full type, such as null or an empty array:

TSX
import { useState } from "react";

type User = { id: number; name: string };

export default function App() {
  const [count, setCount] = useState(0);                 // number, inferred
  const [user, setUser] = useState<User | null>(null);   // starts empty
  const [tags, setTags] = useState<string[]>([]);        // empty array needs a type

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>Count: {count}</button>{" "}
      <button onClick={() => setUser({ id: 1, name: "Zara" })}>Log in</button>{" "}
      <button onClick={() => setTags([...tags, `tag${tags.length + 1}`])}>Add tag</button>
      <p>{user ? `Hello, ${user.name}` : "Not logged in"} · tags: {tags.join(", ") || "none"}</p>
    </div>
  );
}
Result

Because user is User | null, TypeScript makes you check for null before using user.name, which prevents a classic crash.

Typing events#

Inline handlers get their event types automatically. For separate handler functions, use React's event types:

TSX
import type { ChangeEvent, FormEvent, MouseEvent } from "react";

function handleChange(e: ChangeEvent<HTMLInputElement>) {
  console.log(e.target.value);              // string
}

function handleSubmit(e: FormEvent<HTMLFormElement>) {
  e.preventDefault();
}

function handleClick(e: MouseEvent<HTMLButtonElement>) {
  console.log(e.clientX, e.clientY);
}

Tip: hover over onChange in your editor to see the exact type it expects.

Typing refs#

TSX
const inputRef = useRef<HTMLInputElement>(null);
// later, in an event handler:
inputRef.current?.focus();          // current may be null until React attaches the element

Reducers with discriminated unions#

A union of action types with a shared type field makes reducers type-safe: inside each case, TypeScript knows exactly which fields the action has.

TSX
import { useReducer } from "react";

type State = { items: string[]; filter: "all" | "short" };

type Action =
  | { type: "added"; text: string }
  | { type: "removed"; index: number }
  | { type: "filterChanged"; filter: State["filter"] };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case "added":
      return { ...state, items: [...state.items, action.text] };
    case "removed":
      return { ...state, items: state.items.filter((_, i) => i !== action.index) };
    case "filterChanged":
      return { ...state, filter: action.filter };
  }
}

export default function App() {
  const [state, dispatch] = useReducer(reducer, { items: ["Tea", "Chocolate biscuits"], filter: "all" });
  const visible = state.filter === "short" ? state.items.filter((t) => t.length <= 5) : state.items;

  return (
    <div>
      <button onClick={() => dispatch({ type: "added", text: "Jam" })}>Add Jam</button>{" "}
      <button onClick={() => dispatch({ type: "filterChanged", filter: state.filter === "all" ? "short" : "all" })}>
        Show {state.filter === "all" ? "short names only" : "all"}
      </button>
      <ul>
        {visible.map((item) => (
          <li key={item}>
            {item} <button onClick={() => dispatch({ type: "removed", index: state.items.indexOf(item) })}>✕</button>
          </li>
        ))}
      </ul>
    </div>
  );
}
Result

Dispatching { type: "added" } without text, or a misspelt type such as "add", is a compile error. Because every case returns, TypeScript also checks that the reducer handles all action types.

Typing context#

TSX
import { createContext, useContext } from "react";

type Theme = "light" | "dark";
type ThemeContextValue = { theme: Theme; toggle: () => void };

const ThemeContext = createContext<ThemeContextValue | null>(null);

export function useTheme() {
  const value = useContext(ThemeContext);
  if (!value) throw new Error("useTheme must be used inside <ThemeProvider>");
  return value;                   // type: ThemeContextValue, not null
}

Typing custom hooks#

Return tuples as const, so callers get exact types instead of a mixed array:

TSX
import { useState } from "react";

export function useToggle(initial = false) {
  const [on, setOn] = useState(initial);
  const toggle = () => setOn((v) => !v);
  return [on, toggle] as const;   // readonly [boolean, () => void]
}

Without as const, the return type would be (boolean | (() => void))[], and const [on, toggle] = useToggle() would give both variables that union type.

Common mistakes#

Practice#

Practice exercise

Type a StudentRow component that takes name: string, marks: number, and an optional onSelect callback that receives the name. Render two rows and show the selected name.

Show hint

type StudentRowProps = { name: string; marks: number; onSelect?: (name: string) => void }; and call onSelect?.(name) in the click handler.

Show solution
TSX
import { useState } from "react";

type StudentRowProps = {
  name: string;
  marks: number;
  onSelect?: (name: string) => void;
};

function StudentRow({ name, marks, onSelect }: StudentRowProps) {
  return (
    <p>
      {name}: {marks} <button onClick={() => onSelect?.(name)}>Select</button>
    </p>
  );
}

export default function App() {
  const [selected, setSelected] = useState<string | null>(null);
  return (
    <div>
      <StudentRow name="Ishaan" marks={91} onSelect={setSelected} />
      <StudentRow name="Mira" marks={84} onSelect={setSelected} />
      <p>Selected: {selected ?? "nobody"}</p>
    </div>
  );
}
Result

Key takeaway#

Next: Routing with React Router.

Finished this lesson?