At first glance, the benefits of using TypeScript may not be immediately apparent. After all, JavaScript is a powerful and flexible language that can be used to create complex applications with relative ease. Additionally, the untyped nature of JavaScript can make it feel faster to develop in, as developers can quickly iterate and experiment with different ideas.

However, the benefits of TypeScript become more apparent as a project grows in size and complexity. In this article, we’ll not only explore some of the key benefits of using TypeScript in React, but also delve into how to use it effectively in your own projects.

This article assumes the reader is already familiar with typescript but hasn’t used it in a React context before. It’ll try to highlight the most common usecases used in React applications.

The benefits of Typescript

TypeScript is a language that adds optional static typing to JavaScript, providing us with a number of benefits that can enhance the development process and improve code quality. One of the most significant advantages of TypeScript is its ability to detect many common errors at compile-time, leading to more robust and reliable code with fewer runtime errors.

Another advantage of TypeScript is its better support for large codebases, making it easier to refactor and modify the code as needed. The language also offers improved collaboration between team members, thanks to its type annotations that make it easier to understand code and its input requirements.

Finally, TypeScript provides us with a better development experience, offering features like better code completion and error messages that can help us write code more quickly and with fewer mistakes. This can lead to a more enjoyable and productive development experience.

How much typing is needed

The amount of typing required when using TypeScript can vary depending on the type of project you’re working on. For instance, if you’re building a library or a package that other developers will use, you may need to add more types to provide clear interfaces. On the other hand, if you’re building an application, you may be able to get away with fewer types.

Despite the variability in typing requirements, TypeScript’s type inference capabilities can make it easy to enjoy the benefits of static typing without having to manually add types to every line of code. It can automatically determine the types of variables, functions, and other constructs based on their usage, reducing the need for manual typing.

That being said, adding just a few types in the right places can greatly enhance the benefits of using TypeScript in React. By carefully selecting where to add types, we can get maximum benefits with minimal complexity.

How to type a component

Prop Typing

React components are essentially functions, so we can declare an interface for the component props.

interface Props {
    name: string
}

const User = ({ name }: Props) => {
    return (
        <div>
            <h2>{name}</h2>
        </div>
    )
}

In most cases, there’s no need to define the return type because it will be automatically inferred by TypeScript. However, we can choose to annotate the return type if we want to enforce certain constraints. For example, we can annotate the return type to ensure that the component does not return null.

interface Props {
    name: string
}

const User = ({ name }: Props): ReactElement => {
    return (
        <div>
            <h2>{name}</h2>
        </div>
    )
}

Note on React.FunctionalComponent

React also offers a React.FunctionalComponent (React.FC for short) generic which can be used to type components instead. It provides extra typechecing for for displayName, propTypes, and defaultProps. Previously React.FC was not recommended as it implicitly included types for the children prop as well, even though a component might not be accepting children.

After React 18 the children type was removed from React.FC so I believe it’s a matter of preference at this point.

interface Props {
    name: string
}

const User: React.FC<Props> = ({ name }) => {
    return (
        <div>
            <h2>{name}</h2>
        </div>
    )
}

Using native interfaces

When working with React components, we often want to retain the native HTML attributes of the element. However, it can be tedious to manually add all the attributes to the props interface.

Fortunately, we can simplify this process by using the React.ComponentProps generic.

// Props will include native button attributes like `onClick`, `type` and more
interface Props extends React.ComponentProps<"button"> {
    text: string
}

const CallToAction = ({ text, ...props }: Props) => {
    return (
       <button {...props}>{text}</button>
    )
}

React.ComponentProps also works for getting the props of other components

import Button from 'components/Button'

interface Props extends React.ComponentProps<typeof Button> {
    text: string
}

const CallToAction = ({ text, ...props }: Props) => {
    return (
       <Button {...props}>{text}</button>
    )
}

Typing React elements

It’s common to pass elements or other components as props or children. To help us handle these cases, React and JSX provide several types that we can choose from based on our needs.

React.ReactElement and JSX.Element

The React.ReactElement and JSX.Element types represent objects created by invoking React.createElement, either directly or via JSX transpilation. These objects contain a type, props, and key. JSX.Element is essentially the same as React.ReactElement, with the props and type having a type of any.

By default, a React component returns either a ReactElement or null. However, we can explicitly define the return type of a component as seen above.

We can also use React.ReactElement to type props that are expected to be elements.

interface Props {
    name: string
    icon: React.ReactElement
}

const User = ({ name, icon }: Props) => {
    return (
        <div>
            {icon}
            <h2>{name}</h2>
        </div>
    )
}

// Usage - Note how <UserIcon /> is already "invoked"
<User name='Rafael' icon={<UserIcon />} />

React.ElementType

Used when we would like to pass a “component” which the consumer will “invoke” internally.

interface Props<T> {
    name: string
    icon: React.ElementType
}

// Component names need to be Capitalized before invoking
const User = ({ name, icon: Icon }: Props) => {
    return (
        <div>
            <Icon name='person' />
            <h2>{name}</h2>
        </div>
    )
}

// Usage - Note how UserIcon is not invoked
<User name='Rafael' icon={Icon} />

To make sure that we get the correct types while working with the passed component we can provide React.ElementType with the relevant type.

import type { IconProps } from '~/components/Icon'

interface Props<T> {
    name: string
    icon: React.ElementType<IconProps>
}

// Component names need to be capitalized before invoking
const User = ({ name, icon: Icon }: Props) => {
    return (
        <div>
            // Typescript now knows what Icon is
            <Icon name='person' />
            <h2>{name}</h2>
        </div>
    )
}

// Usage - Note how UserIcon is not invoked
<User name='Rafael' icon={Icon} />

React.ReactNode

A React.ReactNode is a type that can represent an element, a React fragment, a string, a number, null, undefined, a boolean, or an array of React nodes. This type is more generic than ReactElement or JSX.Element, and is often used when we want to accept any value that can be rendered by React.

In addition, React.ReactNode is the default type for children when using the PropsWithChildren generic.

// Explicitly defining the children prop
interface Props {
    name: string
    children: React.ReactNode
}

const User = ({ name }: Props) => {
    return (
        <div>
            <h2>{name}</h2>
            {children}
        </div>
    )
}

// Using the PropsWithChildren generic
interface Props {
    name: string
}

const User = ({ name }: PropsWithChildren<Props>) => {
    return (
        <div>
            <h2>{name}</h2>
            {children}
        </div>
    )
}

Typing render props

Render props can either be passed as a normal prop or as children. In either case we can define them like any other function. They can take params and act as a way to share state between composed components.

interface Props {
    name: string
    renderIcon: () => ReactNode
    children: (name: string) => ReactNode
}

const User = ({ name, icon }: Props) => {
    return (
        <div>
            {renderIcon()}
            <h2>{name}</h2>
            {children(name)}
        </div>
    )
}

Working with Hooks

Going into every and each hook will make this article extremely long so we’ll focus on the main usecases.

useState

State type will be infered if useState is initialized by some initial value

// `progress` will have the type `number`
const [progress, updateProgress] = useState(10)

// `user` will have the type `{
//    name: string
//    age: number
// }
const [user, updateUser] = useState({
    name: 'Martin',
    age: 1
})

In cases where we would not pass initial values then we can let useState know what the type should be

type Project = {
    name: string
    budget: number
    status: 'planned' | 'ongoing' | 'finished'
}

// `project` type is `Project | undefined`
const [project, setProject] = useState<Project>()

// `project` type is `Project | null`
const [project, setProject] = useState<Project | null>(null)

useRef

There are generally two usecases for useRef. It’s either assigned a reference to an HTML element or holding mutable values.

When holding a value then it behaves just like useState. Types will be infered from initial values if not explicitly provided.

When holding a reference to an element it needs to be initialized with null first.

const Tooltip = () => {
    const tooltipContainer = useRef(null)

    return (
        <div ref={tooltipContainer}>
           ...
        </div>
    )
}

However, just passing null isn’t enought to let the ref know which element type it is refereing to. To fix this we need to pass the element type manually to useRef.

const Tooltip = () => {
    const tooltipContainer = useRef<HTMLDivElement>(null)

    // Typescript is now aware of all the properties that
    // exist on a div element and will be able to provide us
    // with intellisense autocomplete
    tooltipContainer.current?.classList.add('container');

    return (
        <div ref={tooltipContainer}>
           ...
        </div>
    )
}

Typing events

Events are wrapped in what is called a “Synthetic Event” by React in order to have a consistent API across different browsers.

The FormEvent is a general event which is triggered whenever a form or one of its elements gets/loses focus, value is changed, or form is submitted.

const Application = () => {
    const handleSubmit = (submitEvent: FormEvent<HTMLFormElement>) => { ... }
    const handleChange = (changeEvent: FormEvent<HTMLInputElement>) => { ... }
    const handleKeyUp = (keyupEvent: FormEvent<HTMLTextAreaElement>) => { ... }

    return (
        <form onSubmit={handleSubmit}>
            <input name="name" onChange={handleChange} />
            <textarea name="bio" onKeyUp={handleKeyUp} />
        </form>
    )
}

React also provides us with specific types for different events like MouseEvent, ChangeEvent, and KeyboardEvent.

const Application = () => {
    // Used directly on a form
    const handleSubmit = (submitEvent: FormEvent) => { ... }

    // Can be used on <input>, <select>, and <textarea>
    const handleChange = (changeEvent: ChangeEvent) => { ... }

    // Works also for keydown events
    const handleKeyUp = (keyupEvent: KeyboardEvent) => { ... }

    // Works for all mouse events like onClick, mouseEnter, mouseExit ...etc
    const handleMouseEnter = (mouseEnterEvent: MouseEvent) => { ... }


    return (
        <form onSubmit={handleSubmit}>
            <input name="name" onChange={handleChange} />
            <textarea name="bio" onKeyUp={handleKeyUp} />
            <button type="submit" onMouseEnter={handleMouseEnter}>Submit</button>
        </form>
    )
}

Similar to FormEvent, ChangeEvent and MouseEvent are also generics which can be restricted if needed.

const Application = () => {
    const handleSubmit = (submitEvent: FormEvent) => { ... }
    const handleChange = (changeEvent: ChangeEvent<HTMLInputElement>) => { ... }
    const handleKeyUp = (keyupEvent: KeyboardEvent) => { ... }
    const handleMouseEnter = (mouseEnterEvent: MouseEvent<HTMLButtonElement>) => { ... }


    return (
        <form onSubmit={handleSubmit}>
            <input name="name" onChange={handleChange} />
            <textarea name="bio" onKeyUp={handleKeyUp} />
            <button type="submit" onMouseEnter={handleMouseEnter}>Submit</button>
        </form>
    )
}

Typing event handlers

In some cases we might need to type the event handler itself for example when its being passed as a prop.

interface FormProps {
    onSubmit: FormEventHandler
    onChange: ChangeEventHandler
    onKeyUp: KeyboardEventHandler
    onMouseEnter: MouseEventHandler
}

const Form = (props: FormProps) => {
    ...
}

const Application = () => {
    const handleSubmit: FormEventHandler = (submitEvent) => { ... }
    const handleChange: ChangeEventHandler = (changeEvent) => { ... }
    const handleKeyUp: KeyboardEventHandler = (keyupEvent) => { ... }
    const handleMouseEnter: MouseEventHandler = (mouseEnterEvent) => { ... }


    return (
        <Form
            onSubmit={handleSubmit}
            onChange={handleChange}
            onKeyUp={handleKeyUp}
            onMouseEnter={handleMouseEnter}
        >
    )
}

Event handler types are can also be restricted similar to event types as shown above

const handleChange: ChangeEventHandler<HTMLInputElement> = (changeEvent) => { ... }

Making sense out of TS errors

Typescript errors can be confusing! But luckily there is a VSCode extenstion that which can help a little by “translating” error to plain english!

Types vs interfaces

Types and interfaces are similar but have different features which we might not really need in the context of React applications so it doesn’t really matter! As a rule of thumb we may use types unless an interface is specifically needed.

Want to know more about how we can work together and launch a successful digital energy service?