Props
It defines exactly what a parent component is allowed to pass down.
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.
npm create vite@latestThen select:
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 JSXProps
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> );}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 [user, setUser] = useState<User | null>(null);When the starting value is null, you should give the generic type yourself, otherwise TypeScript will not know what type user should become later.
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);};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.tsexport 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?
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.
src├── components│ ├── Card.tsx│ └── TeaCard.tsx├── hooks│ └── useFetch.ts├── types│ ├── tea.ts│ └── api.ts├── context│ └── auth-context.tsx└── pages └── Home.tsxKeeping things separated like this makes it much easier to maintain your UI code, your type definitions, and your business logic separately.
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.