Learn

/

React + TypeScript

React + TypeScript

7 patterns

Component props, event handlers, refs, generic components, and polymorphic patterns. You'll hit this when you try to type a React component that forwards refs or accepts an `as` prop.

Avoid
function UserCard(props: any) {
  return (
    <div>
      <h2>{props.name}</h2>
      <p>{props.emial}</p>
      {/* Typo: "emial" instead of "email" */}
      {/* No error because props is any */}
    </div>
  );
}

<UserCard name="Alice" />
// Missing email, no error
function UserCard(props: any) {
  return (
    <div>
      <h2>{props.name}</h2>
      <p>{props.emial}</p>
      {/* Typo: "emial" instead of "email" */}
      {/* No error because props is any */}
    </div>
  );
}

<UserCard name="Alice" />
// Missing email, no error

Prefer
interface UserCardProps {
  name: string;
  email: string;
  role?: "admin" | "user";
}

function UserCard({ name, email, role = "user" }: UserCardProps) {
  return (
    <div>
      <h2>{name}</h2>
      <p>{email}</p>
      <span>{role}</span>
    </div>
  );
}

// <UserCard name="Alice" />
// Error: missing required prop 'email'
interface UserCardProps {
  name: string;
  email: string;
  role?: "admin" | "user";
}

function UserCard({ name, email, role = "user" }: UserCardProps) {
  return (
    <div>
      <h2>{name}</h2>
      <p>{email}</p>
      <span>{role}</span>
    </div>
  );
}

// <UserCard name="Alice" />
// Error: missing required prop 'email'
Why avoid

Using any for props disables all type checking on the component. Misspelled property accesses, missing required props, and wrong value types all pass without errors. The bugs only surface at runtime when the UI renders incorrectly or crashes.

Why prefer

Defining a props interface gives you autocomplete, catches typos, and ensures required props are passed. Destructuring in the parameter list with default values makes the component signature clear. Optional props use ? and can have defaults.

React: Typing Component Props
Avoid
function SearchInput() {
  const handleChange = (e: any) => {
    // e is 'any', no autocomplete
    console.log(e.target.value);
    // What if target is null?
    // What if value doesn't exist?
  };

  const handleSubmit = (e: any) => {
    e.preventDefault();
    // Works, but no type safety
  };

  return <input onChange={handleChange} />;
}
function SearchInput() {
  const handleChange = (e: any) => {
    // e is 'any', no autocomplete
    console.log(e.target.value);
    // What if target is null?
    // What if value doesn't exist?
  };

  const handleSubmit = (e: any) => {
    e.preventDefault();
    // Works, but no type safety
  };

  return <input onChange={handleChange} />;
}

Prefer
function SearchInput() {
  const handleChange = (
    e: React.ChangeEvent<HTMLInputElement>
  ) => {
    console.log(e.target.value);
    // target is HTMLInputElement, not null
    // .value is guaranteed to exist
  };

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

  return <input onChange={handleChange} />;
}
function SearchInput() {
  const handleChange = (
    e: React.ChangeEvent<HTMLInputElement>
  ) => {
    console.log(e.target.value);
    // target is HTMLInputElement, not null
    // .value is guaranteed to exist
  };

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

  return <input onChange={handleChange} />;
}
Why avoid

Using any for event types removes autocomplete for event properties and element-specific attributes. You lose access to typed properties like e.target.value, e.target.checked, and e.currentTarget. Event types are straightforward to use and prevent common mistakes like forgetting preventDefault.

Why prefer

React provides generic event types like ChangeEvent, FormEvent, MouseEvent, and KeyboardEvent. The generic parameter specifies the element type (HTMLInputElement, HTMLFormElement), which types the target property correctly and provides autocomplete for element-specific properties.

React: Typing DOM Events
Avoid
interface ButtonProps {
  label: string;
  onClick?: () => void;
  disabled?: boolean;
  className?: string;
  type?: "button" | "submit" | "reset";
  // Must manually list every HTML button prop
}

function Button({ label, ...rest }: ButtonProps) {
  return <button {...rest}>{label}</button>;
}
interface ButtonProps {
  label: string;
  onClick?: () => void;
  disabled?: boolean;
  className?: string;
  type?: "button" | "submit" | "reset";
  // Must manually list every HTML button prop
}

function Button({ label, ...rest }: ButtonProps) {
  return <button {...rest}>{label}</button>;
}

Prefer
import { ComponentProps } from "react";

interface ButtonProps extends ComponentProps<"button"> {
  label: string;
}

function Button({ label, ...rest }: ButtonProps) {
  return <button {...rest}>{label}</button>;
}

// All native button props are included automatically
// <Button label="Go" aria-label="Go" formAction="/api" />
import { ComponentProps } from "react";

interface ButtonProps extends ComponentProps<"button"> {
  label: string;
}

function Button({ label, ...rest }: ButtonProps) {
  return <button {...rest}>{label}</button>;
}

// All native button props are included automatically
// <Button label="Go" aria-label="Go" formAction="/api" />
Why avoid

Manually listing HTML props is incomplete and fragile. You inevitably miss attributes like aria-*, form, formAction, or autoFocus. Every time you need another native prop, you have to update the interface. ComponentProps gives you all of them for free.

Why prefer

ComponentProps<"button"> extracts every valid HTML button attribute automatically. Your wrapper only declares the custom props it adds. New HTML attributes are picked up when React's types update, and consumers get full autocomplete for native props like aria attributes and form actions.

Total TypeScript: ComponentProps
Avoid
const Input = React.forwardRef((props, ref) => {
  // ref is unknown, props is {}
  // No autocomplete, no type safety
  return (
    <input
      ref={ref}
      placeholder={props.placeholder}
      // Error: Property 'placeholder'
      // does not exist on type '{}'
    />
  );
});
const Input = React.forwardRef((props, ref) => {
  // ref is unknown, props is {}
  // No autocomplete, no type safety
  return (
    <input
      ref={ref}
      placeholder={props.placeholder}
      // Error: Property 'placeholder'
      // does not exist on type '{}'
    />
  );
});

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

const Input = React.forwardRef<
  HTMLInputElement,
  InputProps
>(({ placeholder, label, error }, ref) => {
  return (
    <div>
      <label>{label}</label>
      <input ref={ref} placeholder={placeholder} />
      {error && <span>{error}</span>}
    </div>
  );
});

Input.displayName = "Input";
interface InputProps {
  placeholder?: string;
  label: string;
  error?: string;
}

const Input = React.forwardRef<
  HTMLInputElement,
  InputProps
>(({ placeholder, label, error }, ref) => {
  return (
    <div>
      <label>{label}</label>
      <input ref={ref} placeholder={placeholder} />
      {error && <span>{error}</span>}
    </div>
  );
});

Input.displayName = "Input";
Why avoid

Without generic parameters, forwardRef infers ref as unknown and props as an empty object. Accessing any prop requires a type assertion, and the ref cannot be used with element-specific methods. The generic parameters are the only way to get proper types through forwardRef.

Why prefer

React.forwardRef accepts two generic parameters: the ref element type and the props type. This gives the ref the correct type (HTMLInputElement) so consumers get autocomplete on ref.current, and the component gets typed props. Setting displayName helps with React DevTools.

React: forwardRef
Avoid
interface LayoutProps {
  children: any;
}

function Layout({ children }: LayoutProps) {
  return <main>{children}</main>;
}

// All of these work, but some shouldn't:
<Layout children={undefined} />
<Layout children={new Map()} />
<Layout children={Symbol("x")} />
// Maps and Symbols aren't renderable
interface LayoutProps {
  children: any;
}

function Layout({ children }: LayoutProps) {
  return <main>{children}</main>;
}

// All of these work, but some shouldn't:
<Layout children={undefined} />
<Layout children={new Map()} />
<Layout children={Symbol("x")} />
// Maps and Symbols aren't renderable

Prefer
interface LayoutProps {
  children: React.ReactNode;
}

function Layout({ children }: LayoutProps) {
  return <main>{children}</main>;
}

// Accepts valid React children:
<Layout>Hello</Layout>
<Layout><Header /><Content /></Layout>
<Layout>{null}</Layout>
<Layout>{items.map((i) => <Item key={i.id} />)}</Layout>

// For single element only:
// children: React.ReactElement
interface LayoutProps {
  children: React.ReactNode;
}

function Layout({ children }: LayoutProps) {
  return <main>{children}</main>;
}

// Accepts valid React children:
<Layout>Hello</Layout>
<Layout><Header /><Content /></Layout>
<Layout>{null}</Layout>
<Layout>{items.map((i) => <Item key={i.id} />)}</Layout>

// For single element only:
// children: React.ReactElement
Why avoid

Typing children as any allows non-renderable values like Maps and Symbols to be passed. React will throw a runtime error when it tries to render them. ReactNode is the correct type for the children prop because it matches exactly what React can render.

Why prefer

React.ReactNode covers everything React can render: elements, strings, numbers, fragments, portals, null, undefined, and booleans. Using it instead of any prevents passing non-renderable values like Maps, Sets, and Symbols. Use React.ReactElement if you need exactly one JSX element.

React: Typing Children
Avoid
interface ButtonProps {
  variant: "link" | "button";
  href?: string;
  onClick?: () => void;
  children: React.ReactNode;
}

function Button({ variant, href, onClick, children }: ButtonProps) {
  if (variant === "link") {
    // href might be undefined even for links
    return <a href={href}>{children}</a>;
  }
  return <button onClick={onClick}>{children}</button>;
}

// No error, but link has no href:
<Button variant="link">Go</Button>
interface ButtonProps {
  variant: "link" | "button";
  href?: string;
  onClick?: () => void;
  children: React.ReactNode;
}

function Button({ variant, href, onClick, children }: ButtonProps) {
  if (variant === "link") {
    // href might be undefined even for links
    return <a href={href}>{children}</a>;
  }
  return <button onClick={onClick}>{children}</button>;
}

// No error, but link has no href:
<Button variant="link">Go</Button>

Prefer
type ButtonProps =
  | {
      variant: "link";
      href: string;
      children: React.ReactNode;
    }
  | {
      variant: "button";
      onClick?: () => void;
      children: React.ReactNode;
    };

function Button(props: ButtonProps) {
  if (props.variant === "link") {
    // href is guaranteed to be string
    return <a href={props.href}>{props.children}</a>;
  }
  return (
    <button onClick={props.onClick}>
      {props.children}
    </button>
  );
}

// Error: 'href' is missing for variant "link"
// <Button variant="link">Go</Button>
type ButtonProps =
  | {
      variant: "link";
      href: string;
      children: React.ReactNode;
    }
  | {
      variant: "button";
      onClick?: () => void;
      children: React.ReactNode;
    };

function Button(props: ButtonProps) {
  if (props.variant === "link") {
    // href is guaranteed to be string
    return <a href={props.href}>{props.children}</a>;
  }
  return (
    <button onClick={props.onClick}>
      {props.children}
    </button>
  );
}

// Error: 'href' is missing for variant "link"
// <Button variant="link">Go</Button>
Why avoid

Making all variant-specific props optional means TypeScript cannot enforce that a link has an href or that a button has an onClick. A link without href renders as an anchor with no destination. Discriminated unions enforce correct props for each variant.

Why prefer

Discriminated unions use a literal type field (variant) to determine which set of props is required. When variant is 'link', TypeScript knows href is required and onClick does not exist. This makes invalid states unrepresentable at the type level.

TypeScript: Discriminated Unions
Avoid
interface TextProps {
  as?: string;
  children: React.ReactNode;
  className?: string;
}

function Text({ as = "p", children, ...rest }: TextProps) {
  const Component = as as any;
  return <Component {...rest}>{children}</Component>;
}

// No validation of valid HTML elements
// No type safety on element-specific props
<Text as="buttn">Click</Text>
// Typo renders <buttn> tag
interface TextProps {
  as?: string;
  children: React.ReactNode;
  className?: string;
}

function Text({ as = "p", children, ...rest }: TextProps) {
  const Component = as as any;
  return <Component {...rest}>{children}</Component>;
}

// No validation of valid HTML elements
// No type safety on element-specific props
<Text as="buttn">Click</Text>
// Typo renders <buttn> tag

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

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

<Text as="a" href="/about">Link</Text>
// <Text as="a" href={42}>Link</Text>
// Error: href must be string
type TextProps<T extends React.ElementType> = {
  as?: T;
  children: React.ReactNode;
} & Omit<
  React.ComponentPropsWithoutRef<T>,
  "as" | "children"
>;

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

<Text as="a" href="/about">Link</Text>
// <Text as="a" href={42}>Link</Text>
// Error: href must be string
Why avoid

Typing the 'as' prop as string provides no validation. Typos in element names compile without errors, and element-specific props like href are not type-checked. The generic pattern connects the 'as' value to the allowed props, so passing href to a div is caught at compile time.

Why prefer

A generic component parameterized by React.ElementType lets TypeScript infer the correct props for whatever element or component is passed via the 'as' prop. Using Omit prevents conflicts between your custom props and the target element's props. This is the pattern used by libraries like Chakra UI and Radix.

Total TypeScript: Polymorphic Components