In the previous tutorial, we learned about async/await and Promises. Now let’s learn how to use TypeScript with React — the most popular combination in frontend development.

By the end of this tutorial, you will know how to type components, props, hooks, events, context, and build generic components.

Setting Up a React + TypeScript Project

The fastest way is with Vite:

npm create vite@latest my-app -- --template react-ts
cd my-app
npm install
npm run dev

This creates a project with TypeScript configured out of the box. All component files use .tsx extension.

Typing Functional Components

Inline Props

The simplest way to type a component:

function Greeting({ name, age }: { name: string; age: number }) {
  return (
    <p>Hello, {name}! You are {age} years old.</p>
  );
}

// Usage
<Greeting name="Alex" age={28} />

Props Interface

For reusable types, define an interface:

interface ButtonProps {
  label: string;
  onClick: () => void;
  variant?: "primary" | "secondary"; // optional
  disabled?: boolean;                // optional
}

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

React.FC — Should You Use It?

React.FC (FunctionComponent) is an older pattern:

const Greeting: React.FC<{ name: string }> = ({ name }) => {
  return <p>Hello, {name}</p>;
};

Most teams avoid React.FC in modern React. It used to implicitly include children in props, but this was removed in React 18. Just type your props directly — it is simpler and clearer.

Typing Children

Use React.ReactNode for the children prop:

interface CardProps {
  title: string;
  children: React.ReactNode;
}

function Card({ title, children }: CardProps) {
  return (
    <div className="card">
      <h2>{title}</h2>
      <div>{children}</div>
    </div>
  );
}

// Usage
<Card title="Settings">
  <p>Change your preferences here.</p>
  <button>Save</button>
</Card>

React.ReactNode accepts strings, numbers, JSX elements, arrays, fragments, null, and undefined. It covers almost everything.

If you only want JSX elements (not strings or numbers), use React.ReactElement.

Typing Hooks

useState

TypeScript infers the type from the initial value:

const [count, setCount] = useState(0);        // number
const [name, setName] = useState("Alex");     // string
const [isOpen, setIsOpen] = useState(false);  // boolean

For complex types or initial null, provide the type explicitly:

interface User {
  id: number;
  name: string;
  email: string;
}

const [user, setUser] = useState<User | null>(null);

// Later
if (user) {
  console.log(user.name); // TypeScript knows user is User here
}

useRef

// DOM element ref
const inputRef = useRef<HTMLInputElement>(null);

function handleClick() {
  inputRef.current?.focus(); // ?. because current might be null
}

return <input ref={inputRef} />;
// Mutable ref (not for DOM)
const timerRef = useRef<number>(0);
timerRef.current = window.setTimeout(() => {}, 1000);

useReducer

interface State {
  count: number;
  loading: boolean;
}

type Action =
  | { type: "increment" }
  | { type: "decrement" }
  | { type: "setLoading"; payload: boolean };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case "increment":
      return { ...state, count: state.count + 1 };
    case "decrement":
      return { ...state, count: state.count - 1 };
    case "setLoading":
      return { ...state, loading: action.payload };
    default:
      return state;
  }
}

const [state, dispatch] = useReducer(reducer, { count: 0, loading: false });

dispatch({ type: "increment" });
dispatch({ type: "setLoading", payload: true });
// dispatch({ type: "reset" }); // Error: "reset" is not a valid action

useCallback and useMemo

const handleClick = useCallback((id: number) => {
  console.log("Clicked:", id);
}, []);

const sortedUsers = useMemo(() => {
  // Copy before sort — .sort() mutates the original array
  return [...users].sort((a, b) => a.name.localeCompare(b.name));
}, [users]);

TypeScript infers the types automatically from the function body.

Typing Events

React events have specific types. Here are the most common ones:

Click Events

function handleClick(event: React.MouseEvent<HTMLButtonElement>) {
  console.log("Clicked at:", event.clientX, event.clientY);
}

return <button onClick={handleClick}>Click me</button>;

Change Events

function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
  console.log("Value:", event.target.value);
}

return <input onChange={handleChange} />;

Form Events

function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
  event.preventDefault();
  // process form data
}

return <form onSubmit={handleSubmit}>...</form>;

Keyboard Events

function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
  if (event.key === "Enter") {
    console.log("Enter pressed");
  }
}

Quick Reference

EventType
onClickReact.MouseEvent<HTMLElement>
onChangeReact.ChangeEvent<HTMLInputElement>
onSubmitReact.FormEvent<HTMLFormElement>
onKeyDownReact.KeyboardEvent<HTMLElement>
onFocusReact.FocusEvent<HTMLElement>
onDragReact.DragEvent<HTMLElement>

Typing Context

Creating Typed Context

interface ThemeContextType {
  theme: "light" | "dark";
  toggleTheme: () => void;
}

const ThemeContext = React.createContext<ThemeContextType | null>(null);

Custom Hook for Context

Create a hook that throws if context is missing:

function useTheme(): ThemeContextType {
  const context = React.useContext(ThemeContext);
  if (!context) {
    throw new Error("useTheme must be used within a ThemeProvider");
  }
  return context;
}

Provider Component

function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState<"light" | "dark">("light");

  const toggleTheme = useCallback(() => {
    setTheme(prev => prev === "light" ? "dark" : "light");
  }, []);

  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

Using the Context

function Header() {
  const { theme, toggleTheme } = useTheme();

  return (
    <header className={theme}>
      <button onClick={toggleTheme}>
        Switch to {theme === "light" ? "dark" : "light"} mode
      </button>
    </header>
  );
}

Generic Components

Generic components accept any type while keeping type safety:

interface ListProps<T> {
  items: T[];
  renderItem: (item: T) => React.ReactNode;
  keyExtractor: (item: T) => string | number;
}

function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) {
  return (
    <ul>
      {items.map(item => (
        <li key={keyExtractor(item)}>{renderItem(item)}</li>
      ))}
    </ul>
  );
}

Using the Generic Component

interface User {
  id: number;
  name: string;
}

<List<User>
  items={users}
  renderItem={(user) => <span>{user.name}</span>}
  keyExtractor={(user) => user.id}
/>

interface Product {
  sku: string;
  title: string;
  price: number;
}

<List<Product>
  items={products}
  renderItem={(product) => <span>{product.title} - ${product.price}</span>}
  keyExtractor={(product) => product.sku}
/>

TypeScript infers the type T from the items prop, so the renderItem callback is fully typed.

Typing Style Props

interface BoxProps {
  children: React.ReactNode;
  style?: React.CSSProperties;
  className?: string;
}

function Box({ children, style, className }: BoxProps) {
  return <div style={style} className={className}>{children}</div>;
}

<Box style={{ padding: 16, backgroundColor: "blue" }}>
  Content
</Box>

React.CSSProperties gives you autocomplete for all CSS properties.

Extending HTML Element Props

Sometimes you want a component that accepts all native HTML props plus some custom ones:

interface CustomInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
  label: string;
  error?: string;
}

function CustomInput({ label, error, ...inputProps }: CustomInputProps) {
  return (
    <div>
      <label>{label}</label>
      <input {...inputProps} />
      {error && <span className="error">{error}</span>}
    </div>
  );
}

// All native input props work
<CustomInput
  label="Email"
  type="email"
  placeholder="Enter email"
  error="Invalid email"
  required
/>

What’s Next?

In the next tutorial, we will learn about TypeScript with Node.js and Express — how to build type-safe backend APIs.