Hirefullstack – Software Engineering & IT-Beratung aus Berlin
← Back to overview
Beginner 11 min read

Typing props and events

Where types belong in day-to-day React – and where they don't

In one sentence

In React you write types in about three places: props, extracted event handlers and a few hooks. TypeScript works out the rest.

Props: one object, one type

A ComponentA function that describes what a piece of the interface should look like – a button, or a whole page.It always starts with a capital letter and returns JSX. You then use it like your own HTML element: <MyButton />. receives one object. So you describe exactly that object – nothing more.

Button.tsx
type ButtonProps = {
  label: string;
  variant?: "primary" | "ghost";   // ? = optional
  onClick: () => void;             // function, no arguments, no return
};

export function Button({ label, variant = "primary", onClick }: ButtonProps) {
  return <button className={variant} onClick={onClick}>{label}</button>;
}
Good to know You do not need React.FC

People used to write const X: React.FC<Props> = …. Today the plain function with a typed parameter is the standard – shorter and without surprises.

children: everything written in between

children.tsx
import type { ReactNode } from "react";

type CardProps = {
  title: string;
  children: ReactNode;    // anything displayable: JSX, text, number, array, null
};

export function Card({ title, children }: CardProps) {
  return (
    <section>
      <h2>{title}</h2>
      {children}
    </section>
  );
}

// Usage:
<Card title="Notice">This ends up in children.</Card>

ReactNode is nearly always right. JSX.Element is narrower – with it even <Card>Hello</Card> would be an error, because plain text is not an element.

Events: think from the element

events.tsx
// Written inline: TypeScript knows the type by itself – preferred!
<input onChange={(e) => console.log(e.target.value)} />

// Extracted: now you have to write the type yourself
import type { ChangeEvent, FormEvent, MouseEvent } from "react";

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

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

function handleClick(e: MouseEvent<HTMLButtonElement>) {
  console.log("clicked");
}
Tip The easiest route

Write the handler inline first and let the editor show you the type. Then pull it out and copy the type across – faster than guessing it.

Hooks: only where needed

hooks.tsx
// Usually the inferred type is enough:
const [count, setCount] = useState(0);        // number
const [name, setName] = useState("");         // string

// Needed as soon as the starting value doesn't cover everything:
const [user, setUser] = useState<User | null>(null);
const [items, setItems] = useState<string[]>([]);

// For a ref pointing at the page:
const inputRef = useRef<HTMLInputElement>(null);
Pitfall `useState([])` goes wrong

From an empty array TypeScript cannot tell what should be inside. After that every setItems(["a"]) is an error. Spell it out: useState<string[]>([]).

Reach for it when …
  • +

    Props – that is your component's contract

  • +

    Extracted event handlers

  • +

    useState starting with an empty array or null

  • +

    Anything coming from an API or a library

Skip it when …
  • Return types of components (: JSX.Element) – inferred anyway

  • Local variables that are obvious

  • any as a stopgap

In more depth: inheriting props and flexible building blocks optional

Take native attributes instead of retyping them

Your <Button> should also accept disabled, title, aria-label and the rest? You do not have to list them all.

rebuild every prop by hand
type Props = {
  label: string;
  onClick: () => void;
  disabled?: boolean;
  type?: "button" | "submit";
  className?: string;
  // … and the other 40?
};
inherit from the real element
import type { ComponentProps } from "react";

type Props = ComponentProps<"button"> & {
  label: string;
};

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

ComponentProps<"button"> gives you every attribute a real <button> accepts. It works for your own components too: ComponentProps<typeof Card>.

One component for any kind of data

List.tsx
type ListProps<T> = {
  items: T[];
  show: (item: T) => ReactNode;
  keyOf: (item: T) => string;
};

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

// T is determined automatically at the call site:
<List
  items={users}             // T = User
  keyOf={(u) => u.id}       // u is User
  show={(u) => u.name}      // u is User
/>

Does it stick?

4 questions on this lesson. Wrong answers show up in your stats.

Now write it yourself

Build a type-safe component

Finish Badge: the text arrives as children, tone colours it. On top of that it should pass ordinary <span> attributes such as title through.

  • <Badge>New</Badge> shows the text New
  • tone="danger" sets data-tone="danger", the default is neutral
  • A title passed in ends up on the <span>
import type { ComponentProps } from "react";

// TODO: type the props (children + tone + ordinary span attributes)
export function Badge(props: any) {
  return <span>???</span>;
}

export default function App() {
  return (
    <div style={{ fontFamily: "system-ui", padding: 16, display: "flex", gap: 8 }}>
      <Badge>New</Badge>
      <Badge tone="danger" title="Careful">Error</Badge>
    </div>
  );
}

Hirefullstack

Need React firepower on your team?

We have been building React and Next.js applications for clients across Germany for years – as a single expert, as reinforcement for an existing team, or as a complete Scrum team.

Talk about your project →