Bobby Encoded
PostsAbout
PostsAbout

© 2026 Bobby Jose

← Back to Blog

TypeScript with React: The Complete Guide

March 19, 2025 · 9 min read

React, TypeScript, JavaScript, Type Safety, Interview Prep

Introduction

TypeScript adds static type checking to React applications, catching errors at compile time and providing excellent IDE support. This article covers typing props, state, events, and advanced patterns like generics and discriminated unions.


Typing Component Props

// Basic props interface
interface ButtonProps {
  label: string;
  onClick: () => void;
  disabled?: boolean;  // Optional
  variant?: 'primary' | 'secondary' | 'danger';  // Union type
}

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

Children Prop

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

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

// More specific children types
interface ListProps {
  children: React.ReactElement[];  // Only elements
}

interface RenderPropProps {
  children: (data: { loading: boolean }) => React.ReactNode;
}

Typing Events

function SearchForm({ onSearch }: { onSearch: (query: string) => void }) {
  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    onSearch(e.target.value);
  };

  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
  };

  return (
    <form onSubmit={handleSubmit}>
      <input type="text" onChange={handleChange} />
    </form>
  );
}

// Common event types
type InputChange = React.ChangeEvent<HTMLInputElement>;
type SelectChange = React.ChangeEvent<HTMLSelectElement>;
type FormSubmit = React.FormEvent<HTMLFormElement>;
type ButtonClick = React.MouseEvent<HTMLButtonElement>;
type KeyDown = React.KeyboardEvent<HTMLInputElement>;

Typing Hooks

useState

// Type inference works for simple types
const [count, setCount] = useState(0);  // number
const [name, setName] = useState('');   // string

// Explicit typing for complex state
interface User {
  id: string;
  name: string;
  email: string;
}

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

useReducer

interface State {
  count: number;
  step: number;
}

type Action =
  | { type: 'increment' }
  | { type: 'decrement' }
  | { type: 'setStep'; payload: number }
  | { type: 'reset' };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'increment':
      return { ...state, count: state.count + state.step };
    case 'decrement':
      return { ...state, count: state.count - state.step };
    case 'setStep':
      return { ...state, step: action.payload };
    case 'reset':
      return { count: 0, step: 1 };
  }
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, { count: 0, step: 1 });

  return (
    <div>
      <button onClick={() => dispatch({ type: 'increment' })}>+</button>
      <button onClick={() => dispatch({ type: 'setStep', payload: 5 })}>
        Step 5
      </button>
    </div>
  );
}

useRef

// DOM refs - initialize with null
function TextInput() {
  const inputRef = useRef<HTMLInputElement>(null);

  const focus = () => {
    inputRef.current?.focus();  // current might be null
  };

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

// Mutable refs - no null
function Timer() {
  const intervalRef = useRef<number | null>(null);

  useEffect(() => {
    intervalRef.current = window.setInterval(() => {}, 1000);
    return () => {
      if (intervalRef.current) clearInterval(intervalRef.current);
    };
  }, []);
}

Generic Components

Generics preserve type information through component APIs:

interface ListProps<T> {
  items: T[];
  renderItem: (item: T) => React.ReactNode;
  keyExtractor: (item: T) => string;
}

function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) {
  return (
    <ul>
      {items.map(item => (
        <li key={keyExtractor(item)}>{renderItem(item)}</li>
      ))}
    </ul>
  );
}

// Usage - T is inferred from items
interface Product {
  id: string;
  name: string;
  price: number;
}

<List
  items={products}  // T = Product
  renderItem={(product) => <span>{product.name}</span>}
  keyExtractor={(product) => product.id}
/>

Generic Select Component

interface SelectProps<T> {
  options: T[];
  value: T;
  onChange: (value: T) => void;
  getLabel: (option: T) => string;
  getValue: (option: T) => string;
}

function Select<T>({
  options,
  value,
  onChange,
  getLabel,
  getValue,
}: SelectProps<T>) {
  return (
    <select
      value={getValue(value)}
      onChange={(e) => {
        const selected = options.find(opt => getValue(opt) === e.target.value);
        if (selected) onChange(selected);
      }}
    >
      {options.map(option => (
        <option key={getValue(option)} value={getValue(option)}>
          {getLabel(option)}
        </option>
      ))}
    </select>
  );
}

Discriminated Unions

Model state machines with discriminated unions:

type AsyncState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: Error };

function useFetch<T>(url: string) {
  const [state, setState] = useState<AsyncState<T>>({ status: 'idle' });

  useEffect(() => {
    setState({ status: 'loading' });

    fetch(url)
      .then(res => res.json())
      .then(data => setState({ status: 'success', data }))
      .catch(error => setState({ status: 'error', error }));
  }, [url]);

  return state;
}

// Type-safe rendering
function ProductList() {
  const state = useFetch<Product[]>('/api/products');

  switch (state.status) {
    case 'idle':
      return <div>Ready</div>;
    case 'loading':
      return <Spinner />;
    case 'success':
      // TypeScript knows state.data is Product[]
      return <List items={state.data} />;
    case 'error':
      // TypeScript knows state.error is Error
      return <Error message={state.error.message} />;
  }
}

Why Discriminated Unions

This pattern makes invalid states unrepresentable. You can't access data on a loading state or error on a success state. TypeScript enforces this at compile time.

Type Safety Pays Off

Discriminated unions eliminated an entire class of bugs in Glucoplate - no more accessing data that might be undefined during loading. TypeScript forces you to handle every state explicitly. Worth the upfront investment.


Typing Context

interface AuthContextType {
  user: User | null;
  isAuthenticated: boolean;
  login: (email: string, password: string) => Promise<void>;
  logout: () => Promise<void>;
}

const AuthContext = createContext<AuthContextType | undefined>(undefined);

// Type-safe hook
function useAuth(): AuthContextType {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error('useAuth must be used within AuthProvider');
  }
  return context;
}

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

  const login = useCallback(async (email: string, password: string) => {
    const user = await authApi.login(email, password);
    setUser(user);
  }, []);

  const value: AuthContextType = {
    user,
    isAuthenticated: !!user,
    login,
    logout: useCallback(async () => {
      await authApi.logout();
      setUser(null);
    }, []),
  };

  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}

Utility Types

interface ProductProps {
  id: string;
  name: string;
  price: number;
  description: string;
  category: string;
  onEdit: () => void;
  onDelete: () => void;
}

// Pick specific props
type ProductSummaryProps = Pick<ProductProps, 'name' | 'price'>;

// Omit certain props
type ProductDisplayProps = Omit<ProductProps, 'onEdit' | 'onDelete'>;

// Make all optional
type PartialProductProps = Partial<ProductProps>;

// Make all required
type RequiredProductProps = Required<PartialProductProps>;

// Extract component props
type ButtonProps = React.ComponentProps<typeof Button>;
type InputProps = React.ComponentProps<'input'>;

// Extend HTML props
interface CustomInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
  label: string;
  error?: string;
}

function CustomInput({ label, error, ...props }: CustomInputProps) {
  return (
    <div>
      <label>{label}</label>
      <input {...props} aria-invalid={!!error} />
      {error && <span>{error}</span>}
    </div>
  );
}

forwardRef with TypeScript

interface InputProps {
  label: string;
  error?: string;
}

const Input = forwardRef<HTMLInputElement, InputProps>(
  ({ label, error }, ref) => (
    <div>
      <label>{label}</label>
      <input ref={ref} aria-invalid={!!error} />
      {error && <span>{error}</span>}
    </div>
  )
);

// Usage
function Form() {
  const inputRef = useRef<HTMLInputElement>(null);

  return <Input ref={inputRef} label="Email" />;
}

as const Assertion

// Without as const
const STATUSES = ['pending', 'active', 'completed'];
// Type: string[]

// With as const
const STATUSES = ['pending', 'active', 'completed'] as const;
// Type: readonly ['pending', 'active', 'completed']

// Extract union type
type Status = typeof STATUSES[number];
// Type: 'pending' | 'active' | 'completed'

// Object with as const
const config = {
  apiUrl: 'https://api.example.com',
  timeout: 5000,
} as const;
// All properties are readonly literals

Interview Questions

Q1: type vs interface - when to use each?

Answer:

InterfaceType
Object shapesUnions, tuples, primitives
Extendable, mergeableComputed types
Class contractsMapped types
// Interface - extendable
interface User { name: string; }
interface Admin extends User { permissions: string[]; }

// Type - unions, tuples
type Status = 'active' | 'inactive';
type Coordinates = [number, number];

Both work for props. Consistency matters more than the choice.

Q2: How do you type event handlers?

Answer:

// Inline
<input onChange={(e: React.ChangeEvent<HTMLInputElement>) => {}} />

// Extracted
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {};

// Common types
React.ChangeEvent<HTMLInputElement>   // input onChange
React.FormEvent<HTMLFormElement>      // form onSubmit
React.MouseEvent<HTMLButtonElement>   // button onClick

Q3: What are generic components?

Answer: Components with type parameters that preserve type information:

function List<T>({ items }: { items: T[] }) { ... }

<List items={users} />  // T = User, fully typed

Use for reusable components like lists, tables, selects.

Q4: How do you handle nullable values safely?

Answer:

// Optional chaining
const name = user?.profile?.name;

// Nullish coalescing
const displayName = user?.name ?? 'Anonymous';

// Type narrowing
if (!user) return <Login />;
// TypeScript knows user is not null here

// Type guard
function isUser(value: unknown): value is User {
  return typeof value === 'object' && value !== null && 'id' in value;
}

Common Mistakes

1. Using any

// ❌ Defeats TypeScript's purpose
const handleData = (data: any) => {};

// ✅ Use unknown and narrow
const handleData = (data: unknown) => {
  if (isUser(data)) {
    console.log(data.name);
  }
};

2. Not Handling Null

// ❌ Crashes if user is null
function Profile({ user }: { user: User | null }) {
  return <div>{user.name}</div>;  // Error!
}

// ✅ Handle null
function Profile({ user }: { user: User | null }) {
  if (!user) return <Login />;
  return <div>{user.name}</div>;
}

3. Wrong Event Types

// ❌ Wrong type
const handleClick = (e: React.ChangeEvent) => {};
<button onClick={handleClick} />  // Error!

// ✅ Correct type
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {};

4. Over-Typing

// ❌ Unnecessary - TypeScript infers
const [count, setCount] = useState<number>(0);

// ✅ Let TypeScript infer simple types
const [count, setCount] = useState(0);

// Do type when inference doesn't work
const [user, setUser] = useState<User | null>(null);
const [items, setItems] = useState<Item[]>([]);

Summary

  • Props interfaces define clear component contracts
  • Event types use React.ChangeEvent<T>, React.MouseEvent<T>, etc.
  • Generics create reusable, type-safe components
  • Discriminated unions model state machines safely
  • Utility types (Pick, Omit, Partial) manipulate types
  • Let inference work - only annotate boundaries and complex types
  • Avoid any - use unknown with type guards

Next up: React 18 & 19 Features - concurrent rendering, Suspense, Server Components, and the future of React.


Part 10 of the React Developer Reference series.

← Previous

React 18 & 19: Concurrent Features, Suspense, and Server Components

Next →

React Testing: Jest and React Testing Library