React with TypeScript: Complete Type Safety Guide (2026)

TypeScript transforms React development by catching prop errors at compile time, enabling precise autocompletion, and making refactors safe. This guide covers everything from basic component typing to generics, discriminated unions, forwardRef and the utility types that make your code self-documenting.

1. Project Setup

# Create with Vite (recommended)
npm create vite@latest my-app -- --template react-ts

# Or with Next.js
npx create-next-app@latest my-app --typescript

# tsconfig.json strict mode — always enable these
{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true
  }
}

2. Component Props Typing

// Basic props interface
interface ButtonProps {
  label: string;
  variant?: 'primary' | 'secondary' | 'danger';  // string union
  size?: 'sm' | 'md' | 'lg';
  disabled?: boolean;
  onClick: () => void;
  children?: React.ReactNode;  // anything React can render
}

// Prefer interface over type for component props (better error messages)
const Button: React.FC<ButtonProps> = ({
  label,
  variant = 'primary',
  size = 'md',
  disabled = false,
  onClick,
  children
}) => (
  <button
    className={`btn btn-${variant} btn-${size}`}
    disabled={disabled}
    onClick={onClick}
  >
    {children ?? label}
  </button>
);

// Extending HTML element props — use ComponentPropsWithoutRef
interface InputProps extends React.ComponentPropsWithoutRef<'input'> {
  label: string;
  error?: string;
}

const FormInput: React.FC<InputProps> = ({ label, error, ...inputProps }) => (
  <div>
    <label>{label}</label>
    <input {...inputProps} />
    {error && <span className="error">{error}</span>}
  </div>
);
Pro Tip: Use React.ComponentPropsWithoutRef<'input'> to inherit all native HTML attributes. Use ComponentPropsWithRef when you also want to forward refs. Never type props as any — it defeats the purpose of TypeScript.

3. Event Handlers

// The correct event types for common handlers
const MyForm: React.FC = () => {
  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    // form handling
  };

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

  const handleSelect = (e: React.ChangeEvent<HTMLSelectElement>) => {
    console.log(e.target.value);
  };

  const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
    e.currentTarget.blur();
  };

  const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
    if (e.key === 'Enter') { /* ... */ }
  };

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

4. Typing Hooks

// useState — TypeScript infers from initial value, but be explicit for complex types
const [count, setCount] = useState<number>(0);
const [user, setUser] = useState<User | null>(null);  // nullable types
const [status, setStatus] = useState<'idle' | 'loading' | 'error' | 'success'>('idle');

// useRef — two use cases
const inputRef = useRef<HTMLInputElement>(null);  // DOM ref — can be null
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);  // mutable value

// useReducer
type Action =
  | { type: 'INCREMENT' }
  | { type: 'DECREMENT' }
  | { type: 'RESET'; payload: number };

interface State { count: number; }

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'INCREMENT': return { count: state.count + 1 };
    case 'DECREMENT': return { count: state.count - 1 };
    case 'RESET':     return { count: action.payload };
  }
}

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

// Custom hook with return type
function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T) => void] {
  const [stored, setStored] = useState<T>(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch { return initialValue; }
  });

  const setValue = (value: T) => {
    setStored(value);
    window.localStorage.setItem(key, JSON.stringify(value));
  };

  return [stored, setValue];
}

5. Generic Components

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

function List<T>({ items, renderItem, keyExtractor, emptyMessage = 'No items' }: ListProps<T>) {
  if (items.length === 0) return <p>{emptyMessage}</p>;
  return (
    <ul>
      {items.map((item, index) => (
        <li key={keyExtractor(item)}>{renderItem(item, index)}</li>
      ))}
    </ul>
  );
}

// Usage — TypeScript infers T from items
<List
  items={users}
  keyExtractor={(u) => u.id}
  renderItem={(u) => <span>{u.name}</span>}
/>

6. Discriminated Unions for Props

// Use discriminated unions to make invalid prop combinations impossible
type AlertProps =
  | { variant: 'success'; message: string }
  | { variant: 'error'; message: string; onRetry: () => void }
  | { variant: 'loading'; message?: string };

const Alert: React.FC<AlertProps> = (props) => {
  if (props.variant === 'loading') {
    return <div>{props.message ?? 'Loading...'}</div>;
  }
  if (props.variant === 'error') {
    // TypeScript knows onRetry exists here
    return (
      <div>
        {props.message}
        <button onClick={props.onRetry}>Retry</button>
      </div>
    );
  }
  return <div className="success">{props.message}</div>;
};

7. forwardRef Typing

interface TextInputProps {
  label: string;
  error?: string;
  placeholder?: string;
}

// forwardRef with generics — forward ref to the underlying input element
const TextInput = React.forwardRef<HTMLInputElement, TextInputProps>(
  ({ label, error, placeholder }, ref) => (
    <div className="form-group">
      <label>{label}</label>
      <input ref={ref} placeholder={placeholder} className={error ? 'error' : ''} />
      {error && <span>{error}</span>}
    </div>
  )
);
TextInput.displayName = 'TextInput';

// Usage — ref is typed as RefObject<HTMLInputElement>
const inputRef = useRef<HTMLInputElement>(null);
<TextInput ref={inputRef} label="Email" />

8. Utility Types

interface User {
  id: string;
  email: string;
  name: string;
  role: 'admin' | 'user';
  createdAt: Date;
}

// Partial — all fields optional (useful for update payloads)
type UserUpdate = Partial<User>;

// Required — all fields required
type RequiredUser = Required<User>;

// Pick — subset of fields
type UserPreview = Pick<User, 'id' | 'name' | 'email'>;

// Omit — exclude fields
type CreateUserPayload = Omit<User, 'id' | 'createdAt'>;

// Record — key-value map
type UserMap = Record<string, User>;

// ReturnType — infer return type of a function
type ApiResponse = ReturnType<typeof fetchUser>;  // Promise<User>

// Parameters — infer parameter types
type FetchParams = Parameters<typeof fetchUser>;  // [id: string]

Frequently Asked Questions

Should I use type or interface for React props?

Use interface for React component props — it produces cleaner error messages and supports declaration merging. Use type for union types, mapped types, and conditional types where interface cannot express the shape.

What is the difference between React.FC and function component declaration?

React.FC implicitly types the return as ReactElement | null and includes children in older versions. Modern preference (React 18+) is to use plain function declaration and type children explicitly with React.ReactNode — avoids implicit children and is more explicit.

How do I type a component that accepts className and style?

Extend React.ComponentPropsWithoutRef<'div'> to inherit all HTML div attributes including className, style, and all event handlers. This is better than manually typing them and ensures you don't miss anything.

How do I type async data fetching state?

Use a discriminated union: type State = { status: 'idle' } | { status: 'loading' } | { status: 'success'; data: User } | { status: 'error'; error: string }. This way TypeScript ensures you only access data when status is 'success'.