Skip to content

React with TypeScript in Detail

React with TypeScript is a great combination. It helps you build user interfaces that are strong and easy to maintain. In this section, we will learn how to use TypeScript with React the right way. This includes typing props, managing state, handling events, using hooks, using the context API, and modeling API responses. By the end of this section, you will understand how to use TypeScript in your React apps to get better type safety and a smoother coding experience.

Most beginners feel that React with TypeScript is a totally new and different way of writing components.

That is not true.

React code is still React code. TypeScript just adds some type rules on top, around your data and your functions.

graph LR A[React UI Logic] --> B[Type Definitions] B --> C[Safer Components] C --> D[Fewer Runtime Bugs]
Terminal window
npm create vite@latest

Then select:

  • React
  • TypeScript

Typical structure:

project
├── src
│ ├── App.tsx
│ ├── main.tsx
│ └── index.css
└── package.json
  • .ts is used for files that only have TypeScript logic, no JSX
  • .tsx is used for TypeScript files that also contain JSX

Props

It defines exactly what a parent component is allowed to pass down.

State

It stops invalid state values and states that should not be possible.

Events

It gives you strongly typed event objects inside your handlers.

API Data

It makes sure the response shape is handled correctly in the UI.

Hooks

Reusable hooks become safer and easier to use.

Context

It lets you share strongly typed values across your component tree.

function Button(props: any) {
return <button>{props.label}</button>;
}
interface ButtonProps {
label: string;
onClick?: () => void;
disabled?: boolean;
}
function Button({ label, onClick, disabled = false }: ButtonProps) {
return (
<button onClick={onClick} disabled={disabled}>
{label}
</button>
);
}
graph TD A[Parent Component] -->|passes props| B[ButtonProps Contract] B --> C[Button Component] B --> D[Compile-time Validation]
interface TeaCardProps {
name: string;
price: number;
isSpecial?: boolean;
}
export function TeaCard({ name, price, isSpecial = false }: TeaCardProps) {
return (
<article>
<h2>
{name} {isSpecial && "⭐"}
</h2>
<p>Price: {price}</p>
</article>
);
}

If the parent component does not pass isSpecial, then the default value false is used instead.

import type { PropsWithChildren, ReactNode } from "react";
interface CardProps extends PropsWithChildren {
title: string;
footer?: ReactNode;
}
export function Card({ title, footer, children }: CardProps) {
return (
<section>
<h2>{title}</h2>
{children}
{footer && <footer>{footer}</footer>}
</section>
);
}
  • PropsWithChildren automatically adds a children prop for you.
  • ReactNode is a type that accepts almost anything that can be shown (rendered) inside JSX.
const [count, setCount] = useState(0);

Here, TypeScript automatically figures out that count is a number, you do not need to write the type yourself.

const [items, setItems] = useState<Tea[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
type Action =
| { type: "add"; payload: Tea }
| { type: "remove"; payload: number }
| { type: "reset" };
function reducer(state: Tea[], action: Action): Tea[] {
switch (action.type) {
case "add":
return [...state, action.payload];
case "remove":
return state.filter((tea) => tea.id !== action.payload);
case "reset":
return [];
default: {
const _never: never = action;
return _never;
}
}
}

Exhaustive checks (like the default case above) help make sure you do not forget to handle a new action if one gets added later.

const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setName(e.target.value);
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
};
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
console.log(e.currentTarget.disabled);
};
graph TD A[User Event] --> B[React Synthetic Event] B --> C[Specific Generic Type] C --> D[Safe Access to target and currentTarget]

Form Handling: Number Inputs and Conversion

Section titled “Form Handling: Number Inputs and Conversion”

HTML input values always come as strings, even when the input type is type="number".

const handleCupsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setCups(Number(e.target.value));
};

If the conversion does not work properly, Number(...) will return NaN (which means “not a number”), so make sure to check for this where it matters.

In medium and large apps, it is a good idea to keep your shared types in their own separate files.

// src/types/tea.ts
export interface Tea {
id: number;
name: string;
price: number;
}

Use type-only imports wherever you can:

import type { Tea } from "../types/tea";

This avoids adding extra, unnecessary code at runtime, since you are only importing the type, not any actual logic.

import type { Tea } from "../types/tea";
interface TeaListProps {
items: Tea[];
}
export function TeaList({ items }: TeaListProps) {
return (
<div>
{items.map((tea) => (
<TeaCard
key={tea.id}
name={tea.name}
price={tea.price}
isSpecial={tea.price > 30}
/>
))}
</div>
);
}

Now TypeScript makes sure that tea.id, tea.name, and tea.price always exist, and that they are always the correct type.

Generic hooks let you write one single hook that can work with many different response types.

interface FetchState<T> {
data: T | null;
loading: boolean;
error: string | null;
}
export function useFetch<T>(url: string): FetchState<T> {
const [state, setState] = useState<FetchState<T>>({
data: null,
loading: true,
error: null,
});
useEffect(() => {
let mounted = true;
fetch(url)
.then((res) => {
if (!res.ok) throw new Error("Request failed");
return res.json() as Promise<T>;
})
.then((data) => {
if (mounted) setState({ data, loading: false, error: null });
})
.catch((err: Error) => {
if (mounted) setState({ data: null, loading: false, error: err.message });
});
return () => {
mounted = false;
};
}, [url]);
return state;
}

Usage:

const users = useFetch<User[]>("/api/users");
const products = useFetch<Product[]>("/api/products");

Here, the same useFetch hook is reused for two completely different data types, just by changing what is passed inside the < > brackets.

interface AuthUser {
id: string;
name: string;
}
interface AuthContextValue {
user: AuthUser | null;
login: (user: AuthUser) => void;
logout: () => void;
}
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) {
throw new Error("useAuth must be used inside AuthProvider");
}
return ctx;
}

Why use undefined as the default value for the context?

  • It forces a check at runtime, so mistakes are caught early.
  • It stops the context from being used accidentally outside of its provider.
type ApiResponse<T> =
| { status: "success"; data: T }
| { status: "error"; message: string };
function UserPanel({ response }: { response: ApiResponse<User[]> }) {
if (response.status === "error") {
return <p>{response.message}</p>;
}
return (
<ul>
{response.data.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}

Mistake: use any everywhere

Better: write proper interfaces for your props and your API models instead.

Mistake: no null in state type

Better: use a union like User | null when the data is being loaded asynchronously (meaning it is not there right away).

Mistake: wrong event type

Better: use the correct, specific React event type for each kind of element.

Mistake: huge inline types

Better: pull these out into reusable, named interfaces and type aliases.

Suggested Folder Strategy for React + TS Apps

Section titled “Suggested Folder Strategy for React + TS Apps”
src
├── components
│ ├── Card.tsx
│ └── TeaCard.tsx
├── hooks
│ └── useFetch.ts
├── types
│ ├── tea.ts
│ └── api.ts
├── context
│ └── auth-context.tsx
└── pages
└── Home.tsx

Keeping things separated like this makes it much easier to maintain your UI code, your type definitions, and your business logic separately.

graph LR A[API Response] --> B[Typed Model] B --> C[State and Hooks] C --> D[Typed Props] D --> E[UI Components] E --> F[Typed Events and Actions]

React is used to build the UI.

TypeScript adds clear rules for how data moves around inside that UI.

When you properly type your props, state, events, and API contracts, your app becomes much easier to grow over time, and much safer to change later.