React + TypeScript: The Complete Guide 🔷

1. Introduction

TypeScript adds a static type system on top of JavaScript, catching a whole category of bugs — wrong prop types, undefined values, mismatched function signatures — before your code ever runs. Combined with React, it turns components into self-documenting, autocomplete-friendly building blocks.

This tutorial walks through everything you need to type React applications confidently: components, hooks, events, context, forms, and more.

Information

This tutorial assumes familiarity with both React fundamentals and basic TypeScript syntax (types, interfaces).

2. Why Use TypeScript with React? 💡

  • Catch bugs early — typos in prop names or wrong data shapes are caught at compile time, not in production.
  • Better editor support — autocomplete, inline docs, and "go to definition" all improve dramatically.
  • Safer refactoring — renaming a prop or changing a type immediately flags every place that needs updating.
  • Self-documenting code — types describe the shape of props, state, and data without needing separate docs.

3. Setting Up React with TypeScript âš™ī¸

The fastest way to start a new project is via Vite, which ships first-class TypeScript templates.

Creating a new project

npm create vite@latest my-app -- --template react-ts
cd my-app
npm install
npm run dev

For an existing JavaScript project, add TypeScript incrementally by installing the necessary packages and a tsconfig.json.

Adding TypeScript to an existing project

npm install -D typescript @types/react @types/react-dom

4. Project Structure đŸ—‚ī¸

TypeScript React files use the .tsx extension for files containing JSX, and plain .ts for files that don't render markup.

src
main.tsx
App.tsx
components
Button.tsx
Card.tsx

Tip

Keep shared type definitions (e.g. API response shapes) in a dedicated types/ folder so they can be imported across components without duplication.

5. Typing Components 🧩

A function component in TypeScript is just a regular function that returns JSX — the return type is usually inferred, but props need explicit typing.

Greeting.tsx

interface GreetingProps {
  name: string;
}

function Greeting({ name }: GreetingProps) {
  return <p>Hello, {name}!</p>;
}

Note

The older React.FC type is now generally discouraged — it implicitly adds children to every component and complicates generics. Plain function typing (as above) is the current recommended approach.

6. Typing Props 📋

Props are typically defined with an interface (extendable, good for object shapes) or a type alias (more flexible, supports unions).

Prop patterns

interface ButtonProps {
  label: string;
  onClick: () => void;
  disabled?: boolean;       // optional prop
  variant?: 'primary' | 'secondary'; // union of string literals
}

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

7. Typing State đŸ—ƒī¸

useState infers the type from its initial value in simple cases, but you'll often need an explicit generic for more complex or nullable state.

State typing examples

const [count, setCount] = useState(0); // inferred as number

interface User {
  id: string;
  name: string;
}

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

8. Typing Events đŸ–ąī¸

React provides specific synthetic event types for each element and event kind, ensuring the event object's properties (like target.value) are correctly typed.

Common event types

function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
  console.log(e.target.value); // typed as string
}

function handleClick(e: React.MouseEvent<HTMLButtonElement>) {
  console.log(e.currentTarget);
}

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

Hint

When in doubt about which event type to use, hover the built-in onX prop (e.g. onChange) in your editor — TypeScript will show the expected handler signature.

9. Typing Functions 🔧

Function props — like callbacks passed down to children — should be typed with explicit parameter and return types to avoid any creeping in.

Typed callback props

interface SearchBarProps {
  onSearch: (query: string) => void;
  onClear?: () => void;
}

function SearchBar({ onSearch, onClear }: SearchBarProps) {
  return (
    <input
      onChange={(e) => onSearch(e.target.value)}
      onBlur={() => onClear?.()}
    />
  );
}

10. Typing Children đŸ‘ļ

The children prop uses React's built-in React.ReactNode type, which covers strings, numbers, elements, fragments, and arrays thereof.

Card.tsx

interface CardProps {
  title: string;
  children: React.ReactNode;
}

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

Caution

Avoid JSX.Element for children — it's narrower than ReactNode and rejects valid children like strings, numbers, or null.

11. Typing Refs 📌

useRef needs a type argument matching the DOM element (or value) it will point to. Refs to DOM nodes should be initialized with null.

Typed refs

function TextInput() {
  const inputRef = useRef<HTMLInputElement>(null);

  const focus = () => {
    inputRef.current?.focus(); // optional chaining, since current may be null
  };

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

12. Typing Hooks đŸĒ

Most built-in hooks are generic functions — you pass a type parameter in angle brackets (<T>) to tell TypeScript what shape of data they hold, as covered in detail in the next several sections.

13. Typing useState đŸ”ĸ

useState patterns

// Inferred
const [isOpen, setIsOpen] = useState(false);

// Explicit union for nullable async data
const [error, setError] = useState<string | null>(null);

// Explicit array type
const [items, setItems] = useState<string[]>([]);

Tip

Always give useState an explicit type when the initial value doesn't fully represent future values — e.g. starting with null but expecting an object later.

14. Typing useEffect âŗ

useEffect itself needs no type parameters, but its cleanup function must return either a function or undefined — never a Promise directly.

Typed effect cleanup

useEffect(() => {
  const id = setInterval(() => console.log('tick'), 1000);

  return () => clearInterval(id); // cleanup, correctly typed as () => void
}, []);

Warning

Marking the effect callback itself async is a common mistake — it implicitly returns a Promise, which React's cleanup typing rejects. Define an inner async function instead and call it.

15. Typing useRef đŸŽ¯

useRef has two common typing patterns depending on purpose: DOM refs (read-only, initialized null) and mutable value refs (read-write, holding arbitrary data across renders).

Two useRef patterns

// DOM ref
const divRef = useRef<HTMLDivElement>(null);

// Mutable value ref (e.g. storing a timer ID)
const timerRef = useRef<number | undefined>(undefined);

16. Typing useContext 🧭

Typed context

interface Theme {
  mode: 'light' | 'dark';
  toggle: () => void;
}

const ThemeContext = createContext<Theme | undefined>(undefined);

function useTheme() {
  const ctx = useContext(ThemeContext);
  if (!ctx) throw new Error('useTheme must be used within a ThemeProvider');
  return ctx; // narrowed to Theme, no longer undefined
}

Best Practice

Defaulting context to undefined and throwing in a custom hook (as above) forces consumers to be wrapped in a provider, catching missing-provider bugs at development time instead of silently rendering with wrong defaults.

17. Typing useReducer 🔀

Typed reducer

interface State {
  count: number;
}

type Action =
  | { type: 'increment' }
  | { type: 'decrement' }
  | { type: 'set'; payload: 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 'set':
      return { count: action.payload };
  }
}

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

Tip

A discriminated union for Action (each variant tagged with a distinct type string) lets TypeScript narrow action.payload correctly inside each case.

18. Typing Custom Hooks đŸŽŖ

Custom hooks should have explicit return types — especially when returning a tuple (array), since TypeScript would otherwise infer a generic array type instead of a fixed-length tuple.

useToggle.ts

function useToggle(initial = false): [boolean, () => void] {
  const [value, setValue] = useState(initial);
  const toggle = () => setValue((v) => !v);
  return [value, toggle];
}

Caution

Without the explicit [boolean, () => void] return type, TypeScript may infer (boolean | (() => void))[], breaking destructuring assumptions at call sites.

19. Typing Context API đŸ§ĩ

For larger apps, combine a typed context with a typed provider component to encapsulate both the value shape and the logic that produces it.

AuthContext.tsx

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

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

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

  const login = async (email: string, password: string) => {
    // authentication logic
  };

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

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

20. Typing Forms 📝

Form state is typically modeled as a single typed object, updated immutably on each field change.

Typed form state

interface SignupForm {
  email: string;
  password: string;
  agreeToTerms: boolean;
}

function useSignupForm() {
  const [form, setForm] = useState<SignupForm>({
    email: '',
    password: '',
    agreeToTerms: false,
  });

  function updateField<K extends keyof SignupForm>(key: K, value: SignupForm[K]) {
    setForm((prev) => ({ ...prev, [key]: value }));
  }

  return { form, updateField };
}

Tip

The generic updateField<K extends keyof SignupForm> pattern keeps field name and value type linked — passing a boolean for 'email' would be a compile error.

21. Typing API Responses 🌐

Define interfaces matching your backend's response shape, and apply them to fetch or your HTTP client's generic return type.

Typed fetch

interface Post {
  id: number;
  title: string;
  body: string;
}

async function fetchPosts(): Promise<Post[]> {
  const res = await fetch('/api/posts');
  if (!res.ok) throw new Error('Failed to fetch posts');
  const data: Post[] = await res.json();
  return data;
}

Warning

res.json() returns any by default — TypeScript won't actually verify the response matches your interface at runtime. For real safety, validate the shape with a schema library like zod.

22. Generics in React đŸ§Ŧ

Generic components let a single component work correctly across many data types while preserving full type safety — extremely useful for reusable list, table, or select components.

Generic List component

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

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

// Usage: T is inferred as `User`
<List items={users} renderItem={(user) => <span>{user.name}</span>} />;

23. Utility Types đŸ› ī¸

TypeScript's built-in utility types transform existing types without redefining them from scratch — extremely useful when working with prop types derived from data models.

Utility TypeEffect
Partial<T>Makes all properties of T optional
Pick<T, K>Selects a subset of keys K from T
Omit<T, K>Excludes keys K from T
Readonly<T>Makes all properties immutable
Record<K, V>Builds an object type with keys K mapped to values V

Utility type example

interface User {
  id: string;
  name: string;
  email: string;
}

// Only id and name are required for a preview card
type UserPreview = Pick<User, 'id' | 'name'>;

// All fields optional, for a partial update form
type UserUpdate = Partial<User>;

24. Component Patterns with TypeScript đŸ›ī¸

Use a discriminated union when a component's props change shape based on a variant or kind field, ensuring invalid prop combinations are rejected at compile time.

Discriminated union props

type AlertProps =
  | { variant: 'success'; message: string }
  | { variant: 'error'; message: string; retry: () => void };

function Alert(props: AlertProps) {
  if (props.variant === 'error') {
    return <div onClick={props.retry}>{props.message}</div>; // retry is safely typed here
  }
  return <div>{props.message}</div>;
}

Polymorphic components accept an as prop to render as a different underlying element while keeping that element's own props type-checked.

Simplified polymorphic prop typing

type TextProps<T extends React.ElementType> = {
  as?: T;
  children: React.ReactNode;
} & React.ComponentPropsWithoutRef<T>;

function Text<T extends React.ElementType = 'span'>({ as, children, ...rest }: TextProps<T>) {
  const Component = as || 'span';
  return <Component {...rest}>{children}</Component>;
}

25. Type Safety Best Practices ✅

  1. Enable strict mode in tsconfig.json — it catches far more real bugs than the default settings.
  2. Avoid any; prefer unknown when a type is genuinely not known yet, then narrow it before use.
  3. Let TypeScript infer types where possible — only annotate when inference is ambiguous (like useState with a nullable initial value).
  4. Validate external data (API responses, form input) at runtime with a schema library, since types disappear at compile time.

26. Common Type Errors ❌

ErrorTypical Cause
Object is possibly 'null'Accessing .current on a ref without optional chaining or a null check
Type 'X' is not assignable to type 'Y'Passing a prop of the wrong shape or missing a required field
Property does not exist on type 'EventTarget'Using the wrong (or no) generic on a React event type
Cannot find module or its type declarationsA third-party library lacks @types/bundled types

27. Performance Considerations ⚡

TypeScript itself has zero runtime cost — all type annotations are erased during compilation. However, type choices can still affect developer velocity and build times.

  • Overly complex generic types can slow down the TypeScript compiler on large codebases.
  • Prefer interface over deeply nested type intersections for large object shapes — interfaces tend to check faster.
  • Use type-only imports (import type { User } from './types') so bundlers can safely strip them, keeping bundle size unaffected by type usage.

28. Frequently Asked Questions ❓

Question

Should I use interface or type for props?

Answer

Either works for most component props. interface is conventional for object shapes and supports declaration merging; type is required for unions, intersections, and tuples.

Question

Is React.FC still recommended?

Answer

Most of the community has moved away from it in favor of typing props directly on the function, mainly because React.FC implicitly adds children and complicates generic components.

Question

Does TypeScript replace PropTypes?

Answer

Yes — TypeScript's compile-time checking supersedes the runtime checks PropTypes provided, and most modern projects use TypeScript instead of PropTypes.

29. Summary 📌

Summary

TypeScript brings compile-time safety to every layer of a React app — props, state, events, context, and hooks — catching mismatches before they ever reach a browser. Generic hooks like useState<T> and useRef<T> let you precisely describe data shapes, while utility types like Partial and Pick keep type definitions DRY.

The result is a codebase that's easier to refactor, easier to onboard into, and far less prone to an entire class of runtime bugs. Happy typing! 🎉