Deep Dive: React with TypeScript Best Practices

Welcome to the Deep Dive
You know TypeScript. You know React. But combining them effectively requires understanding patterns that neither teaches in isolation. This post covers production-tested patterns for building type-safe React applications — from component props to hooks to context to performance.
These patterns are used in real codebases at companies like Vercel, Meta, and Airbnb. Master them, and you'll write React code that catches bugs at compile time instead of in production.
Prerequisite: Phase 2: Frontend Development Time commitment: 2-3 hours (work through the examples)
What You'll Learn
✅ Component prop patterns (children, discriminated unions, polymorphic components)
✅ Hooks typing (useState, useRef, custom hooks with generics)
✅ Context API with full type safety
✅ Render props and compound components
✅ Higher-Order Components (HOCs) with proper types
✅ Event handlers and form typing
✅ Ref forwarding and imperative handles
✅ Performance optimization (memo, useMemo, useCallback)
✅ Error boundaries with TypeScript
✅ Suspense and lazy loading types
1. Component Prop Patterns
Basic Props with Interface
interface ButtonProps {
label: string;
onClick: () => void;
disabled?: boolean;
variant?: "primary" | "secondary" | "danger";
}
function Button({
label,
onClick,
disabled = false,
variant = "primary"
}: ButtonProps) {
return (
<button
className={`btn btn-${variant}`}
onClick={onClick}
disabled={disabled}
>
{label}
</button>
);
}Children Props
There are several ways to type children — use the right one for your needs:
import { ReactNode, PropsWithChildren } from "react";
// Option 1: Explicit ReactNode (most common)
interface CardProps {
title: string;
children: ReactNode;
}
function Card({ title, children }: CardProps) {
return (
<div className="card">
<h2>{title}</h2>
<div className="card-body">{children}</div>
</div>
);
}
// Option 2: PropsWithChildren utility
interface PanelProps {
bordered?: boolean;
}
function Panel({ bordered, children }: PropsWithChildren<PanelProps>) {
return (
<div className={bordered ? "border" : ""}>
{children}
</div>
);
}
// Option 3: Render prop pattern (function as child)
interface DataFetcherProps<T> {
url: string;
children: (data: T, loading: boolean) => ReactNode;
}
function DataFetcher<T>({ url, children }: DataFetcherProps<T>) {
const { data, loading } = useFetch<T>(url);
return <>{children(data as T, loading)}</>;
}Discriminated Union Props
When props are mutually exclusive, use discriminated unions:
// Bad: allows invalid combinations
interface BadAlertProps {
type: "success" | "error" | "loading";
message?: string;
progress?: number;
}
// Good: discriminated union enforces valid combinations
type AlertProps =
| { type: "success"; message: string }
| { type: "error"; message: string; onRetry?: () => void }
| { type: "loading"; progress: number };
function Alert(props: AlertProps) {
switch (props.type) {
case "success":
return <div className="alert-success">{props.message}</div>;
case "error":
return (
<div className="alert-error">
{props.message}
{props.onRetry && <button onClick={props.onRetry}>Retry</button>}
</div>
);
case "loading":
return <div className="alert-loading">Loading... {props.progress}%</div>;
}
}
// Usage
<Alert type="success" message="Saved!" />
<Alert type="error" message="Failed" onRetry={() => retry()} />
<Alert type="loading" progress={75} />
// <Alert type="success" progress={50} /> // Error! 'message' is requiredPolymorphic Components (Render As)
Let components render as different HTML elements:
import { ElementType, ComponentPropsWithoutRef } from "react";
type PolymorphicProps<E extends ElementType> = {
as?: E;
} & ComponentPropsWithoutRef<E>;
function Box<E extends ElementType = "div">({
as,
children,
...props
}: PolymorphicProps<E> & { children?: ReactNode }) {
const Component = as || "div";
return <Component {...props}>{children}</Component>;
}
// Usage
<Box>Default div</Box>
<Box as="section">Renders as section</Box>
<Box as="a" href="/about">Renders as anchor</Box>
<Box as="button" onClick={() => alert("clicked")}>Renders as button</Box>Extending HTML Element Props
import { ComponentPropsWithoutRef, ComponentPropsWithRef } from "react";
// Extend button props
interface CustomButtonProps extends ComponentPropsWithoutRef<"button"> {
variant: "primary" | "secondary";
isLoading?: boolean;
}
function CustomButton({
variant,
isLoading,
children,
disabled,
...rest
}: CustomButtonProps) {
return (
<button
className={`btn-${variant}`}
disabled={disabled || isLoading}
{...rest}
>
{isLoading ? "Loading..." : children}
</button>
);
}
// Extend input props
interface TextInputProps extends ComponentPropsWithoutRef<"input"> {
label: string;
error?: string;
}
function TextInput({ label, error, id, ...rest }: TextInputProps) {
return (
<div className="form-field">
<label htmlFor={id}>{label}</label>
<input id={id} {...rest} />
{error && <span className="error">{error}</span>}
</div>
);
}2. Hooks Typing
useState
import { useState } from "react";
// Inferred type (string)
const [name, setName] = useState("");
// Explicit type for complex state
interface User {
id: string;
name: string;
email: string;
}
const [user, setUser] = useState<User | null>(null);
// Union type for loading states
type Status = "idle" | "loading" | "success" | "error";
const [status, setStatus] = useState<Status>("idle");
// Array with explicit type
const [items, setItems] = useState<string[]>([]);useRef
import { useRef, useEffect } from "react";
// DOM element ref — initially null, will be assigned
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
// TypeScript knows inputRef.current might be null
inputRef.current?.focus();
}, []);
// Mutable value ref — not null, value persists
const countRef = useRef<number>(0);
countRef.current += 1; // OK, always defined
// Timer/interval ref
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
timerRef.current = setTimeout(() => {
console.log("Timer fired");
}, 1000);
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
}
};
}, []);Key distinction:
useRef<T>(null)— for DOM refs,.currentisT | nulluseRef<T>(initialValue)— for mutable values,.currentis alwaysT
useReducer
import { useReducer } from "react";
interface State {
count: number;
error: string | null;
}
type Action =
| { type: "increment" }
| { type: "decrement" }
| { type: "reset"; payload: number }
| { type: "error"; payload: string };
function reducer(state: State, action: Action): State {
switch (action.type) {
case "increment":
return { ...state, count: state.count + 1, error: null };
case "decrement":
return { ...state, count: state.count - 1, error: null };
case "reset":
return { ...state, count: action.payload, error: null };
case "error":
return { ...state, error: action.payload };
default:
// Exhaustive check
const _exhaustive: never = action;
return state;
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, { count: 0, error: null });
return (
<div>
<p>Count: {state.count}</p>
<button onClick={() => dispatch({ type: "increment" })}>+</button>
<button onClick={() => dispatch({ type: "decrement" })}>-</button>
<button onClick={() => dispatch({ type: "reset", payload: 0 })}>
Reset
</button>
</div>
);
}Custom Hooks with Generics
import { useState, useEffect } from "react";
// Generic data fetching hook
function useFetch<T>(url: string): {
data: T | null;
loading: boolean;
error: Error | null;
} {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
const controller = new AbortController();
async function fetchData() {
try {
setLoading(true);
const response = await fetch(url, { signal: controller.signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const json = await response.json();
setData(json as T);
setError(null);
} catch (e) {
if (e instanceof Error && e.name !== "AbortError") {
setError(e);
}
} finally {
setLoading(false);
}
}
fetchData();
return () => controller.abort();
}, [url]);
return { data, loading, error };
}
// Usage — type is inferred!
interface User {
id: string;
name: string;
}
function UserProfile({ userId }: { userId: string }) {
const { data: user, loading, error } = useFetch<User>(`/api/users/${userId}`);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
if (!user) return null;
return <div>{user.name}</div>;
}Generic Local Storage Hook
import { useState, useEffect } from "react";
function useLocalStorage<T>(
key: string,
initialValue: T
): [T, (value: T | ((prev: T) => T)) => void] {
const [storedValue, setStoredValue] = useState<T>(() => {
if (typeof window === "undefined") return initialValue;
try {
const item = window.localStorage.getItem(key);
return item ? (JSON.parse(item) as T) : initialValue;
} catch {
return initialValue;
}
});
const setValue = (value: T | ((prev: T) => T)) => {
setStoredValue((prev) => {
const valueToStore = value instanceof Function ? value(prev) : value;
if (typeof window !== "undefined") {
window.localStorage.setItem(key, JSON.stringify(valueToStore));
}
return valueToStore;
});
};
return [storedValue, setValue];
}
// Usage
const [theme, setTheme] = useLocalStorage<"light" | "dark">("theme", "light");
const [cart, setCart] = useLocalStorage<CartItem[]>("cart", []);3. Context API with TypeScript
Basic Typed Context
import { createContext, useContext, useState, ReactNode } from "react";
// Define the context shape
interface AuthContextType {
user: User | null;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
isAuthenticated: boolean;
}
interface User {
id: string;
name: string;
email: string;
}
// Create context with undefined default
const AuthContext = createContext<AuthContextType | undefined>(undefined);
// Provider component
interface AuthProviderProps {
children: ReactNode;
}
function AuthProvider({ children }: AuthProviderProps) {
const [user, setUser] = useState<User | null>(null);
const login = async (email: string, password: string) => {
const response = await fetch("/api/login", {
method: "POST",
body: JSON.stringify({ email, password }),
});
const userData = await response.json();
setUser(userData);
};
const logout = () => setUser(null);
const value: AuthContextType = {
user,
login,
logout,
isAuthenticated: user !== null,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
// Custom hook with type guard
function useAuth(): AuthContextType {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
}
// Usage in component
function Profile() {
const { user, logout, isAuthenticated } = useAuth();
if (!isAuthenticated) {
return <div>Please log in</div>;
}
return (
<div>
<p>Welcome, {user?.name}</p>
<button onClick={logout}>Logout</button>
</div>
);
}Generic Context Factory
Create reusable context patterns:
import { createContext, useContext, ReactNode } from "react";
function createSafeContext<T>(displayName: string) {
const Context = createContext<T | undefined>(undefined);
Context.displayName = displayName;
function useContextSafe(): T {
const context = useContext(Context);
if (context === undefined) {
throw new Error(
`use${displayName} must be used within a ${displayName}Provider`
);
}
return context;
}
return [Context.Provider, useContextSafe] as const;
}
// Usage
interface ThemeContextType {
theme: "light" | "dark";
toggleTheme: () => void;
}
const [ThemeProvider, useTheme] = createSafeContext<ThemeContextType>("Theme");
// In your app
function ThemeWrapper({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<"light" | "dark">("light");
const toggleTheme = () => setTheme((t) => (t === "light" ? "dark" : "light"));
return (
<ThemeProvider value={{ theme, toggleTheme }}>
{children}
</ThemeProvider>
);
}
// Components
function ThemeToggle() {
const { theme, toggleTheme } = useTheme(); // Fully typed!
return <button onClick={toggleTheme}>Current: {theme}</button>;
}Context with Reducer
import { createContext, useContext, useReducer, ReactNode, Dispatch } from "react";
// State and actions
interface CartState {
items: CartItem[];
total: number;
}
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
type CartAction =
| { type: "ADD_ITEM"; payload: Omit<CartItem, "quantity"> }
| { type: "REMOVE_ITEM"; payload: { id: string } }
| { type: "UPDATE_QUANTITY"; payload: { id: string; quantity: number } }
| { type: "CLEAR_CART" };
// Reducer
function cartReducer(state: CartState, action: CartAction): CartState {
switch (action.type) {
case "ADD_ITEM": {
const existing = state.items.find((i) => i.id === action.payload.id);
if (existing) {
return {
...state,
items: state.items.map((i) =>
i.id === action.payload.id
? { ...i, quantity: i.quantity + 1 }
: i
),
total: state.total + action.payload.price,
};
}
return {
items: [...state.items, { ...action.payload, quantity: 1 }],
total: state.total + action.payload.price,
};
}
case "REMOVE_ITEM": {
const item = state.items.find((i) => i.id === action.payload.id);
if (!item) return state;
return {
items: state.items.filter((i) => i.id !== action.payload.id),
total: state.total - item.price * item.quantity,
};
}
case "CLEAR_CART":
return { items: [], total: 0 };
default:
return state;
}
}
// Context
interface CartContextType {
state: CartState;
dispatch: Dispatch<CartAction>;
}
const CartContext = createContext<CartContextType | undefined>(undefined);
function CartProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(cartReducer, { items: [], total: 0 });
return (
<CartContext.Provider value={{ state, dispatch }}>
{children}
</CartContext.Provider>
);
}
function useCart() {
const context = useContext(CartContext);
if (!context) throw new Error("useCart must be used within CartProvider");
return context;
}
// Usage with type-safe dispatch
function AddToCartButton({ product }: { product: Product }) {
const { dispatch } = useCart();
return (
<button
onClick={() =>
dispatch({
type: "ADD_ITEM",
payload: { id: product.id, name: product.name, price: product.price },
})
}
>
Add to Cart
</button>
);
}4. Render Props and Compound Components
Render Props Pattern
import { useState, ReactNode } from "react";
interface ToggleRenderProps {
isOn: boolean;
toggle: () => void;
turnOn: () => void;
turnOff: () => void;
}
interface ToggleProps {
initialOn?: boolean;
children: (props: ToggleRenderProps) => ReactNode;
}
function Toggle({ initialOn = false, children }: ToggleProps) {
const [isOn, setIsOn] = useState(initialOn);
const renderProps: ToggleRenderProps = {
isOn,
toggle: () => setIsOn((on) => !on),
turnOn: () => setIsOn(true),
turnOff: () => setIsOn(false),
};
return <>{children(renderProps)}</>;
}
// Usage
function App() {
return (
<Toggle initialOn={false}>
{({ isOn, toggle }) => (
<div>
<button onClick={toggle}>{isOn ? "ON" : "OFF"}</button>
{isOn && <p>Content is visible!</p>}
</div>
)}
</Toggle>
);
}Compound Components Pattern
Create components that work together while sharing implicit state:
import { createContext, useContext, useState, ReactNode } from "react";
// Types
interface TabsContextType {
activeTab: string;
setActiveTab: (id: string) => void;
}
interface TabsProps {
defaultTab: string;
children: ReactNode;
}
interface TabProps {
id: string;
children: ReactNode;
}
interface TabPanelProps {
id: string;
children: ReactNode;
}
// Context
const TabsContext = createContext<TabsContextType | undefined>(undefined);
function useTabs() {
const context = useContext(TabsContext);
if (!context) throw new Error("Tab components must be used within Tabs");
return context;
}
// Parent component
function Tabs({ defaultTab, children }: TabsProps) {
const [activeTab, setActiveTab] = useState(defaultTab);
return (
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
<div className="tabs">{children}</div>
</TabsContext.Provider>
);
}
// Tab list container
function TabList({ children }: { children: ReactNode }) {
return <div className="tab-list" role="tablist">{children}</div>;
}
// Individual tab button
function Tab({ id, children }: TabProps) {
const { activeTab, setActiveTab } = useTabs();
const isActive = activeTab === id;
return (
<button
role="tab"
aria-selected={isActive}
className={isActive ? "tab active" : "tab"}
onClick={() => setActiveTab(id)}
>
{children}
</button>
);
}
// Tab panel
function TabPanel({ id, children }: TabPanelProps) {
const { activeTab } = useTabs();
if (activeTab !== id) return null;
return (
<div role="tabpanel" className="tab-panel">
{children}
</div>
);
}
// Attach sub-components
Tabs.List = TabList;
Tabs.Tab = Tab;
Tabs.Panel = TabPanel;
// Usage — clean API!
function Settings() {
return (
<Tabs defaultTab="profile">
<Tabs.List>
<Tabs.Tab id="profile">Profile</Tabs.Tab>
<Tabs.Tab id="security">Security</Tabs.Tab>
<Tabs.Tab id="notifications">Notifications</Tabs.Tab>
</Tabs.List>
<Tabs.Panel id="profile">
<ProfileSettings />
</Tabs.Panel>
<Tabs.Panel id="security">
<SecuritySettings />
</Tabs.Panel>
<Tabs.Panel id="notifications">
<NotificationSettings />
</Tabs.Panel>
</Tabs>
);
}5. Higher-Order Components (HOCs)
HOCs are less common in modern React (hooks often replace them), but they're still useful for cross-cutting concerns.
Basic HOC Pattern
import { ComponentType } from "react";
// HOC that adds loading prop
interface WithLoadingProps {
isLoading: boolean;
}
function withLoading<P extends object>(
WrappedComponent: ComponentType<P>
): ComponentType<P & WithLoadingProps> {
return function WithLoadingComponent({
isLoading,
...props
}: P & WithLoadingProps) {
if (isLoading) {
return <div className="spinner">Loading...</div>;
}
return <WrappedComponent {...(props as P)} />;
};
}
// Usage
interface UserListProps {
users: User[];
}
function UserList({ users }: UserListProps) {
return (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
const UserListWithLoading = withLoading(UserList);
// Component now requires isLoading prop
<UserListWithLoading users={users} isLoading={loading} />HOC that Injects Props
import { ComponentType } from "react";
// Theme injection HOC
interface ThemeProps {
theme: Theme;
}
interface Theme {
colors: { primary: string; secondary: string };
spacing: number;
}
function withTheme<P extends ThemeProps>(
WrappedComponent: ComponentType<P>
): ComponentType<Omit<P, keyof ThemeProps>> {
return function WithThemeComponent(props: Omit<P, keyof ThemeProps>) {
const theme = useTheme(); // Get theme from context
return <WrappedComponent {...(props as P)} theme={theme} />;
};
}
// Usage
interface ButtonProps extends ThemeProps {
label: string;
onClick: () => void;
}
function ThemedButton({ theme, label, onClick }: ButtonProps) {
return (
<button
style={{ backgroundColor: theme.colors.primary }}
onClick={onClick}
>
{label}
</button>
);
}
const Button = withTheme(ThemedButton);
// theme is injected, not required in props
<Button label="Click me" onClick={() => {}} />Generic Data Fetching HOC
import { ComponentType, useState, useEffect } from "react";
interface WithDataProps<T> {
data: T;
loading: boolean;
error: Error | null;
refetch: () => void;
}
function withData<T, P extends WithDataProps<T>>(
WrappedComponent: ComponentType<P>,
fetchFn: () => Promise<T>
): ComponentType<Omit<P, keyof WithDataProps<T>>> {
return function WithDataComponent(props: Omit<P, keyof WithDataProps<T>>) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const fetchData = async () => {
setLoading(true);
try {
const result = await fetchFn();
setData(result);
setError(null);
} catch (e) {
setError(e instanceof Error ? e : new Error("Unknown error"));
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
}, []);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
if (!data) return null;
return (
<WrappedComponent
{...(props as P)}
data={data}
loading={loading}
error={error}
refetch={fetchData}
/>
);
};
}
// Usage
interface UserProfileProps extends WithDataProps<User> {
showAvatar?: boolean;
}
function UserProfile({ data: user, showAvatar }: UserProfileProps) {
return (
<div>
{showAvatar && <img src={user.avatar} alt={user.name} />}
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}
const UserProfileWithData = withData(
UserProfile,
() => fetch("/api/me").then((r) => r.json())
);
<UserProfileWithData showAvatar />6. Event Handlers and Forms
Event Handler Types
import {
MouseEvent,
KeyboardEvent,
ChangeEvent,
FormEvent,
FocusEvent,
ClipboardEvent
} from "react";
function EventExamples() {
// Click handler
const handleClick = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
console.log("Clicked:", e.currentTarget.name);
};
// Keyboard handler
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
console.log("Enter pressed");
}
};
// Change handler
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
console.log("Value:", e.target.value);
};
// Select change
const handleSelectChange = (e: ChangeEvent<HTMLSelectElement>) => {
console.log("Selected:", e.target.value);
};
// Form submit
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
console.log("Form data:", Object.fromEntries(formData));
};
// Focus/blur
const handleFocus = (e: FocusEvent<HTMLInputElement>) => {
console.log("Focused:", e.target.name);
};
return (
<form onSubmit={handleSubmit}>
<input
name="email"
onChange={handleChange}
onKeyDown={handleKeyDown}
onFocus={handleFocus}
/>
<select onChange={handleSelectChange}>
<option value="a">Option A</option>
<option value="b">Option B</option>
</select>
<button type="submit" name="submitBtn" onClick={handleClick}>
Submit
</button>
</form>
);
}Typed Form with useState
import { useState, ChangeEvent, FormEvent } from "react";
interface LoginForm {
email: string;
password: string;
rememberMe: boolean;
}
function LoginPage() {
const [form, setForm] = useState<LoginForm>({
email: "",
password: "",
rememberMe: false,
});
const [errors, setErrors] = useState<Partial<Record<keyof LoginForm, string>>>({});
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const { name, value, type, checked } = e.target;
setForm((prev) => ({
...prev,
[name]: type === "checkbox" ? checked : value,
}));
};
const validate = (): boolean => {
const newErrors: Partial<Record<keyof LoginForm, string>> = {};
if (!form.email) {
newErrors.email = "Email is required";
} else if (!/\S+@\S+\.\S+/.test(form.email)) {
newErrors.email = "Invalid email format";
}
if (!form.password) {
newErrors.password = "Password is required";
} else if (form.password.length < 8) {
newErrors.password = "Password must be at least 8 characters";
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
if (!validate()) return;
try {
await login(form);
} catch (err) {
setErrors({ email: "Invalid credentials" });
}
};
return (
<form onSubmit={handleSubmit}>
<div>
<input
type="email"
name="email"
value={form.email}
onChange={handleChange}
placeholder="Email"
/>
{errors.email && <span className="error">{errors.email}</span>}
</div>
<div>
<input
type="password"
name="password"
value={form.password}
onChange={handleChange}
placeholder="Password"
/>
{errors.password && <span className="error">{errors.password}</span>}
</div>
<label>
<input
type="checkbox"
name="rememberMe"
checked={form.rememberMe}
onChange={handleChange}
/>
Remember me
</label>
<button type="submit">Login</button>
</form>
);
}Generic Form Hook
import { useState, ChangeEvent } from "react";
type FormErrors<T> = Partial<Record<keyof T, string>>;
function useForm<T extends Record<string, unknown>>(
initialValues: T,
validate?: (values: T) => FormErrors<T>
) {
const [values, setValues] = useState<T>(initialValues);
const [errors, setErrors] = useState<FormErrors<T>>({});
const [touched, setTouched] = useState<Partial<Record<keyof T, boolean>>>({});
const handleChange = (
e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
) => {
const { name, value, type } = e.target;
const newValue =
type === "checkbox" ? (e.target as HTMLInputElement).checked : value;
setValues((prev) => ({ ...prev, [name]: newValue }));
};
const handleBlur = (
e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
) => {
const { name } = e.target;
setTouched((prev) => ({ ...prev, [name]: true }));
if (validate) {
const validationErrors = validate(values);
setErrors(validationErrors);
}
};
const handleSubmit = (onSubmit: (values: T) => void) => {
return (e: FormEvent) => {
e.preventDefault();
if (validate) {
const validationErrors = validate(values);
setErrors(validationErrors);
if (Object.keys(validationErrors).length > 0) {
return;
}
}
onSubmit(values);
};
};
const reset = () => {
setValues(initialValues);
setErrors({});
setTouched({});
};
return {
values,
errors,
touched,
handleChange,
handleBlur,
handleSubmit,
reset,
setValues,
};
}
// Usage
interface ContactForm {
name: string;
email: string;
message: string;
}
function ContactPage() {
const { values, errors, handleChange, handleBlur, handleSubmit } = useForm<ContactForm>(
{ name: "", email: "", message: "" },
(values) => {
const errors: FormErrors<ContactForm> = {};
if (!values.name) errors.name = "Name is required";
if (!values.email) errors.email = "Email is required";
if (!values.message) errors.message = "Message is required";
return errors;
}
);
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
<input
name="name"
value={values.name}
onChange={handleChange}
onBlur={handleBlur}
/>
{errors.name && <span>{errors.name}</span>}
{/* ... other fields */}
</form>
);
}7. Ref Forwarding and Imperative Handles
forwardRef with TypeScript
import { forwardRef, useRef, useImperativeHandle, Ref } from "react";
interface InputProps {
label: string;
error?: string;
}
// Forward ref to expose the input element
const TextInput = forwardRef<HTMLInputElement, InputProps>(
function TextInput({ label, error }, ref) {
return (
<div className="form-field">
<label>{label}</label>
<input ref={ref} />
{error && <span className="error">{error}</span>}
</div>
);
}
);
// Usage
function Form() {
const inputRef = useRef<HTMLInputElement>(null);
const focusInput = () => {
inputRef.current?.focus();
};
return (
<div>
<TextInput ref={inputRef} label="Email" />
<button onClick={focusInput}>Focus Input</button>
</div>
);
}useImperativeHandle for Custom APIs
Expose a custom API instead of the raw DOM element:
import { forwardRef, useRef, useImperativeHandle, useState } from "react";
// Define the imperative handle type
interface ModalHandle {
open: () => void;
close: () => void;
toggle: () => void;
}
interface ModalProps {
title: string;
children: ReactNode;
}
const Modal = forwardRef<ModalHandle, ModalProps>(
function Modal({ title, children }, ref) {
const [isOpen, setIsOpen] = useState(false);
// Expose custom API to parent
useImperativeHandle(ref, () => ({
open: () => setIsOpen(true),
close: () => setIsOpen(false),
toggle: () => setIsOpen((prev) => !prev),
}));
if (!isOpen) return null;
return (
<div className="modal-overlay">
<div className="modal">
<h2>{title}</h2>
<div className="modal-body">{children}</div>
<button onClick={() => setIsOpen(false)}>Close</button>
</div>
</div>
);
}
);
// Usage
function App() {
const modalRef = useRef<ModalHandle>(null);
return (
<div>
<button onClick={() => modalRef.current?.open()}>Open Modal</button>
<Modal ref={modalRef} title="Settings">
<p>Modal content here</p>
</Modal>
</div>
);
}Generic forwardRef
import { forwardRef, Ref, ReactNode } from "react";
interface ListProps<T> {
items: T[];
renderItem: (item: T) => ReactNode;
}
// Generic component with forwardRef
function ListInner<T>(
{ items, renderItem }: ListProps<T>,
ref: Ref<HTMLUListElement>
) {
return (
<ul ref={ref}>
{items.map((item, index) => (
<li key={index}>{renderItem(item)}</li>
))}
</ul>
);
}
// Type assertion to preserve generic
const List = forwardRef(ListInner) as <T>(
props: ListProps<T> & { ref?: Ref<HTMLUListElement> }
) => JSX.Element;
// Usage with type inference
<List
items={[{ id: 1, name: "Alice" }, { id: 2, name: "Bob" }]}
renderItem={(user) => <span>{user.name}</span>}
/>8. Performance Optimization
React.memo with TypeScript
import { memo, useMemo, useCallback } from "react";
interface ExpensiveListProps {
items: Item[];
onItemClick: (id: string) => void;
filter: string;
}
// memo prevents re-render if props haven't changed
const ExpensiveList = memo(function ExpensiveList({
items,
onItemClick,
filter,
}: ExpensiveListProps) {
const filteredItems = useMemo(
() => items.filter((item) => item.name.includes(filter)),
[items, filter]
);
return (
<ul>
{filteredItems.map((item) => (
<li key={item.id} onClick={() => onItemClick(item.id)}>
{item.name}
</li>
))}
</ul>
);
});
// Custom comparison function
const ExpensiveListCustom = memo(
function ExpensiveList(props: ExpensiveListProps) {
// ...
},
(prevProps, nextProps) => {
// Return true if props are equal (skip re-render)
return (
prevProps.filter === nextProps.filter &&
prevProps.items.length === nextProps.items.length &&
prevProps.items.every((item, i) => item.id === nextProps.items[i].id)
);
}
);useMemo and useCallback
import { useMemo, useCallback, useState } from "react";
interface Product {
id: string;
name: string;
price: number;
category: string;
}
function ProductList({ products }: { products: Product[] }) {
const [filter, setFilter] = useState("");
const [sortBy, setSortBy] = useState<"name" | "price">("name");
// Memoize expensive computation
const filteredAndSorted = useMemo(() => {
console.log("Filtering and sorting...");
return products
.filter((p) => p.name.toLowerCase().includes(filter.toLowerCase()))
.sort((a, b) => {
if (sortBy === "price") return a.price - b.price;
return a.name.localeCompare(b.name);
});
}, [products, filter, sortBy]);
// Memoize callback to prevent child re-renders
const handleProductClick = useCallback((id: string) => {
console.log("Clicked product:", id);
}, []);
// Memoize callback with dependencies
const handleAddToCart = useCallback(
(product: Product) => {
addToCart(product, 1);
},
[addToCart]
);
return (
<div>
<input
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="Filter..."
/>
<select value={sortBy} onChange={(e) => setSortBy(e.target.value as "name" | "price")}>
<option value="name">Sort by Name</option>
<option value="price">Sort by Price</option>
</select>
<ProductGrid
products={filteredAndSorted}
onProductClick={handleProductClick}
onAddToCart={handleAddToCart}
/>
</div>
);
}Typed Memoization Utilities
// Generic memoization function
function memoize<Args extends unknown[], Result>(
fn: (...args: Args) => Result
): (...args: Args) => Result {
const cache = new Map<string, Result>();
return (...args: Args) => {
const key = JSON.stringify(args);
if (cache.has(key)) {
return cache.get(key)!;
}
const result = fn(...args);
cache.set(key, result);
return result;
};
}
// Usage
const expensiveCalculation = memoize((a: number, b: number) => {
console.log("Calculating...");
return a * b;
});
expensiveCalculation(5, 10); // Logs "Calculating...", returns 50
expensiveCalculation(5, 10); // Returns 50 from cache (no log)9. Error Boundaries
Error boundaries catch JavaScript errors in child components. They must be class components in React.
import { Component, ReactNode, ErrorInfo } from "react";
interface ErrorBoundaryProps {
children: ReactNode;
fallback?: ReactNode | ((error: Error, reset: () => void) => ReactNode);
onError?: (error: Error, errorInfo: ErrorInfo) => void;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
this.props.onError?.(error, errorInfo);
// Log to error reporting service
console.error("Error caught by boundary:", error, errorInfo);
}
reset = () => {
this.setState({ hasError: false, error: null });
};
render() {
if (this.state.hasError && this.state.error) {
const { fallback } = this.props;
if (typeof fallback === "function") {
return fallback(this.state.error, this.reset);
}
return fallback || <div>Something went wrong</div>;
}
return this.props.children;
}
}
// Usage
function App() {
return (
<ErrorBoundary
fallback={(error, reset) => (
<div className="error-page">
<h1>Oops! Something went wrong</h1>
<p>{error.message}</p>
<button onClick={reset}>Try Again</button>
</div>
)}
onError={(error, info) => {
// Send to Sentry, LogRocket, etc.
logErrorToService(error, info.componentStack);
}}
>
<MainContent />
</ErrorBoundary>
);
}Error Boundary Hook (React 19+)
React 19 introduces the use hook and better error handling:
import { Suspense } from "react";
import { ErrorBoundary } from "react-error-boundary";
// With react-error-boundary library (type-safe wrapper)
function UserProfile({ userId }: { userId: string }) {
return (
<ErrorBoundary
fallbackRender={({ error, resetErrorBoundary }) => (
<div>
<p>Error: {error.message}</p>
<button onClick={resetErrorBoundary}>Retry</button>
</div>
)}
onReset={() => {
// Reset any state that caused the error
}}
>
<Suspense fallback={<div>Loading user...</div>}>
<UserDetails userId={userId} />
</Suspense>
</ErrorBoundary>
);
}10. Suspense and Lazy Loading
React.lazy with TypeScript
import { lazy, Suspense, ComponentType } from "react";
// Basic lazy loading
const Dashboard = lazy(() => import("./Dashboard"));
const Settings = lazy(() => import("./Settings"));
const Analytics = lazy(() => import("./Analytics"));
// With loading component
function App() {
return (
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
<Route path="/analytics" element={<Analytics />} />
</Routes>
</Suspense>
);
}Named Exports with Lazy
// For named exports, you need a wrapper
const LazyChart = lazy(() =>
import("./Charts").then((module) => ({
default: module.BarChart,
}))
);
// Or create a utility
function lazyNamed<T extends ComponentType<unknown>>(
importFn: () => Promise<{ [key: string]: T }>,
name: string
): React.LazyExoticComponent<T> {
return lazy(() =>
importFn().then((module) => ({
default: module[name] as T,
}))
);
}
const PieChart = lazyNamed(
() => import("./Charts"),
"PieChart"
);Typed Suspense Wrapper
import { Suspense, ReactNode, ComponentProps } from "react";
interface SuspenseWrapperProps {
children: ReactNode;
fallback?: ReactNode;
}
function SuspenseWrapper({ children, fallback }: SuspenseWrapperProps) {
return (
<Suspense fallback={fallback ?? <DefaultLoader />}>
{children}
</Suspense>
);
}
// With error boundary integration
interface AsyncBoundaryProps {
children: ReactNode;
loadingFallback?: ReactNode;
errorFallback?: ReactNode | ((error: Error) => ReactNode);
}
function AsyncBoundary({
children,
loadingFallback,
errorFallback,
}: AsyncBoundaryProps) {
return (
<ErrorBoundary
fallback={
typeof errorFallback === "function"
? (error: Error) => errorFallback(error)
: errorFallback ?? <DefaultError />
}
>
<Suspense fallback={loadingFallback ?? <DefaultLoader />}>
{children}
</Suspense>
</ErrorBoundary>
);
}
// Usage
<AsyncBoundary
loadingFallback={<SkeletonLoader />}
errorFallback={(error) => <ErrorCard message={error.message} />}
>
<UserDashboard />
</AsyncBoundary>Common Patterns Summary
Component Props Cheat Sheet
// Basic props
interface Props {
required: string;
optional?: number;
withDefault?: boolean;
}
// Children
import { ReactNode, PropsWithChildren } from "react";
interface PropsWithKids {
children: ReactNode;
}
// or
type PropsWithKids = PropsWithChildren<{ title: string }>;
// Extending HTML elements
import { ComponentPropsWithoutRef, ComponentPropsWithRef } from "react";
interface ButtonProps extends ComponentPropsWithoutRef<"button"> {
variant: "primary" | "secondary";
}
// Polymorphic "as" prop
import { ElementType } from "react";
type PolymorphicProps<E extends ElementType> = {
as?: E;
} & ComponentPropsWithoutRef<E>;
// Discriminated unions
type Status =
| { type: "loading" }
| { type: "success"; data: string }
| { type: "error"; message: string };Hook Types Cheat Sheet
import { useState, useRef, useReducer, useCallback, useMemo } from "react";
// useState
const [value, setValue] = useState<string>("");
const [user, setUser] = useState<User | null>(null);
// useRef - DOM
const inputRef = useRef<HTMLInputElement>(null);
// useRef - mutable value
const countRef = useRef<number>(0);
// useReducer
const [state, dispatch] = useReducer(reducer, initialState);
// useCallback
const handler = useCallback((id: string) => {}, []);
// useMemo
const computed = useMemo(() => expensiveCalc(data), [data]);Event Types Cheat Sheet
import {
MouseEvent,
KeyboardEvent,
ChangeEvent,
FormEvent,
FocusEvent,
} from "react";
// Click
const onClick = (e: MouseEvent<HTMLButtonElement>) => {};
// Keyboard
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {};
// Change
const onChange = (e: ChangeEvent<HTMLInputElement>) => {};
// Form submit
const onSubmit = (e: FormEvent<HTMLFormElement>) => {};
// Focus
const onFocus = (e: FocusEvent<HTMLInputElement>) => {};Summary and Key Takeaways
✅ Props patterns: Use discriminated unions for mutually exclusive props
✅ Children typing: ReactNode for any renderable content, PropsWithChildren<T> for convenience
✅ Hooks: Explicit generics for useState<T>, distinguish DOM refs from mutable refs
✅ Context: Always throw in custom hooks when context is undefined
✅ Forms: Generic form hooks save repetitive typing
✅ forwardRef: Use useImperativeHandle to expose custom APIs
✅ Performance: memo, useMemo, useCallback need stable references
✅ Error boundaries: Still require class components (or use react-error-boundary)
✅ Lazy loading: Wrap with Suspense, handle named exports explicitly
What's Next?
- Deep Dive: Node.js API Development → — Type-safe backends with Express/Fastify
- Deep Dive: Database & ORMs → — Prisma and Drizzle ORM patterns
Additional Resources
Series: TypeScript Full-Stack Roadmap Previous: Deep Dive: Advanced TypeScript Types Next: Deep Dive: Node.js API Development
Happy coding! 🚀
📬 Subscribe to Newsletter
Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.
We respect your privacy. Unsubscribe at any time.
💬 Comments
Sign in to leave a comment
We'll never post without your permission.