Using React with TypeScript is no longer a choice most teams debate; it is the default for professional work. What still varies wildly is the experience. Some codebases feel like autocomplete heaven, where types guide you toward correct usage. Others are archaeology sites of any, sixty-character generics, and assertions that lie. The difference is rarely TypeScript knowledge in the abstract; it is a handful of React-specific patterns applied consistently. This guide covers those patterns: props, hooks, events, refs, and the generic components that usually cause the pain.

Props: interfaces, unions, and honest children

Type props with a named interface or type alias next to the component, and annotate the props parameter directly rather than using React.FC, which adds little and generalizes poorly:

Props: interfaces, unions, and honest children โ€” React with TypeScript: Typing Props, Hooks, and Generics Without the Pain
Props: interfaces, unions, and honest children
interface ButtonProps {
  variant: 'primary' | 'secondary' | 'ghost';
  size?: 'sm' | 'md' | 'lg';
  children: React.ReactNode;
  onClick?: () => void;
}

function Button({ variant, size = 'md', children, onClick }: ButtonProps) {
  return (
    <button className={`btn-${variant} btn-${size}`} onClick={onClick}>
      {children}
    </button>
  );
}

Three details matter here. String literal unions like 'primary' | 'secondary' are the cheapest correctness tool in the file; misspellings fail at compile time and autocomplete lists valid options. React.ReactNode is the honest type for children, covering strings, numbers, elements, and fragments. And defaults belong in destructuring, which TypeScript understands, narrowing size to non-optional inside the body.

When a component wraps a native element, extend its attributes instead of hand-listing them:

interface InputProps extends React.ComponentPropsWithoutRef<'input'> {
  label: string;
  error?: string;
}

Consumers get every valid input attribute typed for free, and your component stays a thin, predictable wrapper. Since React 19, ref is a regular prop on function components, so most components that need to expose a ref can accept it in props without the forwardRef wrapper that older code required.

Discriminated unions: making invalid props unrepresentable

The most underused technique in React TypeScript is modeling mutually exclusive prop combinations as a union. Consider an alert that either auto-dismisses or has a close button, never both:

type AlertProps =
  | { kind: 'auto'; duration: number }
  | { kind: 'manual'; onClose: () => void };

function Alert(props: AlertProps) {
  if (props.kind === 'auto') {
    // TypeScript knows duration exists here
    return <TimedAlert duration={props.duration} />;
  }
  return <ClosableAlert onClose={props.onClose} />;
}

Passing duration together with onClose is now a compile error rather than a runtime surprise. Any time you find yourself writing prop documentation like "ignored unless X is set," reach for a discriminated union instead.

Hooks: let inference work, annotate the gaps

useState usually infers correctly from its initial value; the exception is state that starts empty. Annotate the union explicitly: useState<User | null>(null). For arrays, useState<Todo[]>([]) prevents the dreaded never[] inference. useReducer shines with a discriminated union of actions, which makes the reducer exhaustively checkable:

Hooks: let inference work, annotate the gaps โ€” React with TypeScript: Typing Props, Hooks, and Generics Without the Pain
Hooks: let inference work, annotate the gaps
type Action =
  | { type: 'add'; text: string }
  | { type: 'toggle'; id: number }
  | { type: 'remove'; id: number };

function reducer(state: Todo[], action: Action): Todo[] {
  switch (action.type) {
    case 'add':
      return [...state, { id: Date.now(), text: action.text, done: false }];
    case 'toggle':
      return state.map((t) =>
        t.id === action.id ? { ...t, done: !t.done } : t
      );
    case 'remove':
      return state.filter((t) => t.id !== action.id);
  }
}

useRef has two typing modes worth memorizing. For DOM refs, initialize with null and the element type: useRef<HTMLInputElement | null>(null). For mutable instance values like timers, provide the value type directly: useRef<number>(0). Custom hooks should declare a return type when they return tuples, or use as const, so consumers do not receive a widened array type where element order is lost.

Events: name the handler, not the mystery

React ships types for every synthetic event, and the pattern is always React.<Kind>Event<ElementType>:

function Search() {
  const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    console.log(e.target.value);
  };
  const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
  };
  return (
    <form onSubmit={onSubmit}>
      <input onChange={onChange} />
    </form>
  );
}

When you write handlers inline, TypeScript infers the event type from the JSX attribute, so the annotation is only needed for handlers defined separately. If you can never remember the names, hover the attribute in your editor and copy the inferred signature; that habit alone eliminates most event-typing friction.

Generic components without the agony

Generics earn their keep in components that relate two props, classically a list where the item type ties items to renderItem:

Generic components without the agony โ€” React with TypeScript: Typing Props, Hooks, and Generics Without the Pain
Generic components without the agony
interface ListProps<T> {
  items: T[];
  keyOf: (item: T) => string | number;
  renderItem: (item: T) => React.ReactNode;
}

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

// Usage: T is inferred as Product, renderItem is fully typed
<List items={products} keyOf={(p) => p.id} renderItem={(p) => p.name} />

Callers never write the type argument; inference flows from items into the callbacks. Keep generic components to one or two type parameters, constrain them only when the body requires it, and resist generic wrappers around things that are not actually polymorphic. If a generic signature takes longer to read than the component body, simplify the design rather than the annotation.

Configuration and habits that compound

Run with strict: true non-negotiably, and consider noUncheckedIndexedAccess, which forces handling of possibly-undefined array access and catches real list bugs. Derive types from single sources of truth: infer them from your validation schemas or generate them from your API, rather than hand-maintaining parallel interfaces that drift. Treat any as a code review flag and unknown as its acceptable cousin, narrowed before use. And prefer deleting a clever type over documenting it; the goal of React with TypeScript is not type gymnastics, it is an editor that tells the next developer the truth about every component they touch.

Related Service

๐Ÿ’ป Web Development

Custom websites and web applications built with PHP, Laravel, WordPress, and React by a Chandigarh & Mohali based web developer โ€” fast, secure, scalable, and trusted by clients across India and worldwide.

Explore Web Development →
Share this article
X Facebook LinkedIn