DEV Community

Cover image for TypeScript for React Developers: The Complete Practical Guide
Hamed Farazi
Hamed Farazi

Posted on

TypeScript for React Developers: The Complete Practical Guide

If you're learning React with TypeScript, you've probably encountered code like this:

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

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

But what exactly is type? Why do we use User? What does : User mean? What's the difference between type and interface? And how do TypeScript, React props, state, events, and hooks work together?

This guide covers the TypeScript fundamentals you actually need to build React applications confidently.

We'll start with the basics and gradually move into real-world React patterns.


Table of Contents

  1. What Is TypeScript?
  2. JavaScript vs. TypeScript
  3. Type Annotations
  4. Basic Types
  5. Type Inference
  6. Arrays and Objects
  7. type vs. interface
  8. Union and Literal Types
  9. Optional Properties
  10. Functions and Return Types
  11. Type Aliases and Reusable Types
  12. Type Narrowing
  13. Generics
  14. TypeScript with React Components
  15. Typing Props
  16. Optional Props and Default Values
  17. Children and ReactNode
  18. Event Handling
  19. useState
  20. useEffect
  21. useRef
  22. useReducer
  23. Context API
  24. Custom Hooks
  25. Forms
  26. API Requests
  27. Utility Types
  28. Enums vs. Literal Unions
  29. any, unknown, and never
  30. Type Assertions
  31. Common TypeScript Mistakes
  32. Recommended Learning Path

1. What Is TypeScript?

TypeScript is a programming language developed by Microsoft.

It is a superset of JavaScript, meaning that valid JavaScript code is generally valid TypeScript code.

TypeScript adds a static type system to JavaScript.

Consider this JavaScript function:

function add(a, b) {
  return a + b;
}

add(10, 20);
add("Hello", "World");
Enter fullscreen mode Exit fullscreen mode

JavaScript allows both calls.

But what if the function is supposed to work only with numbers?

TypeScript allows us to express that intention:

function add(a: number, b: number): number {
  return a + b;
}

add(10, 20);

// TypeScript error
add("Hello", "World");
Enter fullscreen mode Exit fullscreen mode

Now TypeScript can detect an incorrect function call before the application runs.

Does TypeScript run in the browser?

No. Browsers execute JavaScript, not TypeScript.

TypeScript code must be transformed into JavaScript before execution.

TypeScript
    ↓
Type checking and compilation
    ↓
JavaScript
    ↓
Browser
Enter fullscreen mode Exit fullscreen mode

Tools such as Vite, Next.js, and other build systems handle this transformation.

Important: TypeScript types are primarily checked during development and are erased from the generated JavaScript. They do not automatically validate data coming from an API at runtime.


2. JavaScript vs. TypeScript

JavaScript:

function getUserName(user) {
  return user.name;
}
Enter fullscreen mode Exit fullscreen mode

TypeScript:

function getUserName(user: { name: string }): string {
  return user.name;
}
Enter fullscreen mode Exit fullscreen mode

The TypeScript version tells us:

  • user must have a name property.
  • name must be a string.
  • The function returns a string.

TypeScript helps developers catch mistakes earlier, improve autocomplete, and understand code more easily.


3. Type Annotations

A type annotation tells TypeScript what type a variable, parameter, or return value should have.

The syntax is:

variableName: Type
Enter fullscreen mode Exit fullscreen mode

Examples:

let username: string = "Hamed";

let age: number = 26;

let isActive: boolean = true;
Enter fullscreen mode Exit fullscreen mode

Now consider:

let age: number = 26;

age = 30;

// TypeScript error
age = "Thirty";
Enter fullscreen mode Exit fullscreen mode

The variable is declared as a number, so assigning a string causes a type error.

Function parameters

function greet(name: string) {
  return `Hello, ${name}`;
}

greet("React Developer");
Enter fullscreen mode Exit fullscreen mode

Function return types

function multiply(a: number, b: number): number {
  return a * b;
}
Enter fullscreen mode Exit fullscreen mode

The : number after the parameter list specifies the function's return type.


4. Basic TypeScript Types

String

let firstName: string = "John";
Enter fullscreen mode Exit fullscreen mode

Number

let score: number = 100;
Enter fullscreen mode Exit fullscreen mode

TypeScript uses number for integers and floating-point numbers.

Boolean

let isLoggedIn: boolean = false;
Enter fullscreen mode Exit fullscreen mode

Null and Undefined

let emptyValue: null = null;

let missingValue: undefined = undefined;
Enter fullscreen mode Exit fullscreen mode

These types are especially important when working with optional values.

Object

let user: {
  name: string;
  age: number;
} = {
  name: "John",
  age: 25,
};
Enter fullscreen mode Exit fullscreen mode

Any

let value: any = "Hello";

value = 100;

value = true;
Enter fullscreen mode Exit fullscreen mode

any disables type checking for that value.

It can be convenient temporarily, but using it everywhere removes many of TypeScript's benefits.

We'll discuss safer alternatives later.


5. Type Inference

TypeScript can often determine a variable's type automatically.

This is called type inference.

let username = "John";
Enter fullscreen mode Exit fullscreen mode

TypeScript understands that username is a string.

Therefore, writing this is usually unnecessary:

let username: string = "John";
Enter fullscreen mode Exit fullscreen mode

Both examples work, but the first is more concise.

Another example:

let count = 10;

count = 20;

// TypeScript error
count = "Twenty";
Enter fullscreen mode Exit fullscreen mode

TypeScript inferred count as a number.

When should you explicitly define a type?

Use explicit types when they improve clarity or when TypeScript cannot infer the intended type.

Common examples include:

  • Function parameters
  • Component props
  • API response models
  • Complex objects
  • State that can contain multiple types
  • Reusable functions and hooks

In React, you'll often see type annotations around component props and state.


6. Arrays and Objects

Arrays

An array of strings:

const names: string[] = ["John", "Sarah", "Alex"];
Enter fullscreen mode Exit fullscreen mode

An array of numbers:

const scores: number[] = [10, 20, 30];
Enter fullscreen mode Exit fullscreen mode

Another syntax uses Array<T>:

const names: Array<string> = ["John", "Sarah"];
Enter fullscreen mode Exit fullscreen mode

Both approaches are valid.

For most React applications, string[] and number[] are easier to read.

Arrays of objects

Imagine an application displaying a list of users.

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

const users: User[] = [
  {
    id: 1,
    name: "John",
    email: "john@example.com",
  },
  {
    id: 2,
    name: "Sarah",
    email: "sarah@example.com",
  },
];
Enter fullscreen mode Exit fullscreen mode

Now every user must follow the User structure.

const newUser: User = {
  id: 3,
  name: "Alex",
  email: "alex@example.com",
};
Enter fullscreen mode Exit fullscreen mode

This becomes extremely useful when rendering lists in React.

{users.map((user) => (
  <div key={user.id}>
    <h2>{user.name}</h2>
    <p>{user.email}</p>
  </div>
))}
Enter fullscreen mode Exit fullscreen mode

7. type vs. interface

Both type and interface allow you to describe the structure of an object.

Using type

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

Using interface

interface User {
  id: number;
  name: string;
  email: string;
}
Enter fullscreen mode Exit fullscreen mode

For this example, both behave similarly.

Extending an interface

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

interface Admin extends User {
  permissions: string[];
}
Enter fullscreen mode Exit fullscreen mode

Now Admin includes the properties from User and adds permissions.

const admin: Admin = {
  id: 1,
  name: "John",
  permissions: ["read", "write"],
};
Enter fullscreen mode Exit fullscreen mode

Extending a type

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

type Admin = User & {
  permissions: string[];
};
Enter fullscreen mode Exit fullscreen mode

The & operator combines types. This is called an intersection type.

What's the difference?

  • interface is commonly used to describe object shapes.
  • type can describe objects, unions, primitives, tuples, and more.
  • Interfaces can be reopened and merged through declaration merging.
  • Type aliases cannot be reopened in the same way.

For React projects, either is perfectly acceptable.

A common convention is:

interface ButtonProps {
  label: string;
}

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

The most important thing is consistency within your project.


8. Union Types and Literal Types

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

let value: string | number;

value = "Hello";

value = 100;
Enter fullscreen mode Exit fullscreen mode

The | symbol means OR.

Union types in React

Imagine a component that receives either a username or a numeric user ID.

type UserIdentifier = string | number;

function getUser(id: UserIdentifier) {
  console.log(id);
}
Enter fullscreen mode Exit fullscreen mode

Both calls are valid:

getUser("john");

getUser(123);
Enter fullscreen mode Exit fullscreen mode

Literal types

Literal types restrict a value to specific options.

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

Now:

let status: Status = "loading";

status = "success";

// TypeScript error
status = "completed";
Enter fullscreen mode Exit fullscreen mode

Literal unions are extremely useful in React.

type ButtonVariant = "primary" | "secondary" | "danger";
Enter fullscreen mode Exit fullscreen mode
interface ButtonProps {
  variant: ButtonVariant;
  children: React.ReactNode;
}

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

Now the component only accepts the supported variants.

<Button variant="primary">Save</Button>

<Button variant="danger">Delete</Button>

// TypeScript error
<Button variant="banana">Click</Button>
Enter fullscreen mode Exit fullscreen mode

9. Optional Properties

Sometimes an object does not need every property.

Use ? to make a property optional.

interface User {
  name: string;
  age?: number;
}
Enter fullscreen mode Exit fullscreen mode

Both objects are valid:

const user1: User = {
  name: "John",
};

const user2: User = {
  name: "Sarah",
  age: 25,
};
Enter fullscreen mode Exit fullscreen mode

The age property may be present or absent.

However, accessing it requires care:

console.log(user1.age);
Enter fullscreen mode Exit fullscreen mode

The result may be undefined.

Optional properties in React

interface GreetingProps {
  name: string;
  subtitle?: string;
}

function Greeting({ name, subtitle }: GreetingProps) {
  return (
    <div>
      <h1>Hello, {name}</h1>

      {subtitle && <p>{subtitle}</p>}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Usage:

<Greeting name="John" />

<Greeting
  name="Sarah"
  subtitle="Welcome back!"
/>
Enter fullscreen mode Exit fullscreen mode

10. Functions and Return Types

TypeScript allows us to define the types of function parameters and return values.

function add(a: number, b: number): number {
  return a + b;
}
Enter fullscreen mode Exit fullscreen mode

The function accepts two numbers and returns a number.

Arrow functions

const subtract = (a: number, b: number): number => {
  return a - b;
};
Enter fullscreen mode Exit fullscreen mode

Functions that return nothing

function logMessage(message: string): void {
  console.log(message);
}
Enter fullscreen mode Exit fullscreen mode

void indicates that the function's return value is not intended to be used.

Function types

type Calculator = (a: number, b: number) => number;

const multiply: Calculator = (a, b) => {
  return a * b;
};
Enter fullscreen mode Exit fullscreen mode

This is useful for callbacks and React props.

interface ButtonProps {
  onClick: () => void;
}
Enter fullscreen mode Exit fullscreen mode

The onClick property is a function that accepts no arguments and returns nothing meaningful.


11. Reusable Types

Instead of repeatedly writing the same object structure, create reusable types.

Without a reusable type:

function getUserName(user: {
  id: number;
  name: string;
}) {
  return user.name;
}

function getUserId(user: {
  id: number;
  name: string;
}) {
  return user.id;
}
Enter fullscreen mode Exit fullscreen mode

This creates unnecessary duplication.

With a reusable type:

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

function getUserName(user: User): string {
  return user.name;
}

function getUserId(user: User): number {
  return user.id;
}
Enter fullscreen mode Exit fullscreen mode

This becomes especially valuable in large React applications.

You might define types for:

  • User
  • Product
  • Post
  • API response
  • Form values
  • Component props

A common project structure might look like:

src/
├── components/
│   ├── Button.tsx
│   └── UserCard.tsx
├── types/
│   └── user.ts
└── App.tsx
Enter fullscreen mode Exit fullscreen mode

Example user.ts:

export interface User {
  id: number;
  name: string;
  email: string;
}
Enter fullscreen mode Exit fullscreen mode

Then import it:

import type { User } from "../types/user";
Enter fullscreen mode Exit fullscreen mode

The import type syntax explicitly imports a type rather than a runtime value.


12. Type Narrowing

Type narrowing means making a broad type more specific after checking its value.

Consider:

function printValue(value: string | number) {
  console.log(value);
}
Enter fullscreen mode Exit fullscreen mode

The value could be a string or a number.

But what if we want to call a string-specific method?

function printValue(value: string | number) {
  if (typeof value === "string") {
    console.log(value.toUpperCase());
  } else {
    console.log(value.toFixed(2));
  }
}
Enter fullscreen mode Exit fullscreen mode

TypeScript understands:

  • Inside the first branch, value is a string.
  • Inside the second branch, value is a number.

This is called type narrowing.

Narrowing with typeof

function formatValue(value: string | number) {
  if (typeof value === "number") {
    return value.toFixed(2);
  }

  return value.toUpperCase();
}
Enter fullscreen mode Exit fullscreen mode

Narrowing with in

type Admin = {
  role: "admin";
  permissions: string[];
};

type User = {
  role: "user";
  name: string;
};

function displayAccount(account: Admin | User) {
  if ("permissions" in account) {
    console.log(account.permissions);
  } else {
    console.log(account.name);
  }
}
Enter fullscreen mode Exit fullscreen mode

Type narrowing is important when working with API responses and complex React state.


13. Generics

Generics are one of the most powerful TypeScript concepts.

They allow you to create reusable functions, types, and components while preserving type information.

Imagine this function:

function getValue(value: number): number {
  return value;
}
Enter fullscreen mode Exit fullscreen mode

It only works with numbers.

We could create another function for strings:

function getString(value: string): string {
  return value;
}
Enter fullscreen mode Exit fullscreen mode

But this duplicates logic.

Generics solve this problem.

Basic generic function

function identity<T>(value: T): T {
  return value;
}
Enter fullscreen mode Exit fullscreen mode

The T represents a type that will be determined when the function is used.

const numberValue = identity(100);

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

TypeScript understands:

numberValue // number
stringValue // string
Enter fullscreen mode Exit fullscreen mode

T is a convention, not a special keyword. You could use another name, but T is commonly used for "Type".

Generic arrays

function getFirstItem<T>(items: T[]): T | undefined {
  return items[0];
}
Enter fullscreen mode Exit fullscreen mode
const firstNumber = getFirstItem([10, 20, 30]);

const firstName = getFirstItem(["John", "Sarah"]);
Enter fullscreen mode Exit fullscreen mode

TypeScript infers the type from the array.

Generics in React

Imagine a reusable list component.

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

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

Usage:

const names = ["John", "Sarah", "Alex"];

<List
  items={names}
  renderItem={(name) => <span>{name}</span>}
/>
Enter fullscreen mode Exit fullscreen mode

TypeScript infers that name is a string.

For a more complex example:

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

const users: User[] = [
  { id: 1, name: "John" },
  { id: 2, name: "Sarah" },
];

<List
  items={users}
  renderItem={(user) => <span>{user.name}</span>}
/>
Enter fullscreen mode Exit fullscreen mode

Now user is automatically typed as User.

You don't need to manually annotate it.


14. TypeScript with React Components

React components are functions that return React elements.

A simple component:

function Welcome() {
  return <h1>Hello React!</h1>;
}
Enter fullscreen mode Exit fullscreen mode

TypeScript can infer the return type.

In most cases, you don't need to explicitly write:

function Welcome(): JSX.Element {
  return <h1>Hello React!</h1>;
}
Enter fullscreen mode Exit fullscreen mode

Modern React projects typically allow TypeScript to infer the component return type.

The most important thing to type explicitly is usually the component's props.


15. Typing React Props

Props are values passed from a parent component to a child component.

JavaScript:

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

TypeScript:

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

function UserCard({ name, age }: UserCardProps) {
  return (
    <div>
      <h2>{name}</h2>
      <p>{age}</p>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Usage:

<UserCard name="John" age={25} />
Enter fullscreen mode Exit fullscreen mode

Incorrect usage:

<UserCard name="John" age="Twenty-five" />
Enter fullscreen mode Exit fullscreen mode

TypeScript detects that age must be a number.

Why destructuring works

This syntax:

function UserCard({ name, age }: UserCardProps) {
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Means:

  1. The component receives an object.
  2. The object must follow UserCardProps.
  3. We extract name and age from that object.

You can also write:

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

Both approaches are valid.


16. Optional Props and Default Values

A component might have required and optional props.

interface ButtonProps {
  label: string;
  disabled?: boolean;
}
Enter fullscreen mode Exit fullscreen mode

Usage:

<Button label="Save" />

<Button label="Save" disabled={true} />
Enter fullscreen mode Exit fullscreen mode

You can provide a default value:

interface ButtonProps {
  label: string;
  disabled?: boolean;
}

function Button({ label, disabled = false }: ButtonProps) {
  return (
    <button disabled={disabled}>
      {label}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

Now disabled defaults to false.

This is a common pattern in React components.


17. Typing Children

React components often receive content between their opening and closing tags.

<Card>
  <h2>Hello</h2>
</Card>
Enter fullscreen mode Exit fullscreen mode

That content is called children.

The common type for arbitrary renderable React content is React.ReactNode.

import type { ReactNode } from "react";

interface CardProps {
  children: ReactNode;
}

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

Usage:

<Card>
  <h2>User Profile</h2>
  <p>Welcome back!</p>
</Card>
Enter fullscreen mode Exit fullscreen mode

ReactNode can represent many kinds of renderable content, including:

  • React elements
  • Strings
  • Numbers
  • Arrays of renderable nodes
  • null
  • undefined

Optional children

interface CardProps {
  children?: ReactNode;
}
Enter fullscreen mode Exit fullscreen mode

Use this when the component can be rendered without children.

ReactNode vs. ReactElement

ReactNode is broader and generally used when a component accepts arbitrary renderable content.

ReactElement describes a React element object.

For most component children props, ReactNode is the practical choice.


18. Typing React Events

React event handlers are one of the most important TypeScript topics.

Consider:

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

React and TypeScript can often infer the event type automatically.

However, sometimes you need to define it explicitly.

Input events

import type { ChangeEvent } from "react";

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

  return <input onChange={handleChange} />;
}
Enter fullscreen mode Exit fullscreen mode

The type:

ChangeEvent<HTMLInputElement>
Enter fullscreen mode Exit fullscreen mode

means this is a change event associated with an HTML input element.

Textarea events

import type { ChangeEvent } from "react";

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

  return <textarea onChange={handleChange} />;
}
Enter fullscreen mode Exit fullscreen mode

Select events

import type { ChangeEvent } from "react";

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

  return (
    <select onChange={handleChange}>
      <option value="react">React</option>
      <option value="vue">Vue</option>
    </select>
  );
}
Enter fullscreen mode Exit fullscreen mode

Click events

import type { MouseEvent } from "react";

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

  return <button onClick={handleClick}>Click me</button>;
}
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}>
      <button type="submit">Login</button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

Common React event types

Event Type
Input change ChangeEvent<HTMLInputElement>
Textarea change ChangeEvent<HTMLTextAreaElement>
Select change ChangeEvent<HTMLSelectElement>
Button click MouseEvent<HTMLButtonElement>
Form submission FormEvent<HTMLFormElement>
Keyboard event KeyboardEvent<HTMLInputElement>

You usually don't need to memorize every event type. TypeScript autocomplete and your editor can help.


19. Typing useState

useState is one of the most common React hooks.

TypeScript can infer the state type from the initial value.

import { useState } from "react";

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

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

TypeScript understands that count is a number.

Therefore:

setCount(10);
Enter fullscreen mode Exit fullscreen mode

is valid, but:

setCount("Ten");
Enter fullscreen mode Exit fullscreen mode

is not.

Explicit state types

Sometimes the initial value doesn't tell TypeScript enough.

For example:

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

Here:

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

The state can contain either a User or null.

Later:

setUser({
  id: 1,
  name: "John",
});
Enter fullscreen mode Exit fullscreen mode

State with multiple possible values

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

const [status, setStatus] = useState<Status>("idle");
Enter fullscreen mode Exit fullscreen mode

Now only those four values are allowed.

Array state

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

const [users, setUsers] = useState<User[]>([]);
Enter fullscreen mode Exit fullscreen mode

You can update it:

setUsers((previousUsers) => [
  ...previousUsers,
  {
    id: 1,
    name: "John",
  },
]);
Enter fullscreen mode Exit fullscreen mode

Important

Don't add explicit types unnecessarily.

This is usually enough:

const [count, setCount] = useState(0);
Enter fullscreen mode Exit fullscreen mode

But this is useful:

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

20. Typing useEffect

useEffect is used to synchronize a component with external systems, such as APIs, subscriptions, or browser features.

TypeScript usually infers the types inside useEffect.

import { useEffect, useState } from "react";

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

  useEffect(() => {
    console.log(count);
  }, [count]);

  return <p>{count}</p>;
}
Enter fullscreen mode Exit fullscreen mode

No additional type annotation is necessary.

Cleanup functions

import { useEffect } from "react";

function Timer() {
  useEffect(() => {
    const timer = setInterval(() => {
      console.log("Tick");
    }, 1000);

    return () => {
      clearInterval(timer);
    };
  }, []);

  return <p>Timer running</p>;
}
Enter fullscreen mode Exit fullscreen mode

TypeScript understands the cleanup function automatically.

The important lesson: you usually don't need to manually type useEffect.


21. Typing useRef

useRef is commonly used to access DOM elements or store values that persist between renders.

Accessing an input element

import { useRef } from "react";

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

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

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

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

Let's understand this:

useRef<HTMLInputElement>(null)
Enter fullscreen mode Exit fullscreen mode

The reference points to an HTML input element.

Initially, it is null because the DOM element hasn't been attached yet.

That's why the type is effectively:

HTMLInputElement | null
Enter fullscreen mode Exit fullscreen mode

The optional chaining operator:

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

checks whether current exists before calling focus().

Other DOM elements

useRef<HTMLDivElement>(null);

useRef<HTMLButtonElement>(null);

useRef<HTMLTextAreaElement>(null);
Enter fullscreen mode Exit fullscreen mode

Storing values

const countRef = useRef<number>(0);
Enter fullscreen mode Exit fullscreen mode

This stores a number without causing a component re-render when it changes.


22. Typing useReducer

useReducer is useful when state logic becomes more complex.

import { useReducer } from "react";

type State = {
  count: number;
};

type Action =
  | { type: "increment" }
  | { type: "decrement" }
  | { type: "reset" };

const initialState: State = {
  count: 0,
};

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case "increment":
      return {
        count: state.count + 1,
      };

    case "decrement":
      return {
        count: state.count - 1,
      };

    case "reset":
      return initialState;

    default:
      return state;
  }
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, initialState);

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

      <button onClick={() => dispatch({ type: "increment" })}>
        Increment
      </button>

      <button onClick={() => dispatch({ type: "decrement" })}>
        Decrement
      </button>

      <button onClick={() => dispatch({ type: "reset" })}>
        Reset
      </button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Notice this:

type Action =
  | { type: "increment" }
  | { type: "decrement" }
  | { type: "reset" };
Enter fullscreen mode Exit fullscreen mode

This is a discriminated union.

The type property identifies which action is being handled.

TypeScript can understand which action is active inside each case.

If you try:

dispatch({ type: "delete" });
Enter fullscreen mode Exit fullscreen mode

TypeScript will report an error.


23. Typing Context API

Context allows components to share values without passing props through every level.

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

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

interface AuthContextType {
  user: User | null;
  login: (user: User) => void;
  logout: () => void;
}

const AuthContext = createContext<AuthContextType | undefined>(
  undefined
);
Enter fullscreen mode Exit fullscreen mode

Now define the provider:

function AuthProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null);

  const login = (user: User) => {
    setUser(user);
  };

  const logout = () => {
    setUser(null);
  };

  return (
    <AuthContext.Provider value={{ user, login, logout }}>
      {children}
    </AuthContext.Provider>
  );
}
Enter fullscreen mode Exit fullscreen mode

Create a custom hook:

function useAuth() {
  const context = useContext(AuthContext);

  if (!context) {
    throw new Error("useAuth must be used within AuthProvider");
  }

  return context;
}
Enter fullscreen mode Exit fullscreen mode

Now components can access the context:

function Profile() {
  const { user, logout } = useAuth();

  if (!user) {
    return <p>Please log in</p>;
  }

  return (
    <div>
      <h2>{user.name}</h2>

      <button onClick={logout}>
        Logout
      </button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The context type guarantees that consumers receive the correct structure.


24. Typing Custom Hooks

Custom hooks are reusable functions that use React hooks.

TypeScript makes custom hooks safer and easier to reuse.

import { useState } from "react";

function useToggle(initialValue: boolean = false) {
  const [value, setValue] = useState(initialValue);

  const toggle = () => {
    setValue((previousValue) => !previousValue);
  };

  return {
    value,
    toggle,
  };
}
Enter fullscreen mode Exit fullscreen mode

Usage:

function App() {
  const { value, toggle } = useToggle();

  return (
    <button onClick={toggle}>
      {value ? "ON" : "OFF"}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

TypeScript infers the return type of the hook.

You don't need to manually write it unless there's a specific reason.

Custom hook with generics

import { useState } from "react";

function useArray<T>(initialValue: T[]) {
  const [items, setItems] = useState<T[]>(initialValue);

  const addItem = (item: T) => {
    setItems((previousItems) => [...previousItems, item]);
  };

  const removeItem = (index: number) => {
    setItems((previousItems) =>
      previousItems.filter((_, i) => i !== index)
    );
  };

  return {
    items,
    addItem,
    removeItem,
  };
}
Enter fullscreen mode Exit fullscreen mode

Usage:

const { items, addItem } = useArray<string>(["React", "TypeScript"]);

addItem("Next.js");
Enter fullscreen mode Exit fullscreen mode

This hook works with different data types:

const numbers = useArray<number>([1, 2, 3]);

numbers.addItem(4);
Enter fullscreen mode Exit fullscreen mode

25. Typing Forms

Forms are an essential part of React applications.

Let's create a login form.

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

interface LoginForm {
  email: string;
  password: string;
}

function Login() {
  const [form, setForm] = useState<LoginForm>({
    email: "",
    password: "",
  });

  const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
    const { name, value } = event.target;

    setForm((previousForm) => ({
      ...previousForm,
      [name]: value,
    }));
  };

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

    console.log(form);
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        name="email"
        type="email"
        value={form.email}
        onChange={handleChange}
      />

      <input
        name="password"
        type="password"
        value={form.password}
        onChange={handleChange}
      />

      <button type="submit">
        Login
      </button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

The LoginForm interface defines the structure of the form state.

The event handler receives a React change event.

The submit handler receives a React form event.

A note about dynamic property updates

This expression:

[name]: value
Enter fullscreen mode Exit fullscreen mode

uses a computed property name.

It allows us to update the field whose name matches the input.

For more complex forms, libraries such as React Hook Form can provide additional structure and validation.


26. Typing API Requests

One of the most important real-world TypeScript use cases is working with APIs.

Imagine an API returns this JSON:

{
  "id": 1,
  "name": "John",
  "email": "john@example.com"
}
Enter fullscreen mode Exit fullscreen mode

Create a type:

interface User {
  id: number;
  name: string;
  email: string;
}
Enter fullscreen mode Exit fullscreen mode

Then use it with Axios:

import axios from "axios";
import { useEffect, useState } from "react";

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

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

  useEffect(() => {
    async function fetchUser() {
      try {
        const response = await axios.get<User>(
          "https://api.example.com/users/1"
        );

        setUser(response.data);
      } catch (error) {
        console.error(error);
      }
    }

    fetchUser();
  }, []);

  if (!user) {
    return <p>Loading...</p>;
  }

  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

This part is important:

axios.get<User>(...)
Enter fullscreen mode Exit fullscreen mode

The generic tells Axios what type you expect the response data to have.

However, this is only a compile-time expectation.

TypeScript does not verify that the server actually returned a valid User.

If the API returns unexpected data, TypeScript won't automatically detect it at runtime.

For runtime validation, libraries such as Zod can help.

Typing fetch

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

async function getUser(): Promise<User> {
  const response = await fetch("/api/user");

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

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

  return data;
}
Enter fullscreen mode Exit fullscreen mode

This example describes the expected type, but it still doesn't validate the JSON received from the server.

For production applications, consider validating external data at runtime.


27. Utility Types

TypeScript includes built-in utility types that help transform existing types.

These are especially useful in React projects.

Partial

Partial<T> makes every property optional.

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

type UpdateUser = Partial<User>;
Enter fullscreen mode Exit fullscreen mode

Now:

const update: UpdateUser = {
  name: "Sarah",
};
Enter fullscreen mode Exit fullscreen mode

This is useful for update forms and partial updates.

Pick

Pick<T, Keys> selects specific properties.

type UserPreview = Pick<User, "id" | "name">;
Enter fullscreen mode Exit fullscreen mode

Result:

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

Omit

Omit<T, Keys> removes selected properties.

type UserWithoutEmail = Omit<User, "email">;
Enter fullscreen mode Exit fullscreen mode

Readonly

Readonly<T> makes properties readonly.

type ReadonlyUser = Readonly<User>;
Enter fullscreen mode Exit fullscreen mode

Now:

const user: ReadonlyUser = {
  id: 1,
  name: "John",
  email: "john@example.com",
};

// TypeScript error
user.name = "Sarah";
Enter fullscreen mode Exit fullscreen mode

Record

Record<Keys, Value> creates an object type with specific key and value types.

type UserRoles = Record<string, string>;
Enter fullscreen mode Exit fullscreen mode

Example:

const roles: UserRoles = {
  john: "admin",
  sarah: "editor",
};
Enter fullscreen mode Exit fullscreen mode

Another example:

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

type StatusMessages = Record<Status, string>;

const messages: StatusMessages = {
  success: "Operation completed",
  error: "Something went wrong",
  loading: "Please wait",
};
Enter fullscreen mode Exit fullscreen mode

ReturnType

ReturnType<T> extracts the return type of a function.

function createUser() {
  return {
    id: 1,
    name: "John",
  };
}

type CreatedUser = ReturnType<typeof createUser>;
Enter fullscreen mode Exit fullscreen mode

This is useful when you want to reuse a function's return type.


28. Enums vs. Literal Unions

TypeScript supports enums.

enum Status {
  Loading,
  Success,
  Error,
}
Enter fullscreen mode Exit fullscreen mode

Usage:

const currentStatus = Status.Loading;
Enter fullscreen mode Exit fullscreen mode

But in many React applications, literal unions are a simpler alternative.

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

Usage:

const currentStatus: Status = "loading";
Enter fullscreen mode Exit fullscreen mode

Literal unions are often preferred because:

  • They are simple.
  • They work naturally with string values.
  • They don't require an enum declaration.
  • They are convenient for component props and state.

Enums are still useful in certain situations, but you don't need to use them everywhere.


29. any, unknown, and never

These three types are important to understand.

any

let value: any = "Hello";

value.toUpperCase();

value = 100;

value.someRandomMethod();
Enter fullscreen mode Exit fullscreen mode

TypeScript stops checking many operations involving any.

This can hide bugs.

Avoid using any unless you have a specific reason.

unknown

unknown is a safer alternative to any.

let value: unknown = "Hello";
Enter fullscreen mode Exit fullscreen mode

You cannot use the value freely without checking its type.

if (typeof value === "string") {
  console.log(value.toUpperCase());
}
Enter fullscreen mode Exit fullscreen mode

This makes unknown useful when handling data whose type is not known yet.

For example:

function handleError(error: unknown) {
  if (error instanceof Error) {
    console.log(error.message);
  } else {
    console.log("An unknown error occurred");
  }
}
Enter fullscreen mode Exit fullscreen mode

This is a good pattern for handling errors in TypeScript.

never

never represents a value that should never occur.

One common use is exhaustive checking.

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

function handleStatus(status: Status) {
  switch (status) {
    case "loading":
      return "Loading...";

    case "success":
      return "Success!";

    case "error":
      return "Error!";
  }
}
Enter fullscreen mode Exit fullscreen mode

A helper for exhaustive checking:

function assertNever(value: never): never {
  throw new Error(`Unexpected value: ${value}`);
}
Enter fullscreen mode Exit fullscreen mode
function handleStatus(status: Status) {
  switch (status) {
    case "loading":
      return "Loading...";

    case "success":
      return "Success!";

    case "error":
      return "Error!";

    default:
      return assertNever(status);
  }
}
Enter fullscreen mode Exit fullscreen mode

If someone adds a new status and forgets to handle it, TypeScript can help detect the missing case.


30. Type Assertions

Sometimes you know more about a value than TypeScript does.

A type assertion tells TypeScript how you want to treat that value.

const value = document.getElementById("username") as HTMLInputElement;
Enter fullscreen mode Exit fullscreen mode

Now TypeScript treats value as an input element.

But be careful.

A type assertion does not perform runtime validation.

It does not convert a value into another type.

For example:

const value = "123" as unknown as number;
Enter fullscreen mode Exit fullscreen mode

This does not convert the string into a number.

The value is still a string at runtime.

Use assertions carefully

Assertions are sometimes necessary, but they can hide errors if used incorrectly.

Prefer proper type narrowing when possible.


31. Common TypeScript Mistakes in React

Mistake 1: Using any everywhere

Bad:

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

Better:

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

interface UserCardProps {
  user: User;
}

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

Mistake 2: Overusing explicit types

Unnecessary:

const [count, setCount] = useState<number>(0);
Enter fullscreen mode Exit fullscreen mode

Usually enough:

const [count, setCount] = useState(0);
Enter fullscreen mode Exit fullscreen mode

TypeScript already knows the type.

Mistake 3: Forgetting null

Incorrect:

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

Correct:

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

Mistake 4: Confusing type annotations with runtime validation

const user: User = await response.json();
Enter fullscreen mode Exit fullscreen mode

This does not validate the API response.

It only tells TypeScript to treat the value as User.

Mistake 5: Using React.FC everywhere

You may encounter:

const Button: React.FC<ButtonProps> = ({ children }) => {
  return <button>{children}</button>;
};
Enter fullscreen mode Exit fullscreen mode

This is valid.

But modern React with TypeScript doesn't require React.FC.

You can simply write:

function Button({ children }: ButtonProps) {
  return <button>{children}</button>;
}
Enter fullscreen mode Exit fullscreen mode

This is a common and straightforward approach.


32. Recommended Learning Path

You don't need to master every advanced TypeScript feature before starting React.

Focus on the following order:

Level 1 — Essential TypeScript

  • string
  • number
  • boolean
  • Arrays
  • Objects
  • Type annotations
  • Type inference
  • Functions
  • type
  • interface
  • Optional properties

Level 2 — React TypeScript

  • Component props
  • Props destructuring
  • Children
  • Optional props
  • Event types
  • useState
  • useEffect
  • useRef

Level 3 — Intermediate React TypeScript

  • Union types
  • Literal types
  • Type narrowing
  • Generics
  • Context API
  • useReducer
  • Custom hooks
  • API response types

Level 4 — Advanced TypeScript

  • Utility types
  • Discriminated unions
  • unknown
  • never
  • Type assertions
  • Generic components
  • Runtime validation

Final Thoughts

TypeScript isn't about adding complicated syntax to JavaScript.

It's about making your code easier to understand, safer to maintain, and more predictable as your application grows.

When working with React, the most important TypeScript concepts are:

  • Typing component props
  • Understanding objects and interfaces
  • Working with union types
  • Typing state correctly
  • Handling React events
  • Understanding generics
  • Typing API responses
  • Using utility types
  • Avoiding unnecessary any

You don't need to memorize every TypeScript feature.

Learn the fundamentals, practice them in React components, and gradually introduce more advanced concepts as your applications become more complex.

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

The goal is to write clear, maintainable, and reliable React code.

Happy coding! 🚀


What TypeScript concept was the most confusing when you started learning React?

Share your experience in the comments. Let's help each other become better developers.

TypeScript #React #JavaScript #WebDevelopment #FrontendDevelopment

Top comments (0)