Clean React with TypeScript

15min • 13 September 2024
100% human-written

Up until last year, my only experience with TypeScript was people on Twitter either arguing which overly complex solution is the best or hating about how slow the compiler is. I had been using ReasonML and ReScript for almost 6 years (thanks Sean (new tab)), and was more than happy with that. Yet, TypeScript gained in popularity and eventually my previous team at Carla also wanted to use it for our React application.

So here I was, frightened of all the wizardry I had seen on Twitter and biased towards the beauty of a functional programming language with a proper type system. I wanted to bridge all of the good stuff over and rather explore different design pattern than opting for overly complex solutions.

Today, I use TypeScript on a daily basis and actually like it quite a bit, even though I still regularly break the compiler. One thing that took me quite some time though, was figuring out the best way to add TypeScript to all the different React patterns and features. I've seen it done in so many ways ending up confused by all the different types the library itself provides.

To help you avoid that struggle and/or to improve your existing React codebase, this article explores different use cases and the - in my opinion - most elegant solutions.

The article is into four main sections: components, state management, refs and events. Feel free to jump around however you want.

Components

First of all, let's talk about components.
They're the heart of every React application and probably the thing we write most often. Ever since the introduction of hooks, most components are plain JavaScript functions that take a set of props and return some markup - usually in the form of JSX.

Given that, typing them is actually straight forward. The only real constraint is that it always takes a single argument - an object of properties to be precise.

Basic Props

Let's start with some primitive props first. In this example, we have a component that takes a title and a description. Both are strings and description is optional.

type Props = {
  title: string
  description?: string
}

function ProductTile({ title, description }: Props) {
  return (
    <div>
      <div>{title}</div>
      {description && <div>{description</div>}
    </div>
  )
}

Children

So far, so good. Now a common use case, is to pass children to a component in order to render nested components. You might be inclined to add it to your type, but React actually provides a special type that can help reduce redundancy.

It's called PropsWithChildren and it's a generic type that takes your existing props type and adds the children for you.

import { ReactNode, PropsWithChildren } from 'react'

type Props = {
  title: string
}

function ProductTile({ title, children }: PropsWithChildren<Props>) {
  return (
    <div>
      <div>{title}</div>
      {children}
    </div>
  )
}

Tip: The argument is optional. If your component only takes children, you can just pass PropsWithChildren on its own.

Piping Props

Another common thing to do, is piping props to inner components. For example, we might have a component that itself renders our ProductTile component, but also accepts additional props for customisation:

import ProductTile from './ProductTile'

type Props = {
  color: string
  title: string
}

function ProminentProductTile({ color, title }: Props) {
  return (
    <div style={{ background: color }}>
      <ProductTile title={title} />
    </div>
  )
}

While this is totally fine for primitive types such as strings and numbers, it can become quite cumbersome to repeat those types if you're dealing with complex records or functions - especially if you have to pass a value through multiple components.

Remember the DRY rule? Instead of repeating the same types over and over, we can leverage another generic provided by React which is ComponentProps.
It takes the type of a component which we can get by using typeof and returns its props type. We can then use indexed access to get a specific value.

import { ComponentProps } from 'react'

import ProductTile from './ProductTile'

type ProductTileProps = ComponentProps<typeof ProductTile>
type Props = {
  color: string
  title: ProductTileProps['title']
}

You could argue that you can achieve the same by simply exporting the type in ProductTile. And you're right, that works too, but it's also more prone to errors if you change your types and/or extend them at some point.

Spreading Props

Similar to piping props, sometimes we want to spread all extra props to some underlying component. Imagine the ProminentProductTile should pipe both title and description.

Instead of specifying each property one by one, we can simply extend the type by leveraging ComponentProps once again.

import { ComponentProps } from 'react'

import ProductTile from './ProductTile'

type Props = {
  color: string
} & ComponentProps<typeof ProductTile>

function ProminentProductTile({ color, ...props }: Props) {
  return (
    <div style={{ background: color }}>
      <ProductTile {...props} />
    </div>
  )
}

Tip: If you only want to spread a subset of those props, you can use the TypeScript built-in Pick to do so. For example:

type ProductTileProps = ComponentProps<typeof ProductTile>

type Props = {
  color: string
} & Pick<ProductTileProps, 'title' | 'description'>

HTML Elements

The same pattern also applies to HTML primitives. If we want to pass down all remaining props to e.g. a button element, we can simply use ComponentProps<"button">.

Note: There's also React.JSX.IntrinsicElements["button"] which refers to the identical type, but I'd recommend you to only use one for consistency and readability and I generally prefer the first as it's easier to type.

Extra: For certain edge cases where we only want to pass down valid HTML attributes - excluding React specific props such as ref and key - we can also use specific attributes types e.g. React.ButtonHTMLAttributes<HTMLButtonElement>. However, I've not yet encountered a use case for those and as it's once again more to type, so I still prefer the shorter ComponentProps<"button">.

Passing JSX

Now that we covered simple props, let's look at some more advanced use cases.
Sometimes, passing primitive props is not enough and we want to pass raw JSX to render nested content. While dependency injection generally makes your components less predictable, it's a great way to customise generic components. This is especially useful, when we're already passing children, but need to inject additional markup at a specific position.

Luckily, React provides us with another useful type called ReactNode.
Imagine a layout component that wraps your whole application and also receives the sidebar to render a dedicated navigation in a predefined slot:

import { PropsWithChildren, ReactNode } from 'react'

type Props = {
  title: string
  sidebar: ReactNode
}

function Layout({ title, children, sidebar }: PropsWithChildren<Props>) {
  return (
    <>
      <div className="sidebar">{sidebar}</div>
      <main className="content">
        <h1>{title}</h1>
        {children}
      </main>
    </>
  )
}

Now we can pass whatever we want:

const sidebar = (
  <div>
    <a data-selected href="/shoes">
      Shoes
    </a>
    <a href="/watches">Watches</a>
    <a href="/shirts">Shirts</a>
  </div>
)

const App = (
  <Layout title="Running shoes" sidebar={sidebar}>
    {/* Page content */}
  </Layout>
)

Bonus: PropsWithChildren is actually using ReactNode under the hood. A custom implementation would look something like this:

type PropsWithChildren<Props = {}> = { children: ReactNode } & Props

Passing Components

Passing JSX is great when you want maximum flexibility. But what if you want to restrict rendering to certain components or pass some props to that subtree as well without moving all the logic into a single component bloating it even more?

What became popular as the render-prop pattern (new tab) is exactly that: dependency injection with constraints.
Once again, React has a neat type to help us with that called ComponentType.

import { ComponentType } from 'react'

type ProductTileProps = {
  title: string
  description?: string
}

type Props = {
  render: ComponentType<ProductTileProps>
}

function ProductTile({ render }: Props) {
  // some logic to compute props

  return render(props)
}

Extra: This is also quite nice for third-party components via d.ts files:

declare module 'some-lib' {
  type Props = {
    title: string
    description?: string
  }

  export const ProductTile: ComponentType<Props>
}

Specific Components

If we only allow a specific component, the code is even simpler as we can use the built-in typeof operator (new tab) to do so.

import { ComponentProps } from 'react'

import Icon from './Icon'

type Props = {
  icon?: typeof Icon
} & ComponentProps<'button'>

function Button({ icon: Icon, children, ...props }: Props) {
  return (
    <button {...props}>
      {icon && <Icon size={24} />}
      {children}
    </button>
  )
}

Inferring Props

This is a common pattern used for generic (layout) components. It's often used in component libraries and can be a great foundation for creating flexible layouts in React.

Usually you pass an as or component prop and the component becomes that, including its props and types. For example:

const App = (
  <>
    {/* this throws as div doesn't have a value prop */}
    <Box as="div" value="foo" />
    {/* this works however */}
    <Box as="input" value="foo" />
  </>
)

The best part is that is also works with custom components, giving us endless flexibility.
Alright, but how do we achieve this? Instead of explaining what Matt Pocock (new tab) from Total TypeScript (new tab) already did with a great article, I'm just going to link it here: Passing Any Component as a Prop and Inferring Its Props (new tab).

State Management

Managing state is probably the most common use case for hooks and React provides both a useState (new tab) hook for simple cases as well the useReducer (new tab) hook for more complex scenarios.

useState

By default, useState automatically infers the type of the value based on the initial value that is passed. That means, if we have a simple counter state with 0 as the default state, we don't have to type anything and it just works:

import { useState } from 'react'

function Counter() {
  const [counter, setCounter] = useState(0)

  // component logic
}

However, if we don't pass a default value, work with nullable values or the default value does not represent the full type e.g. when using objects with optional keys, we have to provide a type for it to work properly.
Luckily, React allows us to pass an optional type to tell it what values to accept.

import { useState } from 'react'

type AuthState = {
  authenticated: boolean
  user?: {
    firstname: string
    lastname: string
  }
}

type Todo = {
  id: string
  title: string
  completed: boolean
}

function TodoList() {
  // without passing AuthState, we wouldn't be able to set the user
  const [authState, setAuthState] = useState<AuthState>({
    authenticated: false,
  })
  // data is loaded asynchronously and thus null on first render
  const [data, setData] = useState<Array<Todo> | null>(null)

  // component logic
}

useReducer

When working with reducers there is no type inference, because we have to actively type the reducer anyway. Its first argument (state) will be used to infer the type and also to type-check the initial state that is being passed to the hook.

import { useReducer } from 'react'

type State = number
type Action =
  | { type: 'increment' }
  | { type: 'decrement' }
  | { type: 'reset'; payload: number }

function reducer(state: State, action: Action) {
  switch (action.type) {
    case 'increment':
      return state + 1
    case 'decrement':
      return state - 1
    case 'reset':
      return action.payload
    default:
      return state
  }
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, 0)

  // component logic
}

After all, it's all just pure TypeScript and no React specifics here.

Refs

Refs, short for reference, provide a way to directly access and interact with DOM elements or React component.

While I would recommend you only use refs when necessary and rely on components and state whenever possible, they can be quite useful for focus management or reading/manipulating a node directly.

Note: Refs can be used for all kind of things and values, but we will focus only on HTML elements in this article.

Using Refs

React provides a convenient hook useRef (new tab) that creates a ref inside a functional component.

In order to get proper types and no compiler errors, we have to provide a type. Since the element is not yet mounted on first render, we also pass null as a default.

import { useRef, ComponentProps } from 'react'

function Button(props: ComponentProps<'button'>) {
  const ref = useRef<HTMLButtonElement | null>(null)

  return <button ref={ref} {...props} />
}

Forwarding Refs

Note: Forwarding refs will soon be a thing of the past!
Once React 19 hits, refs will be forwarded automatically, with no need to wrap components in forwardRef anymore.

Forwarding refs (new tab) is necessary whenever we want to pass a ref to a custom component. Let's take the button with the icon from above. Something I've seen a lot in code bases, is typing the props and the ref individually:

import { forwardRef, ComponentProps, ForwardedRef } from 'react'

import Icon from './Icon'

type Props = {
  icon?: typeof Icon
} & ComponentProps<'button'>

const Button = forwardRef(
  (
    { icon, children, ...props }: Props,
    ref: ForwardedRef<HTMLButtonElement>
  ) => {
    return (
      <button ref={ref} {...props}>
        {icon && <Icon size={24} />}
        {children}
      </button>
    )
  }
)

I always find this code quite hard to read as there's a lot going on with many parenthesis and brackets. But, it doesn't have to be that way! forwardRef accepts two optional types to initialise it's function button:

const Button = forwardRef<Props, HTMLButtonElement>(
  ({ icon, children, ...props }, ref) => {
    return (
      <button ref={ref} {...props}>
        {icon && <Icon size={24} />}
        {children}
      </button>
    )
  }
)

This results not only in more readable code, but also reduces some of the boilerplate, as we don't need ForwardedRef at all.

Passing Refs

Sometimes, we don't want to forward a ref directly, but rather pass a ref to another element. Imagine, you have a popover component and want to pass which element it should anchor to. We can do that by passing a ref. To do so, React provides a RefObject type.

import { RefObject } from 'react'

type Props = {
  anchor: RefObject<HTMLElement>
}

function Popover({ anchor }: Props) {
  // position component according to the anchor ref
}

Tip: Generally speaking, I recommend using more universal types such as HTMLElement unless you need specific attributes or want to limit the API e.g. when building a component library. Why? Because HTMLDivElement also satisfies HTMLElement, but HTMLSpanElement doesn't satisfy HTMLDivElement.

Events

Last but not least, let's talk about events and event listeners.
We usually encounter them in two ways:

  1. Passing event listeners to components directly via props e.g. onClick, onBlur
  2. Adding event listeners in effects when targeting different elements, e.g. scroll mouseup

However, before we dive into both use cases, we should first talk about the different event types, where they come from and what the differences are.

MouseEvent vs. React.MouseEvent

At the beginning, this really confused me. There's both a global MouseEvent as well as MouseEvent exported by React. They're both used for event listeners are I've seen them mixed up more than once.

To put it simply, the difference is that the global built-in MouseEvent refers to native JavaScript events while the other one is specifically tailored for React's Synthetic Event system (new tab).

They have a lot in common and can often be used interchangeable, but the React version also accounts for browser incompatibilities and includes some React specific properties such as persist.

Besides mouse events, there are all sorts of events for every kind of event listeners.

Note: In older versions prior to React 17, synthetic events would also be pooled, which means that the event object is reused for performance reasons.

Passing Event Listeners

The first use case is passing event listeners to components. When doing so, we're using React's own event system and thus should use the special events provided by React.

The most common example is passing a onClick handler to a clickable component.

Note: Once again, we should most likely be using ComponentProps<"button">["onClick"] here, but just for the sake of exploring the types, we will write the type ourself.

import { MouseEventHandler } from 'react'

type Props = {
  onClick: MouseEventHandler<HTMLButtonElement>
}

function Button({ onClick }: Props) {
  return <button onClick={onClick} />
}

Another common thing is to manipulate event listeners, e.g. when submitting a form. In that case, we have to type the event to be get the proper type.

// using named imports would overwrite the native events here
// it's more safe to do it this way, in case we want to use both in a single file
import * as React from 'react'

function Login() {
  return (
    <form
      onSubmit={(e: React.FormEvent<HTMLFormElement>) => {
        e.preventDefault()

        // login logic
      }}>
      {/* Login form */}
    </form>
  )
}

Attaching Event Listeners

Finally, the last thing to discuss is attaching event listeners. This is primarily useful when dealing with events that can't be passed to the component directly such as a global scroll listener.

Typically, we register the event listener in a useEffect.
Since those are native event listeners and have nothing to do with React, we get the native events and should use the native types as well.

import { useEffect } from 'react'

function Navigation() {
  useEffect(() => {
    const onScroll = (e: Event) => {
      // do something when scrolling
    }

    document.addEventListener('scroll', onScroll)
    return () => document.removeEventListener('scroll', onScroll)
  }, [])

  // component logic
}

Conclusion

Alright, that ended up being quite a long article.
As we've seen, there are a ton of features, patterns and use cases in modern React applications. Combining it with TypeScript can be confusing and not always straight-forward, but I hope this article sheds some light and clarity on all of the different built-in types and how to use them properly.
If you follow most of the best practices shared in this article, you should end up having a pretty clean codebase where TypeScript seamlessly integrates with your React code.

To The Top
Picture of Robin Weser

Thanks for reading!

Comments or questions? Reach out to me via Twitter (new tab) or Mail (new tab).
Subscribe to my newsletter to get notified of new articles!

Enjoyed the article?