Skip to content

React with TypeScript in Detail

Most beginners feel React with TypeScript is a completely new way of writing components.

That is not true.

React code is still React code. TypeScript only adds type rules around your data and function contracts.

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 for TypeScript logic files
  • .tsx for TypeScript files containing JSX

Props

Define exactly what parent components are allowed to pass.

State

Prevent invalid state values and impossible states.

Events

Get strongly typed event objects in handlers.

API Data

Ensure response shape is handled correctly in UI.

Hooks

Reusable hooks become safer and easier to consume.

Context

Share strongly typed values across 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 parent does not provide isSpecial, default false is used.


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 adds children automatically
  • ReactNode accepts anything renderable in JSX

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

TypeScript infers count is number.

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 help prevent missing branches when new actions are added.


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 come as strings, even for type="number".

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

If conversion fails, Number(...) returns NaN, so validate where needed.


In medium and large apps, put shared types in dedicated files.

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

Use type-only imports where possible:

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

This avoids unnecessary runtime imports.


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>
);
}

TypeScript now guarantees that tea.id, tea.name, and tea.price exist and are correct types.


Generic hooks let one hook support many 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");

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 in context default?

  • It forces a runtime guard.
  • It prevents accidental usage outside 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: define interfaces for props and API models.

Mistake: no null in state type

Better: use union like User | null when loading asynchronously.

Mistake: wrong event type

Better: use specific React event generics for each element.

Mistake: huge inline types

Better: extract reusable named interfaces and 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

This separation keeps UI, type contracts, and business logic easy to maintain.


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 builds UI.

TypeScript adds clear rules for data moving through that UI.

When you type props, state, events, and API contracts well, your app becomes easier to scale and safer to refactor.