React with TypeScript: Props, State, Events, and Component Types

Learn React with TypeScript: Props, State, Events, and Component Types with practical examples, explanations, best practices, and real-world coding techniques.

Introduction to React with TypeScript

TypeScript extends JavaScript with a static type system that helps developers identify many programming mistakes during development. When combined with React, it lets you define the expected structure of component props, state values, event handlers, API data, and other application logic.

React works with both JavaScript and TypeScript. TypeScript does not change how React renders components; it adds type checking and editor support to make larger applications easier to understand, maintain, and refactor.

Why Use TypeScript with React?

  • Catch mistakes earlier: Detect many invalid property accesses, incorrect argument types, and incompatible values before running the application.
  • Document component contracts: Define the properties a component expects and which values are optional.
  • Improve editor assistance: Get autocomplete, type information, and safer refactoring in supported editors.
  • Model application data: Represent users, products, API responses, form values, and other domain objects.
  • Support maintainability: Make it easier for developers to understand how components and functions interact.

TypeScript catches many errors statically, but it does not validate arbitrary runtime data automatically. Values received from an API or user input may still require runtime validation.

Setting Up a React TypeScript Project

A common way to start a new React application with TypeScript is to use Vite's React TypeScript template. Ensure Node.js and npm are installed before running the commands.

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

The template includes TypeScript configuration and React type definitions. Most React components use the .tsx extension when they contain JSX, while TypeScript files without JSX typically use .ts.

src/
  components/
    UserCard.tsx
  types/
    user.ts
  App.tsx
  main.tsx
  vite-env.d.ts

This is an example structure rather than a required layout. You can organize larger applications by feature or domain. For the JavaScript-based setup, see React Setup.

Understanding Type Annotations

A type annotation describes the kind of value a variable, parameter, or property is expected to contain. TypeScript can infer many types automatically, but explicit types are particularly useful for component interfaces and public function boundaries.

const username: string = 'Alex';
const age: number = 25;
const isActive: boolean = true;

function greet(name: string): string {
  return 'Hello, ' + name;
}

TypeScript infers the types of many initialized variables, so annotations are not required everywhere. Add explicit types when they improve clarity or define an important contract.

Typing React Component Props

Props are the values passed from a parent component to a child component. Define an object type or interface to specify which props the component accepts.

interface UserCardProps {
  name: string;
  age: number;
  role?: string;
}

function UserCard({
  name,
  age,
  role = 'Developer'
}: UserCardProps) {
  return (
    <article>
      <h2>{name}</h2>
      <p>Age: {age}</p>
      <p>Role: {role}</p>
    </article>
  );
}

export default UserCard;

The question mark in role? marks the property as optional. Providing a default value makes the component use Developer when the caller omits the role.

<UserCard name="Alex" age={25} />
<UserCard name="Sam" age={30} role="Designer" />

TypeScript reports errors when required props are missing or values do not match the declared types. Learn more about component contracts in React Props.

Using type and interface

Both type and interface can describe object shapes used by React components. Interfaces are commonly used for extensible object contracts, while type aliases also support unions, intersections, and other type expressions.

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

type Status = 'loading' | 'success' | 'error';

type UserWithStatus = User & {
  status: Status;
};

Choose the form that best communicates the data model. There is no need to use one exclusively throughout the project.

Typing useState

React's useState Hook works with TypeScript and usually infers the state type from its initial value. When the state may hold multiple types of values or begins without a value, explicitly specifying the type can make the intended behavior clearer.

import { useState } from 'react';

function Counter() {
  const [count, setCount] = useState<number>(0);
  const [name, setName] = useState<string>('');
  const [selectedUser, setSelectedUser] =
    useState<{ id: number; name: string } | null>(null);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(current => current + 1)}>
        Increment
      </button>

      <input
        value={name}
        onChange={event => setName(event.target.value)}
        placeholder="Enter name"
      />

      <p>Selected user: {selectedUser?.name ?? 'None'}</p>
    </div>
  );
}

export default Counter;

The union {'User | null'} means the state may contain a user object or null. This is useful when a selection has not yet been made or data has not yet loaded. For the underlying state model, see React State.

Typing Event Handlers

React provides event types that describe the event objects received by handlers. In many inline handlers, TypeScript can infer the event type automatically. Explicit types are helpful when you move a handler into a separate function.

import { useState } from 'react';
import type { ChangeEvent, FormEvent } from 'react';

function LoginForm() {
  const [email, setEmail] = useState('');

  function handleEmailChange(
    event: ChangeEvent<HTMLInputElement>
  ) {
    setEmail(event.target.value);
  }

  function handleSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    console.log('Submitted email:', email);
  }

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="email">Email</label>
      <input
        id="email"
        type="email"
        value={email}
        onChange={handleEmailChange}
      />
      <button type="submit">Continue</button>
    </form>
  );
}

export default LoginForm;

ChangeEvent<HTMLInputElement> describes a change event from an input element, while FormEvent<HTMLFormElement> describes a form event. Importing types with import type makes it clear that they are used only for type checking.

React event handlers and browser event handlers are not always interchangeable. Use the appropriate React event type for React props such as onChange and onSubmit. See React Events for event-handling fundamentals.

Typing Children Props

A component that wraps other JSX elements can type its children with React's ReactNode type. This supports a broad range of renderable content, including elements, strings, numbers, arrays, and null values.

import type { ReactNode } from 'react';

interface PanelProps {
  title: string;
  children: ReactNode;
}

function Panel({ title, children }: PanelProps) {
  return (
    <section className="panel">
      <h2>{title}</h2>
      <div>{children}</div>
    </section>
  );
}

export default Panel;

The component can now wrap different types of content while preserving a consistent layout. If the children must be a specific element or component type, a more restrictive type may be appropriate.

Typing useRef

The useRef Hook is commonly used with TypeScript to reference DOM elements or store mutable values. For a DOM reference, include the element type and account for the fact that the element may initially be unavailable.

import { useRef } from 'react';

function SearchBox() {
  const inputRef = useRef<HTMLInputElement | null>(null);

  function focusInput() {
    inputRef.current?.focus();
  }

  return (
    <div>
      <input ref={inputRef} placeholder="Search" />
      <button onClick={focusInput}>Focus input</button>
    </div>
  );
}

export default SearchBox;

The optional chaining operator prevents an error when current is null. Learn more in React useRef.

Typing useEffect

TypeScript works with useEffect without requiring a special generic in most cases. Type the values used inside the Effect and return a cleanup function when necessary.

import { useEffect, useState } from 'react';

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

function UserDetails({ userId }: { userId: number }) {
  const [user, setUser] = useState<User | null>(null);

  useEffect(() => {
    const controller = new AbortController();

    async function loadUser() {
      try {
        const response = await fetch(
          '/api/users/' + userId,
          { signal: controller.signal }
        );

        if (!response.ok) {
          throw new Error('Failed to load user');
        }

        const data: User = await response.json();
        setUser(data);
      } catch (error) {
        if (error instanceof Error && error.name !== 'AbortError') {
          console.error(error.message);
        }
      }
    }

    loadUser();

    return () => controller.abort();
  }, [userId]);

  return <p>{user?.name ?? 'Loading user...'}</p>;
}

export default UserDetails;

This example assumes the API returns data matching the User interface. The annotation on data does not validate the JSON at runtime; use runtime validation when the response cannot be trusted. A production implementation should also represent loading and error states explicitly instead of displaying a loading message indefinitely after a failure.

For a deeper explanation of dependency arrays and cleanup, see React useEffect.

Typing API Responses Safely

A common TypeScript mistake is assuming that declaring an interface guarantees the shape of an API response. TypeScript types are removed during compilation and do not validate external JSON.

interface Product {
  id: number;
  name: string;
  price: number;
}

async function getProducts(): Promise<Product[]> {
  const response = await fetch('/api/products');

  if (!response.ok) {
    throw new Error('Failed to fetch products');
  }

  return response.json() as Promise<Product[]>;
}

This example describes the expected return type, but the assertion does not prove the server returned valid products. For external data, a safer approach is to receive the response as unknown and validate its structure before using it.

interface Product {
  id: number;
  name: string;
  price: number;
}

function isProduct(value: unknown): value is Product {
  if (typeof value !== 'object' || value === null) {
    return false;
  }

  const product = value as Record<string, unknown>;

  return (
    typeof product.id === 'number' &&
    typeof product.name === 'string' &&
    typeof product.price === 'number'
  );
}

async function getProduct(): Promise<Product> {
  const response = await fetch('/api/product/1');

  if (!response.ok) {
    throw new Error('Failed to fetch product');
  }

  const data: unknown = await response.json();

  if (!isProduct(data)) {
    throw new Error('Invalid product response');
  }

  return data;
}

The type guard checks the properties used by this example before treating the response as a Product. Real applications may require more comprehensive validation for nested objects, optional properties, and business rules.

Union Types for Loading States

A union type can represent the possible states of an asynchronous operation. This is often clearer and safer than managing several loosely related boolean flags.

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

type UserState =
  | { status: 'loading' }
  | { status: 'success'; user: User }
  | { status: 'error'; message: string };

function UserStatus({ state }: { state: UserState }) {
  switch (state.status) {
    case 'loading':
      return <p>Loading user...</p>;

    case 'success':
      return <p>Welcome, {state.user.name}</p>;

    case 'error':
      return <p role="alert">{state.message}</p>;
  }
}

TypeScript narrows the union based on the status property. Each case exposes only the fields that are valid for that state, reducing the chance of accessing unavailable data.

Typing Reusable Components

Reusable components benefit from clear prop types. For example, a generic list component can accept an array of items, a function that returns a unique key, and a renderer for each item.

import type { ReactNode } from 'react';

interface ListProps<T> {
  items: T[];
  getKey: (item: T) => string | number;
  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>
  );
}

export default List;

The generic type T allows the component to work with different item shapes without losing type information. The caller supplies a key function and rendering function, keeping the list component independent of any particular data model.

React keys should be stable and unique among siblings. Learn more in React Lists and Keys.

Common TypeScript Types in React

Type Purpose
ReactNode Values that can be rendered as React children.
ChangeEvent<T> Change events from typed elements such as inputs.
FormEvent<T> Form-related events.
MouseEvent<T> Mouse events associated with React elements.
CSSProperties Type for React inline style objects.
ComponentProps<T> Utility type for extracting a component's props.

Import React types from the React package as needed. Many event types can be inferred directly from JSX, so explicit annotations should be added where they improve clarity rather than repeated unnecessarily.

Best Practices for React TypeScript

  • Enable strict type checking in the TypeScript configuration when practical.
  • Use descriptive interfaces or type aliases for important domain models and component props.
  • Prefer type inference for straightforward local variables and explicit types at meaningful boundaries.
  • Represent optional and nullable values accurately instead of using unsafe defaults.
  • Use discriminated unions for mutually exclusive states such as loading, success, and failure.
  • Avoid any when possible; prefer unknown for data that needs validation.
  • Validate external data at runtime because TypeScript types do not validate API responses.
  • Use functional state updates when the next value depends on the previous value.
  • Keep component props focused and avoid overly complicated generic types unless they provide a real benefit.
  • Run the TypeScript checker and tests as part of your development or CI workflow.

Common Mistakes to Avoid

Using any for every value

Excessive use of any removes much of TypeScript's protection. Define a suitable type or use unknown when the value's structure is not yet known.

Assuming API data is validated by an interface

Interfaces exist only during type checking and are not runtime validators. Validate data from external systems before relying on its structure.

Using non-null assertions without a guarantee

The ! operator suppresses nullability errors but does not make a value exist at runtime. Prefer explicit checks or optional chaining when a value can be absent.

Creating overly complex prop types

Deeply nested or unnecessarily generic types can make components harder to understand. Start with a straightforward contract and introduce advanced types when the use case requires them.

Key Takeaway

TypeScript makes React applications easier to maintain by describing component contracts, state, events, and data structures. Use strong types where they clarify application behavior, validate external data at runtime, and keep the code simple enough for the entire team to understand.

Code Examples

interface UserCardProps {
  name: string;
  age: number;
  role?: string;
}

function UserCard({
  name,
  age,
  role = "Developer"
}: UserCardProps) {
  return (
    <article>
      <h2>{name}</h2>
      <p>Age: {age}</p>
      <p>Role: {role}</p>
    </article>
  );
}

export default UserCard;
    
import { useState } from "react";

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

function UserSelector() {
  const [count, setCount] = useState<number>(0);
  const [user, setUser] = useState<User | null>(null);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(current => current + 1)}>
        Increment
      </button>
      <p>Selected user: {user?.name ?? "None"}</p>
      <button onClick={() => setUser({ id: 1, name: "Alex" })}>
        Select user
      </button>
    </div>
  );
}

export default UserSelector;
    
import { useState } from "react";
import type { ChangeEvent, FormEvent } from "react";

function LoginForm() {
  const [email, setEmail] = useState("");

  function handleEmailChange(event: ChangeEvent<HTMLInputElement>) {
    setEmail(event.target.value);
  }

  function handleSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    console.log("Submitted email:", email);
  }

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="email">Email</label>
      <input
        id="email"
        type="email"
        value={email}
        onChange={handleEmailChange}
      />
      <button type="submit">Continue</button>
    </form>
  );
}

export default LoginForm;
    
import { useRef } from "react";

function SearchBox() {
  const inputRef = useRef<HTMLInputElement | null>(null);

  function focusInput() {
    inputRef.current?.focus();
  }

  return (
    <div>
      <input ref={inputRef} placeholder="Search" />
      <button onClick={focusInput}>Focus input</button>
    </div>
  );
}

export default SearchBox;
    
interface User {
  id: number;
  name: string;
}

type UserState =
  | { status: "loading" }
  | { status: "success"; user: User }
  | { status: "error"; message: string };

function UserStatus({ state }: { state: UserState }) {
  switch (state.status) {
    case "loading":
      return <p>Loading user...</p>;
    case "success":
      return <p>Welcome, {state.user.name}</p>;
    case "error":
      return <p role="alert">{state.message}</p>;
  }
}

export default UserStatus;
    

Pro Tip

Enable strict TypeScript checks, type component props and state deliberately, and treat API responses as untrusted data until validated. Strong types improve safety, but runtime validation is still necessary at external boundaries.

🎨 IgKnight Docs

Master HTML, CSS, JS, Python, .NET and more with easy-to-follow lessons and practical code examples.

© 2025 IgKnight Learning Platform. All rights reserved.