DEV Community

Cover image for React Mastery Series — Part 9: React with TypeScript — Building Type-Safe Applications
Hamed Farazi
Hamed Farazi

Posted on

React Mastery Series — Part 9: React with TypeScript — Building Type-Safe Applications

As React applications grow, keeping track of component props, state, API responses, and shared data becomes increasingly difficult.

JavaScript gives developers flexibility, but that flexibility can also lead to runtime errors that are difficult to catch before users encounter them.

This is where TypeScript comes in.

TypeScript adds a static type system to JavaScript. It helps identify mistakes during development, improves editor autocomplete, and makes refactoring safer.

In this guide, we'll learn how to use TypeScript effectively in React applications, from basic component props to advanced generics and API data validation.

By the end, you'll be able to build React components that are easier to understand, maintain, and scale.


1. Why Use TypeScript with React?

TypeScript provides several advantages:

  • Catch errors earlier: Detect incorrect prop values and incompatible types before running your application.
  • Better autocomplete: Get useful suggestions for component props, state, and function arguments.
  • Safer refactoring: Rename properties and update function signatures with more confidence.
  • Clearer contracts: Make it easier to understand what a component expects and returns.
  • Improved collaboration: Help teammates understand how different parts of an application work together.

Consider this JavaScript component:

function UserProfile({ user }) {
  return <h2>{user.name}</h2>;
}
Enter fullscreen mode Exit fullscreen mode

What happens if someone passes a string instead of a user object?

JavaScript won't catch the problem until runtime.

With TypeScript:

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

type UserProfileProps = {
  user: User;
};

function UserProfile({ user }: UserProfileProps) {
  return <h2>{user.name}</h2>;
}
Enter fullscreen mode Exit fullscreen mode

Now TypeScript can detect incorrect usage before the application runs.

Important: TypeScript does not validate data at runtime. Its types are removed during compilation, so external data—such as API responses—still needs runtime validation.


2. Setting Up a React + TypeScript Project

The easiest way to start a new React TypeScript application is with Vite.

Run:

npm create vite@latest my-react-app -- --template react-ts
Enter fullscreen mode Exit fullscreen mode

Then install dependencies:

cd my-react-app
npm install
npm run dev
Enter fullscreen mode Exit fullscreen mode

Your project will contain files similar to:

my-react-app/
├── src/
│   ├── App.tsx
│   ├── main.tsx
│   ├── index.css
│   └── assets/
├── public/
├── package.json
├── tsconfig.json
└── vite.config.ts
Enter fullscreen mode Exit fullscreen mode

.tsx vs .ts

Use:

  • .tsx for TypeScript files that contain JSX.
  • .ts for TypeScript files without JSX.

Examples:

Button.tsx
UserProfile.tsx
useProducts.ts
api.ts
types.ts
Enter fullscreen mode Exit fullscreen mode

Understanding tsconfig.json

The TypeScript configuration file controls how TypeScript checks and compiles your code.

Some important options include:

{
  "compilerOptions": {
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true
  }
}
Enter fullscreen mode Exit fullscreen mode

This is an illustration of useful compiler options, not a complete replacement for Vite's generated configuration.

The most important option is:

"strict": true
Enter fullscreen mode Exit fullscreen mode

It enables a collection of stricter type-checking rules and helps prevent many common mistakes.

For most React projects, keeping strict mode enabled is a great starting point.


3. TypeScript Fundamentals

Before typing React components, let's review the essential TypeScript types.

Basic Types

const username: string = "Alex";
const age: number = 28;
const isAdmin: boolean = false;

const scores: number[] = [90, 85, 100];
Enter fullscreen mode Exit fullscreen mode

TypeScript can often infer these types automatically:

const username = "Alex";
const age = 28;
Enter fullscreen mode Exit fullscreen mode

You don't need to explicitly annotate every variable.

Object Types

type User = {
  id: number;
  name: string;
  email: string;
  isActive: boolean;
};

const user: User = {
  id: 1,
  name: "Alex",
  email: "alex@example.com",
  isActive: true
};
Enter fullscreen mode Exit fullscreen mode

Optional Properties

Use ? when a property is not required:

type User = {
  id: number;
  name: string;
  avatar?: string;
};
Enter fullscreen mode Exit fullscreen mode

Now both of these objects are valid:

const userOne: User = {
  id: 1,
  name: "Alex"
};

const userTwo: User = {
  id: 2,
  name: "Sarah",
  avatar: "/avatars/sarah.png"
};
Enter fullscreen mode Exit fullscreen mode

Union Types

A union allows a value to have more than one possible type:

let userId: string | number;

userId = 101;
userId = "user-101";
Enter fullscreen mode Exit fullscreen mode

Union types are especially useful for application states:

type Status = "idle" | "loading" | "success" | "error";
Enter fullscreen mode Exit fullscreen mode

Now TypeScript only accepts one of those four values.

any vs unknown

Avoid using any whenever possible.

let value: any = "hello";

value.toFixed(); // TypeScript won't protect you here.
Enter fullscreen mode Exit fullscreen mode

unknown is safer:

let value: unknown = "hello";

// You must narrow the type before using it.
if (typeof value === "string") {
  console.log(value.toUpperCase());
}
Enter fullscreen mode Exit fullscreen mode

Use unknown when you receive data whose type hasn't been established yet.


4. Typing React Component Props

Props are one of the most common places where TypeScript becomes useful.

You can define props using either type or interface.

Using type

type GreetingProps = {
  name: string;
  age: number;
};

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

export default Greeting;
Enter fullscreen mode Exit fullscreen mode

Usage:

<Greeting name="Alex" age={28} />
Enter fullscreen mode Exit fullscreen mode

Passing an incorrect type will produce a TypeScript error:

<Greeting name="Alex" age="twenty-eight" />
Enter fullscreen mode Exit fullscreen mode

Using interface

interface ProductProps {
  title: string;
  price: number;
}

function ProductCard({ title, price }: ProductProps) {
  return (
    <article>
      <h2>{title}</h2>
      <p>${price}</p>
    </article>
  );
}
Enter fullscreen mode Exit fullscreen mode

Both type and interface are perfectly valid for describing object-shaped props.

A practical guideline:

  • Use interface when describing object contracts that may be extended or implemented.
  • Use type when working with unions, intersections, and more complex type expressions.

You don't need to enforce a strict rule. Consistency matters more.


5. Working with Children Props

React components often need to accept nested content.

Use React.ReactNode to describe content that React can render:

import type { ReactNode } from "react";

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

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

Usage:

<Card title="Welcome">
  <p>This is some content inside the card.</p>
  <button>Learn More</button>
</Card>
Enter fullscreen mode Exit fullscreen mode

You can also make children optional:

type CardProps = {
  title: string;
  children?: ReactNode;
};
Enter fullscreen mode Exit fullscreen mode

Another option is PropsWithChildren:

import type { PropsWithChildren } from "react";

type PanelProps = PropsWithChildren<{
  title: string;
}>;

function Panel({ title, children }: PanelProps) {
  return (
    <section>
      <h2>{title}</h2>
      {children}
    </section>
  );
}
Enter fullscreen mode Exit fullscreen mode

Remember that PropsWithChildren makes children optional. If your component requires children, explicitly declare children: ReactNode.


6. Typing Event Handlers

React event handlers are another important part of TypeScript development.

You can use React's event types to describe the events your functions receive.

Input Events

import type { ChangeEvent } from "react";

function SearchInput() {
  const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
    console.log(event.target.value);
  };

  return (
    <input
      type="text"
      placeholder="Search..."
      onChange={handleChange}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

Form Events

import type { FormEvent } from "react";

function LoginForm() {
  const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();

    console.log("Form submitted!");
  };

  return (
    <form onSubmit={handleSubmit}>
      <input type="email" placeholder="Email" />
      <button type="submit">Login</button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

Mouse Events

import type { MouseEvent } from "react";

function ActionButton() {
  const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
    console.log(event.currentTarget);
  };

  return (
    <button onClick={handleClick}>
      Click Me
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

Common React Event Types

Event Type
Input change ChangeEvent<HTMLInputElement>
Textarea change ChangeEvent<HTMLTextAreaElement>
Form submit FormEvent<HTMLFormElement>
Button click MouseEvent<HTMLButtonElement>
Select change ChangeEvent<HTMLSelectElement>

You don't always need to write these types manually.

TypeScript can infer the event type when you define the handler inline:

function SearchInput() {
  return (
    <input
      onChange={(event) => {
        console.log(event.target.value);
      }}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

Use explicit event types when defining reusable handler functions or when inference isn't sufficient.


7. Typing useState

React's useState works naturally with TypeScript.

Primitive State

TypeScript usually infers the type automatically:

import { useState } from "react";

function Counter() {
  const [count, setCount] = useState(0);

  return (
    <button onClick={() => setCount(count + 1)}>
      Count: {count}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

Since the initial value is a number, TypeScript knows that count is a number.

Array State

When initializing an empty array, provide its element type:

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

function ProductList() {
  const [products, setProducts] = useState<Product[]>([]);

  const addProduct = () => {
    setProducts((currentProducts) => [
      ...currentProducts,
      { id: 1, name: "Laptop" }
    ]);
  };

  return (
    <div>
      {products.map((product) => (
        <p key={product.id}>{product.name}</p>
      ))}

      <button onClick={addProduct}>Add Product</button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Nullable State

Sometimes a state value starts as null and receives an object later.

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

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

  return (
    <div>
      {user ? <h2>{user.name}</h2> : <p>No user selected.</p>}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The union User | null makes the possible states explicit.

Union State

type Status = "idle" | "loading" | "success" | "error";

function DataLoader() {
  const [status, setStatus] = useState<Status>("idle");

  return (
    <div>
      <p>Current status: {status}</p>

      <button onClick={() => setStatus("loading")}>
        Start Loading
      </button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

A useful rule: let TypeScript infer types when it can, but provide explicit types when the initial value doesn't communicate enough information.


8. Typing useRef

Refs are commonly used to access DOM elements or store mutable values.

DOM References

import { useRef } from "react";

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

  const focusInput = () => {
    inputRef.current?.focus();
  };

  return (
    <div>
      <input ref={inputRef} type="text" />

      <button onClick={focusInput}>
        Focus Input
      </button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Here:

  • HTMLInputElement describes the DOM element.
  • null represents the initial state before React attaches the element.
  • Optional chaining prevents accessing a method when the ref is still null.

Mutable Values

Refs can also store values that don't need to trigger a render:

import { useRef } from "react";

function Timer() {
  const renderCount = useRef(0);

  renderCount.current += 1;

  return <p>Render count: {renderCount.current}</p>;
}
Enter fullscreen mode Exit fullscreen mode

The value of a ref persists across renders, but changing it does not trigger a new render.

In real applications, avoid using a ref to manage data that should be displayed reactively. That's what state is for.


9. Typing useEffect

Most effects can rely on TypeScript's inference.

import { useEffect, useState } from "react";

function WindowWidth() {
  const [width, setWidth] = useState(window.innerWidth);

  useEffect(() => {
    const handleResize = () => {
      setWidth(window.innerWidth);
    };

    window.addEventListener("resize", handleResize);

    return () => {
      window.removeEventListener("resize", handleResize);
    };
  }, []);

  return <p>Window width: {width}px</p>;
}
Enter fullscreen mode Exit fullscreen mode

TypeScript understands the effect callback and its cleanup function.

You generally don't need to manually annotate the return type of useEffect.


10. Typing Custom Hooks

Custom Hooks benefit greatly from TypeScript because they often return data and functions that other components reuse.

Consider a Hook for managing a counter:

import { useState } from "react";

function useCounter(initialValue: number = 0) {
  const [count, setCount] = useState(initialValue);

  const increment = () => setCount((value) => value + 1);
  const decrement = () => setCount((value) => value - 1);
  const reset = () => setCount(initialValue);

  return {
    count,
    increment,
    decrement,
    reset
  };
}
Enter fullscreen mode Exit fullscreen mode

Usage:

function Counter() {
  const { count, increment, decrement, reset } = useCounter(5);

  return (
    <div>
      <h2>{count}</h2>

      <button onClick={increment}>+</button>
      <button onClick={decrement}>-</button>
      <button onClick={reset}>Reset</button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

TypeScript automatically infers the Hook's parameter and return types.

You can add explicit return types when they improve clarity or when a public API needs a stable contract.

type CounterResult = {
  count: number;
  increment: () => void;
  decrement: () => void;
  reset: () => void;
};

function useCounter(initialValue = 0): CounterResult {
  const [count, setCount] = useState(initialValue);

  return {
    count,
    increment: () => setCount((value) => value + 1),
    decrement: () => setCount((value) => value - 1),
    reset: () => setCount(initialValue)
  };
}
Enter fullscreen mode Exit fullscreen mode

11. Typing React Context Safely

Context allows components to share data without passing props through every level of the component tree.

Let's create a theme context.

First, define the context type:

type Theme = "light" | "dark";

type ThemeContextValue = {
  theme: Theme;
  toggleTheme: () => void;
};
Enter fullscreen mode Exit fullscreen mode

Next, create the context:

import { createContext } from "react";

const ThemeContext = createContext<ThemeContextValue | undefined>(
  undefined
);
Enter fullscreen mode Exit fullscreen mode

Create a provider:

import { useState } from "react";
import type { ReactNode } from "react";

type ThemeProviderProps = {
  children: ReactNode;
};

function ThemeProvider({ children }: ThemeProviderProps) {
  const [theme, setTheme] = useState<Theme>("light");

  const toggleTheme = () => {
    setTheme((current) =>
      current === "light" ? "dark" : "light"
    );
  };

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

Create a custom Hook that safely accesses the context:

import { useContext } from "react";

function useTheme() {
  const context = useContext(ThemeContext);

  if (!context) {
    throw new Error("useTheme must be used inside ThemeProvider");
  }

  return context;
}
Enter fullscreen mode Exit fullscreen mode

Now any component can use it:

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

  return (
    <button onClick={toggleTheme}>
      Current theme: {theme}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

This pattern is better than using an empty object or forcing a type assertion to pretend the context always has a value.

It gives you a clear error when a component is rendered outside its provider.


12. Typing API Responses

One of the most important TypeScript lessons is understanding that external data is not automatically safe.

Consider an API response:

{
  "id": 1,
  "name": "Laptop",
  "price": 1200
}
Enter fullscreen mode Exit fullscreen mode

We can describe the expected structure:

type Product = {
  id: number;
  name: string;
  price: number;
};
Enter fullscreen mode Exit fullscreen mode

However, this does not guarantee that the server actually sends that structure.

A Basic API Function

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

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

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

  if (!Array.isArray(data)) {
    throw new Error("Invalid products response");
  }

  return data.map((item): Product => {
    if (
      typeof item !== "object" ||
      item === null ||
      !("id" in item) ||
      !("name" in item) ||
      !("price" in item) ||
      typeof item.id !== "number" ||
      typeof item.name !== "string" ||
      typeof item.price !== "number"
    ) {
      throw new Error("Invalid product data");
    }

    return {
      id: item.id,
      name: item.name,
      price: item.price
    };
  });
}
Enter fullscreen mode Exit fullscreen mode

This example performs runtime checks before returning the data as Product[].

Important: Type Assertions Don't Validate Data

You might see code like this:

const products = (await response.json()) as Product[];
Enter fullscreen mode Exit fullscreen mode

This only tells TypeScript to trust you.

It does not inspect the response or verify that the properties exist.

For larger applications, a runtime validation library such as Zod can make this process more convenient.

Example:

import { z } from "zod";

const ProductSchema = z.object({
  id: z.number(),
  name: z.string(),
  price: z.number()
});

type Product = z.infer<typeof ProductSchema>;

async function fetchProduct(): 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();

  return ProductSchema.parse(data);
}
Enter fullscreen mode Exit fullscreen mode

Now the same schema provides both runtime validation and a TypeScript type.

This is especially useful when working with external APIs, forms, or user-generated data.


13. Understanding Generics in React

Generics allow you to create reusable functions and components that work with different types while preserving type safety.

Consider a simple function:

function identity<T>(value: T): T {
  return value;
}

const numberValue = identity(10);
const stringValue = identity("Hello");
Enter fullscreen mode Exit fullscreen mode

TypeScript understands the type of each returned value.

Generic List Component

Imagine building a list component that can display users, products, or any other data type.

Instead of creating a separate component for each type, use a generic component.

import type { ReactNode } from "react";

type 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

Use it with products:

type Product = {
  id: number;
  name: string;
  price: number;
};

const products: Product[] = [
  { id: 1, name: "Laptop", price: 1200 },
  { id: 2, name: "Phone", price: 800 }
];

function ProductList() {
  return (
    <List
      items={products}
      getKey={(product) => product.id}
      renderItem={(product) => (
        <span>
          {product.name} — ${product.price}
        </span>
      )}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

TypeScript infers T as Product.

The renderItem callback therefore knows that its argument has id, name, and price properties.

Generic Constraints

Sometimes a generic needs to have a particular property.

Use extends to define a constraint:

function getId<T extends { id: number }>(item: T): number {
  return item.id;
}
Enter fullscreen mode Exit fullscreen mode

Now the function accepts any object that has a numeric id.

Generics are especially useful for reusable UI components, data utilities, Hooks, and API helpers.


14. Typing Native HTML Props

Sometimes you want a custom React component to support all the normal HTML attributes of an element.

For example, a custom button may need to support disabled, type, aria-label, and onClick.

You can reuse React's built-in prop types:

import type { ComponentProps } from "react";

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

function Button({
  variant = "primary",
  children,
  ...props
}: ButtonProps) {
  return (
    <button
      className={`button button-${variant}`}
      {...props}
    >
      {children}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

Usage:

<Button
  variant="primary"
  disabled={false}
  onClick={() => console.log("Clicked")}
>
  Save Changes
</Button>
Enter fullscreen mode Exit fullscreen mode

This approach avoids manually recreating every native button property.

You can use ComponentProps<"input">, ComponentProps<"a">, and other HTML element types in the same way.


15. Discriminated Unions for Better Component APIs

Sometimes a component supports multiple modes, and each mode requires different props.

A discriminated union can make invalid combinations impossible to represent.

Consider a button that can behave as either a link or an action button:

type ButtonProps =
  | {
      variant: "link";
      href: string;
      onClick?: never;
    }
  | {
      variant: "button";
      onClick: () => void;
      href?: never;
    };
Enter fullscreen mode Exit fullscreen mode

Implement the component:

function ActionButton(props: ButtonProps) {
  if (props.variant === "link") {
    return <a href={props.href}>Open Page</a>;
  }

  return (
    <button onClick={props.onClick}>
      Perform Action
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

Valid usage:

<ActionButton variant="link" href="/about" />

<ActionButton
  variant="button"
  onClick={() => console.log("Saved")}
/>
Enter fullscreen mode Exit fullscreen mode

Invalid usage:

<ActionButton
  variant="link"
  href="/about"
  onClick={() => console.log("Clicked")}
/>
Enter fullscreen mode Exit fullscreen mode

TypeScript will report an error because the link variant doesn't accept an onClick property.

This pattern is useful for buttons, dialogs, notifications, input components, and other UI elements with multiple modes.


16. Building a Small Type-Safe Product Interface

Let's combine several concepts into a practical example.

We'll create:

  • A Product type
  • A typed ProductCard component
  • A typed product list
  • A callback for adding products to a cart

Step 1: Define the Product Type

export type Product = {
  id: number;
  name: string;
  price: number;
  category: "electronics" | "clothing" | "books";
  inStock: boolean;
};
Enter fullscreen mode Exit fullscreen mode

Step 2: Create ProductCard

import type { Product } from "./types";

type ProductCardProps = {
  product: Product;
  onAddToCart: (product: Product) => void;
};

function ProductCard({
  product,
  onAddToCart
}: ProductCardProps) {
  return (
    <article className="product-card">
      <h2>{product.name}</h2>

      <p>Category: {product.category}</p>
      <p>Price: ${product.price}</p>

      {product.inStock ? (
        <button onClick={() => onAddToCart(product)}>
          Add to Cart
        </button>
      ) : (
        <p>Out of stock</p>
      )}
    </article>
  );
}

export default ProductCard;
Enter fullscreen mode Exit fullscreen mode

Step 3: Create the Product List

import { useState } from "react";
import type { Product } from "./types";
import ProductCard from "./ProductCard";

const initialProducts: Product[] = [
  {
    id: 1,
    name: "Laptop",
    price: 1200,
    category: "electronics",
    inStock: true
  },
  {
    id: 2,
    name: "T-Shirt",
    price: 25,
    category: "clothing",
    inStock: true
  },
  {
    id: 3,
    name: "React Guide",
    price: 40,
    category: "books",
    inStock: false
  }
];

function ProductList() {
  const [cart, setCart] = useState<Product[]>([]);

  const addToCart = (product: Product) => {
    setCart((currentCart) => [
      ...currentCart,
      product
    ]);
  };

  return (
    <main>
      <h1>Products</h1>

      <p>Cart items: {cart.length}</p>

      <div className="product-grid">
        {initialProducts.map((product) => (
          <ProductCard
            key={product.id}
            product={product}
            onAddToCart={addToCart}
          />
        ))}
      </div>
    </main>
  );
}

export default ProductList;
Enter fullscreen mode Exit fullscreen mode

Notice how TypeScript protects the entire data flow:

  1. Every product must match the Product type.
  2. ProductCard requires a valid product.
  3. The callback must receive a Product.
  4. The cart can only contain products.

This makes the application easier to maintain as it grows.


17. Common TypeScript Mistakes in React

Even experienced developers can misuse TypeScript.

Here are some common mistakes to avoid.

Mistake 1: Using any Everywhere

Avoid:

function UserCard({ user }: any) {
  return <h2>{user.name}</h2>;
}
Enter fullscreen mode Exit fullscreen mode

Prefer:

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

type UserCardProps = {
  user: User;
};

function UserCard({ user }: UserCardProps) {
  return <h2>{user.name}</h2>;
}
Enter fullscreen mode Exit fullscreen mode

Mistake 2: Overusing Type Assertions

Avoid:

const user = responseData as User;
Enter fullscreen mode Exit fullscreen mode

This doesn't validate the data.

Prefer runtime validation when the data comes from an untrusted source.

Mistake 3: Using the Non-Null Assertion Everywhere

Avoid:

inputRef.current!.focus();
Enter fullscreen mode Exit fullscreen mode

The ! tells TypeScript to assume the value is not null.

If that assumption is wrong, the application can still crash.

Prefer:

inputRef.current?.focus();
Enter fullscreen mode Exit fullscreen mode

Mistake 4: Ignoring Nullable State

Avoid:

const [user, setUser] = useState<User>(null as any);
Enter fullscreen mode Exit fullscreen mode

Prefer:

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

Mistake 5: Manually Typing Every Variable

You don't need to annotate every value.

const count: number = 10;
const name: string = "Alex";
Enter fullscreen mode Exit fullscreen mode

Can usually be simplified to:

const count = 10;
const name = "Alex";
Enter fullscreen mode Exit fullscreen mode

Let TypeScript infer obvious types.

Mistake 6: Confusing TypeScript Types with Runtime Checks

This:

type User = {
  name: string;
};
Enter fullscreen mode Exit fullscreen mode

Doesn't guarantee that a server response contains a valid name.

Types help during development. Runtime validation protects your application from unexpected data.


18. Best Practices for React + TypeScript

Here are some practical recommendations:

  1. Enable strict TypeScript checking.
  2. Define reusable types for important domain models.
  3. Prefer precise types over any.
  4. Use unknown for untrusted values until they're validated.
  5. Let TypeScript infer types when the intent is obvious.
  6. Use React.ReactNode for renderable children.
  7. Type event handlers accurately.
  8. Use discriminated unions for components with multiple variants.
  9. Keep API validation separate from UI rendering.
  10. Use generics when they improve reuse without making code unnecessarily complex.
  11. Avoid unnecessary type assertions and non-null assertions.
  12. Keep component props focused and easy to understand.

Suggested Project Structure

As your application grows, organizing types and components becomes increasingly important.

src/
├── components/
│   ├── Button.tsx
│   ├── ProductCard.tsx
│   └── ProductList.tsx
├── hooks/
│   └── useProducts.ts
├── context/
│   └── ThemeContext.tsx
├── services/
│   └── productApi.ts
├── types/
│   ├── product.ts
│   └── user.ts
├── App.tsx
└── main.tsx
Enter fullscreen mode Exit fullscreen mode

You don't need to create a separate type file for every component. Keep small, component-specific prop types near the component itself.

Move shared domain types into dedicated files when multiple parts of the application need them.


19. TypeScript React Checklist

Before considering your React TypeScript code ready, check the following:

  • [ ] Strict mode is enabled.
  • [ ] Component props have meaningful types.
  • [ ] State types represent all possible values.
  • [ ] Nullable values are handled safely.
  • [ ] Event handlers use appropriate event types.
  • [ ] Custom Hooks expose clear return values.
  • [ ] Context values are checked safely.
  • [ ] API responses are validated at runtime.
  • [ ] any is avoided unless there's a strong reason.
  • [ ] Type assertions are used carefully.
  • [ ] Reusable components use generics when appropriate.
  • [ ] Components have clear and predictable contracts.

Final Thoughts

TypeScript doesn't replace good React architecture, testing, or runtime error handling.

What it does is make your code easier to reason about.

When you combine React's component model with TypeScript's type system, you get applications that are more predictable, easier to refactor, and simpler to maintain.

You don't need to use advanced generics or complicated utility types everywhere.

Start with component props, state, event handlers, and API models. As your application grows, introduce more advanced patterns where they provide real value.

The goal isn't to write the most complicated types.

The goal is to make incorrect code harder to write.


What's Next?

In Part 10: React Router — Routing, Nested Routes & Navigation, we'll explore how to build multi-page React applications without losing the benefits of a component-based architecture.

We'll cover:

  • Installing and configuring React Router
  • Creating routes and navigation links
  • Dynamic URL parameters
  • Nested routes and layouts
  • Route parameters and query strings
  • Protected routes
  • Programmatic navigation
  • Handling 404 pages

Stay tuned, and keep building! 🚀

Top comments (0)