Articles › TypeScript
TypeScript for React Developers: Typing Props, State, Hooks and API Data
A practical TypeScript guide for React: typing props and children, state, events, refs, custom hooks, context, generic components, API responses and useful utility types.
TypeScript catches mistakes before your users do: a misspelled prop, a missing field in an API response, a function called with the wrong argument. In React projects it also turns your editor into documentation — autocomplete shows exactly which props a component accepts. This guide covers the TypeScript you'll actually use in day-to-day React work.
Setting up
npm create vite@latest my-app -- --template react-ts
Key tsconfig.json settings:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"jsx": "react-jsx",
"moduleResolution": "bundler"
}
}
Keep strict on. Turning it off removes most of the value of TypeScript.
Typing props
type ButtonProps = {
label: string;
variant?: "primary" | "ghost"; // optional, limited to two values
disabled?: boolean;
onClick: () => void;
};
export function Button({ label, variant = "primary", disabled = false, onClick }: ButtonProps) {
return (
<button className={`btn btn--${variant}`} disabled={disabled} onClick={onClick}>
{label}
</button>
);
}
<Button label="Save" onClick={save} />
<Button label="Save" variant="danger" onClick={save} /> // ❌ type error
type and interface both work for props; pick one style and be consistent. Avoid React.FC — typing props directly is simpler and plays better with generics.
Children and native attributes
type CardProps = {
title: string;
children: React.ReactNode;
};
// Extend native element props to accept className, id, aria-*, etc.
type InputProps = React.ComponentProps<"input"> & {
label: string;
error?: string;
};
function TextField({ label, error, ...inputProps }: InputProps) {
return (
<label>
{label}
<input {...inputProps} aria-invalid={Boolean(error)} />
{error && <span role="alert">{error}</span>}
</label>
);
}
Typing state
TypeScript infers simple state types:
const [count, setCount] = useState(0); // number
const [query, setQuery] = useState(""); // string
Provide the type when the initial value doesn't describe all possibilities:
type User = { id: number; name: string; email: string };
const [user, setUser] = useState<User | null>(null);
const [tags, setTags] = useState<string[]>([]);
Discriminated unions for async state
Instead of three independent booleans that can contradict each other, model the states explicitly:
type RequestState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: string };
function render(state: RequestState<User[]>) {
switch (state.status) {
case "loading": return <Spinner />;
case "error": return <p>{state.error}</p>; // TS knows `error` exists here
case "success": return <UserList users={state.data} />;
default: return null;
}
}
Impossible states like "loading and error" can't even be represented.
Typing events
function SearchBox({ onSearch }: { onSearch: (q: string) => void }) {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => onSearch(e.target.value);
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
};
const handleKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Escape") onSearch("");
};
return (
<form onSubmit={handleSubmit}>
<input onChange={handleChange} onKeyDown={handleKey} />
</form>
);
}
Tip: hover over the onChange prop in your editor to see the exact event type to use.
Typing refs
const inputRef = useRef<HTMLInputElement>(null); // DOM ref
const timerRef = useRef<number | undefined>(undefined); // mutable value
useEffect(() => {
inputRef.current?.focus(); // current may be null
}, []);
Typing custom hooks
function useLocalStorage<T>(key: string, initial: T) {
const [value, setValue] = useState<T>(() => {
const stored = localStorage.getItem(key);
return stored ? (JSON.parse(stored) as T) : initial;
});
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue] as const; // tuple, not (T | Dispatch)[]
}
const [theme, setTheme] = useLocalStorage<"dark" | "light">("theme", "dark");
as const keeps the returned tuple precisely typed.
Typing context
type AuthContextValue = {
user: User | null;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
};
const AuthContext = createContext<AuthContextValue | null>(null);
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used inside <AuthProvider>");
return ctx; // AuthContextValue — null is ruled out
}
Generic components
Generics let one component work with many data types while keeping type safety:
type ListProps<T> = {
items: T[];
getKey: (item: T) => string | number;
renderItem: (item: T) => React.ReactNode;
};
function List<T>({ items, getKey, renderItem }: ListProps<T>) {
return <ul>{items.map((item) => <li key={getKey(item)}>{renderItem(item)}</li>)}</ul>;
}
<List
items={articles}
getKey={(a) => a.id}
renderItem={(a) => a.title} // `a` is inferred as Article
/>
Typing API data
TypeScript types disappear at runtime, so the server can still send unexpected data. Define types for responses and, for critical data, validate at runtime:
type ArticleSummary = {
id: number;
slug: string;
title: string;
publishedAt: string; // ISO date string from JSON
};
async function getArticles(): Promise<ArticleSummary[]> {
const res = await fetch("/api/articles");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as Promise<ArticleSummary[]>;
}
With a schema library like Zod, one definition gives you both runtime validation and the TypeScript type:
import { z } from "zod";
const Article = z.object({ id: z.number(), slug: z.string(), title: z.string() });
type Article = z.infer<typeof Article>;
const data = Article.array().parse(await res.json());
You can also generate types from an ASP.NET Core OpenAPI document (e.g. with openapi-typescript) so frontend and backend stay in sync.
Utility types you'll use often
| Utility | Purpose | Example |
|---|---|---|
Partial<T> |
all properties optional | update payloads |
Pick<T, K> |
choose some properties | Pick<User, "id" \| "name"> |
Omit<T, K> |
remove properties | Omit<User, "password"> |
Record<K, V> |
object map | Record<Lang, string> |
ReturnType<F> |
type of a function's result | derive types from helpers |
NonNullable<T> |
remove null/undefined |
after a guard |
type Lang = "fa" | "en";
const labels: Record<Lang, string> = { fa: "فارسی", en: "English" };
Common mistakes
anyeverywhere — it switches type checking off. Useunknownand narrow it instead.- Non-null assertions (
!) to silence errors — handle thenullcase. - Casting API responses blindly for data that really can vary — validate it.
- Over-engineering types — if a type is harder to read than the code, simplify it.
Summary
Type props explicitly, let TypeScript infer simple state, model async state with discriminated unions, use React's event and element types, return tuples as const from hooks, guard context access, write generic components for reusable lists and tables, and treat API data as untrusted until validated. With strict mode on, TypeScript becomes a safety net that makes React refactoring fearless.
FAQ
Should I use type or interface for props?
Either works. Many React teams use type for its flexibility with unions; others prefer interface for extension. Consistency matters more.
Is TypeScript worth it for small projects?
Usually yes — the setup is trivial with Vite, and autocomplete alone pays off quickly.
Does TypeScript make my app slower?
No. Types are removed at build time; the JavaScript that runs is the same.