Props
Define exactly what parent components are allowed to pass.
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.
npm create vite@latestThen select:
Typical structure:
project├── src│ ├── App.tsx│ ├── main.tsx│ └── index.css└── package.json.ts for TypeScript logic files.tsx for TypeScript files containing JSXProps
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> );}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 automaticallyReactNode accepts anything renderable in JSXconst [count, setCount] = useState(0);TypeScript infers count is number.
const [user, setUser] = useState<User | null>(null);When initial value is null, provide generic type.
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);};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.tsexport 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?
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.
src├── components│ ├── Card.tsx│ └── TeaCard.tsx├── hooks│ └── useFetch.ts├── types│ ├── tea.ts│ └── api.ts├── context│ └── auth-context.tsx└── pages └── Home.tsxThis separation keeps UI, type contracts, and business logic easy to maintain.
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.