TechLead

React with TypeScript

Type-safe React development — component props, hooks, events, context, and generic patterns with TypeScript.

React with TypeScript

TypeScript adds static type checking to your React code, catching errors at compile time instead of runtime. This guide covers the essential patterns for typing components, hooks, events, and state in modern React + TypeScript projects.

📦 Setup

Most React frameworks include TypeScript out of the box:

npx create-next-app@latest --typescript my-app
npm create vite@latest my-app -- --template react-ts

1. Typing Component Props

Define an interface or type for your props. Use interface when extending is likely; type for unions or intersections.

// Using an interface
interface ButtonProps {
  label: string;
  variant?: "primary" | "secondary" | "danger"; // optional with union
  disabled?: boolean;
  onClick: () => void;
}

function Button({ label, variant = "primary", disabled, onClick }: ButtonProps) {
  return (
    <button
      className={`btn btn-${variant}`}
      disabled={disabled}
      onClick={onClick}
    >
      {label}
    </button>
  );
}

2. Children Props

Use React.ReactNode for children that can be anything renderable. Use React.ReactElement if you need a specific JSX element.

interface CardProps {
  title: string;
  children: React.ReactNode; // accepts anything renderable
}

function Card({ title, children }: CardProps) {
  return (
    <div className="card">
      <h2>{title}</h2>
      <div>{children}</div>
    </div>
  );
}

// PropsWithChildren shortcut
import { PropsWithChildren } from "react";

type LayoutProps = PropsWithChildren<{
  sidebar: React.ReactNode;
}>;

function Layout({ sidebar, children }: LayoutProps) {
  return (
    <div className="flex">
      <aside>{sidebar}</aside>
      <main>{children}</main>
    </div>
  );
}

3. Typing useState

TypeScript infers useState types from the initial value. Explicitly type when the initial value doesn't represent all possible states.

// Inferred as string
const [name, setName] = useState(""); // string

// Explicit type needed — initial null, later an object
interface User {
  id: number;
  name: string;
  email: string;
}

const [user, setUser] = useState<User | null>(null);

// Later:
setUser({ id: 1, name: "Alice", email: "alice@example.com" });

// Access with narrowing
if (user) {
  console.log(user.name); // TypeScript knows user is User here
}

4. Typing Events

React provides typed event interfaces. The most common ones you'll use:

function EventExamples() {
  // Form events
  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);
  };

  // Change events
  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    console.log(e.target.value);
  };

  // Click events
  const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
    console.log("Clicked at:", e.clientX, e.clientY);
  };

  // Keyboard events
  const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
    if (e.key === "Enter") {
      console.log("Enter pressed!");
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input onChange={handleChange} onKeyDown={handleKeyDown} />
      <button onClick={handleClick}>Submit</button>
    </form>
  );
}

5. Typing useRef

The type parameter determines whether the ref is mutable or read-only.

import { useRef, useEffect } from "react";

function RefExamples() {
  // DOM ref — pass null as initial, React manages .current
  const inputRef = useRef<HTMLInputElement>(null);

  // Mutable ref — for storing values (not DOM)
  const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);

  useEffect(() => {
    inputRef.current?.focus(); // Optional chaining — may be null

    timerRef.current = setInterval(() => {
      console.log("tick");
    }, 1000);

    return () => {
      if (timerRef.current) clearInterval(timerRef.current);
    };
  }, []);

  return <input ref={inputRef} />;
}

6. Typing Context

Create type-safe context with proper null handling to avoid runtime errors.

import { createContext, useContext, useState, ReactNode } from "react";

// 1. Define the context value type
interface AuthContextType {
  user: User | null;
  login: (email: string, password: string) => Promise<void>;
  logout: () => void;
  isLoading: boolean;
}

// 2. Create context with undefined default
const AuthContext = createContext<AuthContextType | undefined>(undefined);

// 3. 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;
}

// 4. Provider component
function AuthProvider({ children }: { children: ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
  const [isLoading, setIsLoading] = useState(false);

  const login = async (email: string, password: string) => {
    setIsLoading(true);
    const user = await authApi.login(email, password);
    setUser(user);
    setIsLoading(false);
  };

  const logout = () => setUser(null);

  return (
    <AuthContext.Provider value={{ user, login, logout, isLoading }}>
      {children}
    </AuthContext.Provider>
  );
}

// 5. Usage — fully typed!
function Profile() {
  const { user, logout } = useAuth();
  if (!user) return <p>Not logged in</p>;
  return (
    <div>
      <p>Welcome, {user.name}</p>
      <button onClick={logout}>Logout</button>
    </div>
  );
}

7. Typing useReducer

Use discriminated unions for actions to get exhaustive type checking in your reducer.

interface TodoState {
  todos: Todo[];
  filter: "all" | "active" | "completed";
}

// Discriminated union — each action has a unique 'type'
type TodoAction =
  | { type: "ADD"; payload: string }
  | { type: "TOGGLE"; payload: number }
  | { type: "DELETE"; payload: number }
  | { type: "SET_FILTER"; payload: TodoState["filter"] };

function todoReducer(state: TodoState, action: TodoAction): TodoState {
  switch (action.type) {
    case "ADD":
      return {
        ...state,
        todos: [...state.todos, { id: Date.now(), text: action.payload, done: false }],
      };
    case "TOGGLE":
      return {
        ...state,
        todos: state.todos.map((t) =>
          t.id === action.payload ? { ...t, done: !t.done } : t
        ),
      };
    case "DELETE":
      return {
        ...state,
        todos: state.todos.filter((t) => t.id !== action.payload),
      };
    case "SET_FILTER":
      return { ...state, filter: action.payload };
    // TypeScript ensures all cases are handled!
  }
}

const [state, dispatch] = useReducer(todoReducer, {
  todos: [],
  filter: "all",
});

dispatch({ type: "ADD", payload: "Learn TypeScript" }); // ✅
dispatch({ type: "ADD", payload: 42 }); // ❌ Type error!

8. Generic Components

Build reusable, type-safe components that work with any data type using generics.

// Generic list component
interface ListProps<T> {
  items: T[];
  renderItem: (item: T, index: number) => React.ReactNode;
  keyExtractor: (item: T) => string | number;
  emptyMessage?: string;
}

function List<T>({ items, renderItem, keyExtractor, emptyMessage }: ListProps<T>) {
  if (items.length === 0) {
    return <p className="text-gray-500">{emptyMessage ?? "No items"}</p>;
  }

  return (
    <ul>
      {items.map((item, index) => (
        <li key={keyExtractor(item)}>{renderItem(item, index)}</li>
      ))}
    </ul>
  );
}

// Usage — TypeScript infers T as User
interface User {
  id: number;
  name: string;
}

<List<User>
  items={users}
  keyExtractor={(user) => user.id}
  renderItem={(user) => <span>{user.name}</span>}
/>

9. Typing Custom Hooks

Return types are usually inferred, but explicit typing makes the API contract clear.

interface UseFetchResult<T> {
  data: T | null;
  error: Error | null;
  isLoading: boolean;
  refetch: () => void;
}

function useFetch<T>(url: string): UseFetchResult<T> {
  const [data, setData] = useState<T | null>(null);
  const [error, setError] = useState<Error | null>(null);
  const [isLoading, setIsLoading] = useState(true);

  const fetchData = useCallback(async () => {
    setIsLoading(true);
    setError(null);
    try {
      const response = await fetch(url);
      if (!response.ok) throw new Error(`HTTP ${response.status}`);
      const json: T = await response.json();
      setData(json);
    } catch (err) {
      setError(err instanceof Error ? err : new Error("Unknown error"));
    } finally {
      setIsLoading(false);
    }
  }, [url]);

  useEffect(() => {
    fetchData();
  }, [fetchData]);

  return { data, error, isLoading, refetch: fetchData };
}

// Usage — type-safe!
interface Post {
  id: number;
  title: string;
  body: string;
}

function Posts() {
  const { data: posts, error, isLoading } = useFetch<Post[]>("/api/posts");

  if (isLoading) return <p>Loading...</p>;
  if (error) return <p>Error: {error.message}</p>;

  return posts?.map((post) => <h3 key={post.id}>{post.title}</h3>);
}

10. Utility Types for React

Use built-in utility types to extend or modify component props.

// Extend native HTML element props
type InputProps = React.InputHTMLAttributes<HTMLInputElement> & {
  label: string;
  error?: string;
};

function Input({ label, error, ...inputProps }: InputProps) {
  return (
    <div>
      <label>{label}</label>
      <input {...inputProps} />
      {error && <span className="text-red-500">{error}</span>}
    </div>
  );
}

// ComponentProps — extract props from any component
type ButtonVariant = React.ComponentProps<typeof Button>["variant"];

// Omit — remove props you want to override
type CustomSelectProps = Omit<React.SelectHTMLAttributes<HTMLSelectElement>, "onChange"> & {
  onChange: (value: string) => void; // Simplified onChange
};

// Pick — select specific props
type UserSummary = Pick<User, "id" | "name">;

⚠️ Common Pitfalls

  • Don't use React.FC — it implicitly includes children and has issues with generics. Type props directly instead.
  • Don't use any for event handlers — use the specific React event types (React.ChangeEvent, React.MouseEvent, etc.).
  • Don't forget to narrow nullable typesuseState<User | null> requires null checks before accessing properties.
  • Avoid type assertions (as) — prefer type guards and proper typing over as SomeType.

📋 React TypeScript Cheat Sheet

Pattern Type
Children React.ReactNode
Style prop React.CSSProperties
Form event React.FormEvent<HTMLFormElement>
Change event React.ChangeEvent<HTMLInputElement>
Click event React.MouseEvent<HTMLButtonElement>
DOM ref useRef<HTMLDivElement>(null)
Nullable state useState<Type | null>(null)
Extend HTML props React.HTMLAttributes<HTMLElement>