Type something to search...
TypeScript with React: Typing Props, State, and Events

TypeScript with React: Typing Props, State, and Events

Most React bugs that TypeScript catches are boring ones: a prop passed as a string instead of a number, a state value that can be null but is used as if it never is, an event handler that reads e.target.value on an element that doesn't have one. Boring bugs are still bugs, and finding them in the editor beats finding them in production.

The hard part isn't TypeScript itself. It's knowing which types React gives you and where inference does the work for you. Many React codebases are full of annotations that add noise without adding safety, and missing the few annotations that actually matter.

In this post I'll cover the everyday typing patterns for React 19 function components: props and children, default and optional props, useState and useReducer, refs, DOM events and handlers, and extending native element props. Each section shows where to annotate and, just as important, where to let inference take over.

Typing Component Props

A component's props are just a function parameter, so you type them like any other object. Use a type alias or an interface. Both work, and the choice is mostly a team convention:

type UserCardProps = {
  name: string;
  email: string;
  age?: number;
  role: "admin" | "editor" | "viewer";
};

export function UserCard({ name, email, age, role }: UserCardProps) {
  return (
    <article>
      <h2>{name}</h2>
      <p>{email}</p>
      {age !== undefined && <p>Age: {age}</p>}
      <span className={`badge badge-${role}`}>{role}</span>
    </article>
  );
}

A few things are happening here. age?: number makes the prop optional, so its type inside the component is number | undefined. The role prop uses a union of string literals, which is far more useful than string: callers get autocomplete, and a typo like "admn" fails to compile.

Notice there's no React.FC annotation. You'll still see const UserCard: React.FC<UserCardProps> = ... in older code. It works, but a plain function with typed parameters is simpler, plays better with generics, and is what the React and TypeScript docs use today. The return type is inferred as JSX, so you don't need to write it.

Default Values

Give optional props defaults with destructuring defaults. TypeScript understands that the variable is no longer undefined inside the function:

type ButtonProps = {
  label: string;
  variant?: "primary" | "secondary";
  size?: "sm" | "md" | "lg";
};

export function Button({ label, variant = "primary", size = "md" }: ButtonProps) {
  // variant is "primary" | "secondary" here, not undefined
  return <button className={`btn btn-${variant} btn-${size}`}>{label}</button>;
}

Don't use defaultProps on function components. React 19 removed support for it on function components entirely.

Typing children

When a component accepts children, type them as React.ReactNode. It covers everything React can render: elements, strings, numbers, arrays, fragments, null, undefined, and booleans.

import type { ReactNode } from "react";

type CardProps = {
  title: string;
  children: ReactNode;
  footer?: ReactNode;
};

export function Card({ title, children, footer }: CardProps) {
  return (
    <section className="card">
      <h3>{title}</h3>
      <div className="card-body">{children}</div>
      {footer && <div className="card-footer">{footer}</div>}
    </section>
  );
}

Avoid JSX.Element for children unless you really need a single element. It rejects plain strings, so Card with text children would fail to compile. If you need to accept a render function, type it explicitly, for example children: (item: Item) => ReactNode.

React.PropsWithChildren is a helper that adds an optional children: ReactNode to your props type. It's fine to use, but being explicit about whether children are required is usually clearer.

Function Props

Callback props are typed as function types. Name them after what happened, not what the parent will do:

type TodoItemProps = {
  id: string;
  text: string;
  done: boolean;
  onToggle: (id: string) => void;
  onRename: (id: string, nextText: string) => void;
};

Using void as the return type is deliberate. It means the component ignores whatever the callback returns, so a parent can pass a function that returns a promise or a number without a type error.

Typing State with useState

For most state, inference is enough. useState(0) is a number, useState("") is a string, and useState(false) is a boolean. Writing useState<number>(0) adds nothing.

Annotate when the initial value doesn't describe every value the state can hold. The two most common cases are nullable state and empty arrays:

import { useState } from "react";

type User = {
  id: number;
  name: string;
};

export function Profile() {
  // Without the annotation, TypeScript infers null and nothing else
  const [user, setUser] = useState<User | null>(null);

  // Without the annotation, this becomes never[]
  const [tags, setTags] = useState<string[]>([]);

  if (!user) {
    return <button onClick={() => setUser({ id: 1, name: "Ada" })}>Load user</button>;
  }

  return (
    <div>
      <h2>{user.name}</h2>
      <button onClick={() => setTags((prev) => [...prev, "new"])}>Add tag</button>
      <p>{tags.join(", ")}</p>
    </div>
  );
}

After the if (!user) check, TypeScript narrows user to User, so user.name is safe. That narrowing is the real value of User | null: the compiler forces you to handle the loading case before reading properties.

Modeling Status with Unions

When several state variables only make sense together, like isLoading, error, and data, a union is safer than separate booleans:

type FetchState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: string };

const [state, setState] = useState<FetchState<User[]>>({ status: "idle" });

Now it's impossible to have data and error at the same time, and state.data is only accessible after checking state.status === "success". I go deeper on this in discriminated unions for type-safe React props.

Typing useReducer

useReducer infers its types from the reducer function, so type the reducer's parameters and the rest follows:

import { useReducer } from "react";

type CartItem = { id: string; name: string; price: number; qty: number };

type CartState = {
  items: CartItem[];
  coupon: string | null;
};

type CartAction =
  | { type: "add"; item: Omit<CartItem, "qty"> }
  | { type: "remove"; id: string }
  | { type: "setQty"; id: string; qty: number }
  | { type: "applyCoupon"; code: string };

function cartReducer(state: CartState, action: CartAction): CartState {
  switch (action.type) {
    case "add": {
      const existing = state.items.find((i) => i.id === action.item.id);
      if (existing) {
        return {
          ...state,
          items: state.items.map((i) =>
            i.id === action.item.id ? { ...i, qty: i.qty + 1 } : i,
          ),
        };
      }
      return { ...state, items: [...state.items, { ...action.item, qty: 1 }] };
    }
    case "remove":
      return { ...state, items: state.items.filter((i) => i.id !== action.id) };
    case "setQty":
      return {
        ...state,
        items: state.items.map((i) => (i.id === action.id ? { ...i, qty: action.qty } : i)),
      };
    case "applyCoupon":
      return { ...state, coupon: action.code };
  }
}

export function Cart() {
  const [state, dispatch] = useReducer(cartReducer, { items: [], coupon: null });
  const total = state.items.reduce((sum, i) => sum + i.price * i.qty, 0);

  return (
    <div>
      <p>Total: ${total.toFixed(2)}</p>
      <button onClick={() => dispatch({ type: "add", item: { id: "a1", name: "Mug", price: 12 } })}>
        Add mug
      </button>
    </div>
  );
}

The action type is a discriminated union on type. Inside each case, TypeScript knows exactly which fields exist, so action.qty only compiles in the "setQty" branch. Calling dispatch({ type: "setQty", id: "a1" }) without qty is a compile error at the call site.

Because the reducer has an explicit CartState return type and every case returns, TypeScript also checks that the switch is exhaustive. Add a new action to the union without handling it and the function no longer satisfies its return type.

Typing Refs

useRef has two common uses, and they're typed differently.

For DOM refs, pass the element type and null as the initial value:

import { useEffect, useRef } from "react";

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

  useEffect(() => {
    inputRef.current?.focus();
  }, []);

  return <input ref={inputRef} type="search" placeholder="Search..." />;
}

inputRef.current is HTMLInputElement | null, so you need optional chaining or a null check. That's correct: the ref is null before mount and after unmount.

For mutable values that aren't DOM nodes, like timer IDs or previous values, give the ref the type of the value:

const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

function start() {
  timerRef.current = setTimeout(() => console.log("done"), 1000);
}

function stop() {
  if (timerRef.current) clearTimeout(timerRef.current);
}

ReturnType<typeof setTimeout> avoids the number versus NodeJS.Timeout mismatch between browser and Node typings. In React 19 types, useRef always requires an argument, and current is always mutable, so the old MutableRefObject versus RefObject confusion is mostly gone.

Passing Refs to Your Own Components

In React 19, ref is a regular prop on function components, so you don't need forwardRef anymore. Type it with React.Ref:

import type { Ref } from "react";

type TextFieldProps = {
  label: string;
  ref?: Ref<HTMLInputElement>;
};

export function TextField({ label, ref }: TextFieldProps) {
  return (
    <label>
      {label}
      <input ref={ref} />
    </label>
  );
}

Typing Events

React wraps DOM events in its own synthetic event types, all exported from react. The type parameter is the element the handler is attached to:

EventType
onClickReact.MouseEvent<HTMLButtonElement>
onChange (input)React.ChangeEvent<HTMLInputElement>
onChange (select)React.ChangeEvent<HTMLSelectElement>
onSubmitReact.FormEvent<HTMLFormElement>
onKeyDownReact.KeyboardEvent<HTMLInputElement>
onFocus/onBlurReact.FocusEvent<HTMLInputElement>
onDragStartReact.DragEvent<HTMLDivElement>

If you write the handler inline, you don't need any of these. TypeScript infers the event type from the JSX attribute:

<input onChange={(e) => setQuery(e.target.value)} />

You need explicit types when the handler is defined separately:

import { useState, type ChangeEvent, type FormEvent, type KeyboardEvent } from "react";

export function SignupForm() {
  const [email, setEmail] = useState("");
  const [plan, setPlan] = useState("free");

  function handleEmailChange(e: ChangeEvent<HTMLInputElement>) {
    setEmail(e.target.value);
  }

  function handlePlanChange(e: ChangeEvent<HTMLSelectElement>) {
    setPlan(e.target.value);
  }

  function handleKeyDown(e: KeyboardEvent<HTMLInputElement>) {
    if (e.key === "Escape") setEmail("");
  }

  function handleSubmit(e: FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const data = new FormData(e.currentTarget);
    console.log(Object.fromEntries(data), { email, plan });
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="email" value={email} onChange={handleEmailChange} onKeyDown={handleKeyDown} />
      <select name="plan" value={plan} onChange={handlePlanChange}>
        <option value="free">Free</option>
        <option value="pro">Pro</option>
      </select>
      <button type="submit">Sign up</button>
    </form>
  );
}

target vs currentTarget

In the submit handler, I used e.currentTarget, not e.target. That's on purpose. currentTarget is the element the handler is attached to, and it's typed with the generic you provided, so it's an HTMLFormElement. target is whatever element actually fired the event, which could be any child, so React types it as a generic EventTarget. For ChangeEvent, React narrows target to the element type for convenience, but for other events, prefer currentTarget.

Typing Handler Props

When you pass handlers as props, you can use React's handler types instead of spelling out the event:

import type { MouseEventHandler } from "react";

type IconButtonProps = {
  icon: string;
  label: string;
  onClick: MouseEventHandler<HTMLButtonElement>;
};

MouseEventHandler<HTMLButtonElement> is shorthand for (event: MouseEvent<HTMLButtonElement>) => void. Often, though, the parent doesn't care about the event at all. In that case onClick: () => void is simpler and doesn't leak DOM details into your component's API.

Extending Native Element Props

Wrapper components like Button or Input should accept every attribute the underlying element accepts: disabled, aria-*, type, onClick, and so on. Don't list them by hand. Use React.ComponentProps:

import type { ComponentProps } from "react";

type ButtonProps = ComponentProps<"button"> & {
  variant?: "primary" | "danger";
  loading?: boolean;
};

export function Button({ variant = "primary", loading = false, children, disabled, ...rest }: ButtonProps) {
  return (
    <button
      {...rest}
      disabled={disabled || loading}
      aria-busy={loading}
      className={`btn btn-${variant}`}
    >
      {loading ? "Saving..." : children}
    </button>
  );
}

ComponentProps<"button"> includes ref in React 19, so the ref flows through ...rest automatically. If you want to exclude a native prop, combine it with Omit, for example Omit<ComponentProps<"input">, "size"> when your own size prop means something different.

You can also extract props from another component with ComponentProps<typeof UserCard>, which is handy when a wrapper needs to forward everything to a child component you don't control.

Common Mistakes When Typing React Components

  • Annotating everything. useState<string>("") and explicit JSX return types add noise. Let inference work, and annotate only where the initial value is incomplete.
  • Using any for events. (e: any) => ... silences every error in the handler. Use the specific React event type, or write the handler inline so it's inferred.
  • Typing children as JSX.Element. It rejects strings and arrays. Use ReactNode.
  • Using useState([]) without a type. You get never[], and every setState call fails with a confusing error.
  • Reading e.target on non-change events. It's a plain EventTarget. Use e.currentTarget, which has the element type.
  • Using object or {} as a props type. Neither describes anything useful. Write out the shape.
  • Reaching for as casts. null as unknown as User hides the exact bugs TypeScript is supposed to catch. Model the nullable state honestly and narrow it.

Frequently Asked Questions (FAQ) About TypeScript with React

Either works. Interfaces can be extended and merged, and type aliases can express unions and mapped types. Many teams use type for props because props often involve unions. Pick one and be consistent across the codebase.

It's not deprecated, but it's no longer recommended. Since React 18 it no longer adds implicit children, so its main benefit is gone. Plain functions with typed props are simpler and work better with generic components.

ReactElement is the object created by JSX, such as the result of rendering a component. ReactNode is wider and includes elements, strings, numbers, arrays, fragments, null, undefined, and booleans. Use ReactNode for children and render slots.

If the child doesn't need the event, use () => void. If it does, use a React handler type like MouseEventHandler<HTMLButtonElement> or write the parameter type yourself, such as (e: ChangeEvent<HTMLInputElement>) => void.

No. In React 19, function components receive ref as a normal prop. Declare it in your props type as ref?: Ref<HTMLInputElement> and pass it to the DOM element. forwardRef still works but will be deprecated in a future version.

Give the reducer an explicit return type and return from every case. If a new action type is added to the union but not handled, the function can fall through and return undefined, which doesn't match the return type, so the compiler reports an error.

Conclusion

Typing React well is mostly about putting annotations in the right few places. Type props with an object type and string literal unions, children with ReactNode, nullable and array state with an explicit generic, reducers through their parameters, refs with the element type and null, and separately defined handlers with React's event types. Everywhere else, let inference do the work.

From here, try converting a component that uses several booleans for loading and error state into a discriminated union, and wrap a native element using ComponentProps so it forwards every attribute. When you're ready for more, generic components in React with TypeScript shows how to build lists, tables, and selects that keep their item types all the way through.

Tags :
Share :

Related Posts

A Practical Guide to useEffect and Its Dependency Array

A Practical Guide to useEffect and Its Dependency Array

useEffect is the hook people get wrong most often, and the dependency array is usually where it goes wrong. Leave a value out and your effect works

Continue Reading
Accessibility Best Practices for React Developers

Accessibility Best Practices for React Developers

React makes it easy to build interfaces out of anything. A div with an onClick looks and behaves like a button for a mouse user, so it ships. The

Continue Reading
Animations in React with Motion (Framer Motion)

Animations in React with Motion (Framer Motion)

CSS transitions get you far, until you need to animate something leaving the page. React removes the element from the DOM immediately, so there's not

Continue Reading