Essential TypeScript Tips for React and Nextjs Developers

Published on
Essential TypeScript Tips for React and Nextjs Developers

Typing a React/Nextjs application

TypeScript helps us catch errors at compile time, making our codebase more robust. Let's begin.

In case you want to just see the examples you can visit the repo: React TypescriptExternal Link

Variables

The most common thing we will do in React is declare variables. TypeScript throws an error when a variable is reassigned with a different type. It can infer variable types from initial values, reducing the need for manual typing.

import React from 'react';
import Link from 'next/link';

export default function Variables() {
  let randomNumber = 42;
  let url = 'https://www.google.com';
  let isTrue = true;

  return (
    <section className='space-y-4'>
      <Link href={url}>Text: {url}</Link>
      <p>Number: {randomNumber}</p>
      <p>Boolean: {isTrue + ''}</p>
    </section>
  );
}
tsx

Functions

Function parameters should be typed; otherwise, they default to 'any'. If we try sending the wrong type in any of the arguments TypeScript will throw a warning.

We can specify a function's return type, though it's not always necessary.

import React from 'react';

export default function Functions() {
  function saveNewUser(name: string, age: number): boolean {
    console.log('age:', age);
    console.log('name:', name);
    return true;
  }
  console.log(saveNewUser('John', 10));

  // console.log(saveNewUser(1, 10)); // This will throw a warning.

  return (
    <section>
      <h1>Hello World</h1>
    </section>
  );
}
tsx

React Components

React components are functions that start with a capital letter and receive parameters we call props.

Props are objects, in order for us to type props in a React component we can use curly braces {}.

function WelcomeMessage(props: { welcomeText: string }) {
  const welcomeText = props.welcomeText;
  return <p>{welcomeText}</p>;
}
tsx
function GoodbyeMessage(props: { goodbyeText: string }) {
  // We can use destructuring
  const { goodbyeText } = props;
  return <p>{goodbyeText}</p>;
}
tsx
// Or inmediatly destructure the properties you need from props
function RandomNumber({ randomMessage }: { randomMessage: number }) {
  return <p>{randomMessage}</p>;
}
tsx

We can add a question mark to make properties optional: count?: number;.

// Sending more than one prop
function MoreProps({
  textColor,
  size,
  isAvailable,
  count = 0,
}: {
  textColor: string;
  size: number;
  isAvailable: boolean;
  count?: number;
}) {
  return (
    <div>
      <p>Text Color: {textColor}</p>
      <p>Size: {size}</p>
      <p>Is Available: {isAvailable + ''}</p>
      <p>Default property: {count}</p>
    </div>
  );
}
tsx

When we use or import a component and do not know the component properties, press ctrl + space in editors like Visual Studio to view all possible component properties.

TypeScript supports built-in methods like textColor.toUpperCase() for strings and throws errors for invalid operations.

Immediately destructuring can be hard to read if it has many props. We can extract them into a separate "Type" to make it cleaner.

type ButtonProps = {
  textColor: string;
  size: number;
  isLoading: boolean;
};

function Button({ textColor, size, isLoading }: ButtonProps) {
  console.log(textColor.toUpperCase());
  // size.toUpperCase(); // This will throw an error because size is of type number

  return (
    <>
      <button
        style={{ fontSize: `${size}px` }}
        className={`${textColor} px-4 py-2 rounded bg-green-500`}>
        {isLoading ? 'Loading...' : 'Click Here'}
      </button>
    </>
  );
}
tsx

The return type of typical React components is: JSX.Element. We do not have to type it ourselves.

export default function ReactComponentsPartTwo(): JSX.Element {
  return (
    <section>
      <h1>Hello world</h1>
    </section>
  );
}
tsx

A type can be more specific with a Union Type for example: type Color = 'white' | 'black' | 'red';.

We can also type arrays like this: margin: number[]; and make it an array of that type.

We can also use types in an arrays and get a tuple which is just a more specific array: padding: [number, number, number, number];. We specify how many values it should have, the order and what types.

import React from 'react';

type Color = 'white' | 'black' | 'red';

type ButtonProps = {
  textColor: Color;
  backgroundColor: Color;
  size: number;
  isLoading: boolean;
  margin: number[];
  padding: [number, number, number, number];
  data: { id: string }[];
};

function Button({
  textColor,
  size,
  isLoading,
  backgroundColor,
  margin,
  padding,
}: ButtonProps) {
  return (
    <button
      style={{
        fontSize: `${size}px`,
        backgroundColor: backgroundColor,
        color: textColor,
        margin: `${margin[0]}px ${margin[1]}px ${margin[2]}px ${margin[3]}px`,
        padding: `${padding[0]}px ${padding[1]}px ${padding[2]}px ${padding[3]}px`,
      }}
      className='rounded border-solid border-indigo-600 border-2'>
      {isLoading ? 'Loading' : 'Click Here'}
    </button>
  );
}

export default function ReactComponentsPartTwo(): JSX.Element {
  return (
    <section>
      <Button
        textColor='white'
        backgroundColor='black'
        isLoading={false}
        size={16}
        margin={[2, 2, 2, 2]}
        padding={[4, 8, 4, 8]}
        data={[{ id: '123' }, { id: '124' }]}
      />
    </section>
  );
}
tsx

CSS

Now let's talk about CSS and how we can style a component with many different properties. We can do that with just a style object like this:

import React from 'react';

type Color = 'white' | 'black' | 'red';

type ButtonProps = {
  style: {
    textColor: Color;
    backgroundColor: Color;
  };
};

function Button({ style }: ButtonProps) {
  return <button style={style}>Click here</button>;
}

export default function ReactComponentsCss(): JSX.Element {
  return (
    <section>
      <Button
        style={{
          textColor: 'white',
          backgroundColor: 'black',
        }}
      />
    </section>
  );
}
tsx

What if we had a lot of CSS properties we needed to specify? It will be troublesome to do it all individually. We get some help from React and use a type React provides.

Instead of specifying our own object, we can can use one of the types we get from React:

import React from 'react';

type ButtonProps = {
  style: React.CSSProperties;
};

function Button({ style }: ButtonProps) {
  return <button style={style}>Click here</button>;
}

export default function ReactComponentsCss(): JSX.Element {
  return (
    <section>
      <Button
        style={{
          color: 'white',
          backgroundColor: 'green',
          padding: '4px 8px',
          borderRadius: '4px',
        }}
      />
    </section>
  );
}
tsx

Passing Functions as Props

Sometimes we need to pass a function, maybe we want to do something when we click a button.

We just need to create a handler function (it can be any name) and pass it as a prop.

In the child component we need to accept it and use an arrow function syntax in the type definition.

We specify the params for example: smallCalculation: (numberToAdd: number) => number; and what the function returns. If the function doesn't return anything we just use void.

'use client'; // used in nextjs

import React from 'react';

type ButtonProps = {
  onButtonClick: () => void;
  smallCalculation: (numberToAdd: number) => number;
};

function Button({ onButtonClick, smallCalculation }: ButtonProps) {
  console.log(smallCalculation(40));
  return (
    <section>
      <button
        className='px-4 py-2 rounded bg-green-500'
        onClick={onButtonClick}>
        Click me
      </button>
    </section>
  );
}

export default function FunctionsComponent() {
  const handleButtonClick = () => {
    console.log('You clicked me!');
  };

  const smallCalculation = (numberToAdd: number) => {
    return 2 + numberToAdd;
  };

  return (
    <section>
      <Button
        onButtonClick={handleButtonClick}
        smallCalculation={smallCalculation}
      />
    </section>
  );
}
tsx

Typing children

There are some cases where we would like to make the content more dynamic. So instead of hard coding things like text or other components, we can accept the children prop inside our custom components.

React.ReactNode is general type, meaning it allows: booleans (which are ignored), null or undefined (which are ignored), numbers, strings, a React element (result of JSX) and an array of any of the above, possibly a nested one.

import React from 'react';

type TitleProps = {
  children: React.ReactNode;
};

function Title({ children }: TitleProps) {
  return <h1 className='font-bold text-4xl'>{children}</h1>;
}

export default function TypingChildren() {
  return (
    <section>
      <Title>Awesome title</Title>
    </section>
  );
}
tsx

If we want to make our components more specific/restrictive and maybe allow only jsx to be passed we can do it like this:

import React from 'react';

type IconProps = {
  children: JSX.Element;
};

function Icon({ children }: IconProps) {
  return <button>{children}</button>;
}

export default function TypingChildren() {
  const icon = <i>&#10003;</i>; // JSX element
  return (
    <section>
      <Icon>{icon}</Icon>
    </section>
  );
}
tsx

Typing useState

When we have state in a parent component but need the child component to change it, we can type the useState hook like this in the child component:

'use client'; // used in nextjs

import React, { useState } from 'react';

type ButtonProps = {
  setCount: React.Dispatch<React.SetStateAction<number>>;
};

const Button = ({ setCount }: ButtonProps) => {
  return (
    <button
      className='px-4 py-2 rounded bg-blue-400'
      onClick={() => setCount((prev) => prev + 1)}>
      Click me
    </button>
  );
};

export default function State() {
  const [count, setCount] = useState(0);

  return (
    <section className='space-y-4'>
      <h1>Count: {count}</h1>
      <Button setCount={setCount} />
    </section>
  );
}
tsx

How can we know it should be typed like that? Well visual and TypeScript help us. By hovering the setCount setter function we can see the type and just copy it.

Component Props and Ref

What if we wanted to wrap a native element with a custom component? We need to allow all of the default attributes to be passed as props.

React provides us with some helper types like ComponentProps<ElementType>, we specify the element inside the < >.

But there is an issue, React will give us a description when hovering ComponentProps: Prefer ComponentPropsWithRef<ElementType> if ref is forwarded and ComponentPropsWithoutRef<ElementType> when ref is not forwarded.

So if we want to forward a ref to a child component we should use the type ComponentPropsWithRef.

'use client'; // used in nextjs

import React, { ComponentPropsWithRef, useRef, RefObject } from 'react';

type ButtonWithRefProps = ComponentPropsWithRef<'button'> & {
  btnRef: RefObject<HTMLButtonElement>;
  variant?: 'primary' | 'secondary';
};

const ButtonWithRef = ({ type, autoFocus, className, btnRef, variant }: ButtonWithRefProps) => {
  return (
    <button
      type={type}
      autoFocus={autoFocus}
      className={className}
      ref={btnRef}>
      {variant === 'primary' ? 'Primary' : 'Secondary'}
    </button>
  );
};

export default function ComponentsWithAndWithoutRef() {
  const buttonRef = useRef<HTMLButtonElement>(null);

  return (
    <section className='space-x-4 space-y-4'>
      <ButtonWithRef
        autoFocus={true}
        type='submit'
        className='px-4 py-2 rounded bg-blue-400'
        btnRef={buttonRef}
        variant='primary'
      />
    </section>
  );
}
tsx

The example above also uses a new concept of intersection with the use of the & and the additional type we want to add.

And if we do not need refs we make it explicit:

'use client'; // used in nextjs

import React, { ComponentPropsWithoutRef } from 'react';

type ButtonWithoutRefProps = ComponentPropsWithoutRef<'button'>;

const ButtonWithoutRef = ({ type, autoFocus, className }: ButtonWithoutRefProps) => {
  return (
    <button
      type={type}
      autoFocus={autoFocus}
      className={className}>
      No Ref
    </button>
  );
};

export default function ComponentsWithAndWithoutRef() {
  return (
    <section className='space-x-4 space-y-4'>
      <ButtonWithoutRef
        autoFocus={false}
        type='button'
        className='px-4 py-2 rounded bg-green-400'
      />
    </section>
  );
}
tsx

Spread and Rest

Should we specify all properties one by one? The answer is no, we can use the rest operator { type, autoFocus, className, ...props } and that becomes an array with all the properties and then we can just spread them {...props} in the native element.

'use client'; // used in nextjs

import React, { ComponentPropsWithoutRef, useRef } from 'react';

type ButtonWithoutRefProps = ComponentPropsWithoutRef<'button'>;

const ButtonWithoutRef = ({
  type,
  autoFocus,
  className,
  ...props
}: ButtonWithoutRefProps) => {
  return (
    <button
      type={type}
      autoFocus={autoFocus}
      className={className}
      {...props}>
      Spread and Rest
    </button>
  );
};

export default function RestAndSpread() {
  return (
    <section className='space-x-4 space-y-4'>
      <ButtonWithoutRef
        autoFocus={false}
        type='button'
        className='px-4 py-2 rounded bg-green-400'
      />
    </section>
  );
}
tsx

Intersection and Extends

We can combine different different types, maybe we have a base type and want to add to it.

We can do it with types using the & and with interfaces using the extends keyword.

'use client'; // used in nextjs

import React from 'react';

// Using interfaces instead of Type:
// interface ButtonProps {
//   type: 'button' | 'submit' | 'reset';
//   color: 'bg-green-400' | 'bg-red-400' | 'bg-blue-400';
// }

// interface ButtonWithSize extends ButtonProps {
//   size: 'sm' | 'md' | 'lg';
// }

type ButtonProps = {
  type: 'button' | 'submit' | 'reset';
  color: 'bg-green-400' | 'bg-red-400' | 'bg-blue-400';
};

type ButtonWithSize = ButtonProps & {
  size: 'sm' | 'md' | 'lg';
};

const ButtonWithSIze = ({ color, type, size }: ButtonWithSize) => {
  const getSize = (size: string) => {
    switch (size) {
      case 'sm':
        return 'px-2 py-2';
      case 'md':
        return 'px-4 py-4';
      case 'lg':
        return 'px-6 py-6';
    }
  };

  return (
    <button
      type={type}
      className={`rounded ${color} ${getSize(size)}`}>
      click me
    </button>
  );
};

export default function InterceptAndExtends() {
  return (
    <section>
      <ButtonWithSIze
        type='button'
        color='bg-blue-400'
        size='lg'
      />
    </section>
  );
}
tsx

Event Handlers

On many html elements like inputs or buttons we have event handlers. We can define functions that we want to run when the event occurs inline or have the function extracted.

When we do not know that the type is, we can get help from the inline version onChange={(e) => console.log(e.target.value)} and hover the parameter to see the type and copy it.

'use client'; // used in nextjs

import React, { MouseEventHandler, ChangeEvent, useState, MouseEvent } from 'react';

type ComponentProps = {
  onClick: MouseEventHandler<HTMLButtonElement>;
};

const Component = ({ onClick }: ComponentProps) => {
  return <button onClick={onClick}>You clicked me</button>;
};

export default function EventHandlers() {
  const [inputValue, setInputValue] = useState('');

  const handleInputChange = (event: ChangeEvent<HTMLInputElement>) =>
    setInputValue(event.target.value);

  const handleClick = (event: MouseEvent<HTMLButtonElement>) =>
    console.log('inputValue:', inputValue, 'event:', event.target);

  return (
    <section className='space-y-4 space-x-4'>
      <input
        className='text-cyan-500'
        onChange={handleInputChange}
      />
      <button onClick={handleClick}>Click me</button>
      <br />
      <input onChange={(e) => console.log(e.target.value)} />
      <Component onClick={(e) => console.log(e)} />
    </section>
  );
}
tsx

useState

We can let TypeScript infer the type when we set an initial value. If we want to be explicit we can use the angle brackets < >.

It's a little bit different when we have an object type, most of the time the initial value is going to be null because we may need to fetch the data first. In this case we want to specify our own object type and we also need to add null like this: const [cat, setCat] = useState<Cat | null>(null);.

We also have to be careful and use optional chaining because if we don't and do something like this: const name = cat.name; we will get an error.

'use client'; // used in nextjs

import React, { useState } from 'react';

type Cat = {
  name: string;
  age: number;
};

export default function HooksUseState() {
  const [count, setCount] = useState<number>(0);
  const [text, setText] = useState<string>('Hello world');
  const [isLoading, setIsLoading] = useState<boolean>(true);

  const [cat, setCat] = useState<Cat | null>(null);

  const name = cat?.name;

  return (
    <section className='space-y-4'>
      <h1>{name}</h1>
      <button onClick={() => setCat({ name: 'Milo', age: 5 })}>Set Cat</button>
    </section>
  );
}
tsx

useRef

useRef by default is undefined, that means we can not assign it to anything. It can be typed using the angle brackets < > and it will start working.

When we specify null as the initial value we tell React to take care of the useRef.

When manipulating the DOM, the null initial value will represent a html element once it is properly attached.

Typescript helps us by providing a more general type: <Element>, a less general type: <HTMLElement> or more specific types like: <HTMLButtonElement>.

Besides DOM manipulating we can also specify any value of any type.

'use client'; // used in nextjs

import React, { useRef } from 'react';

export default function HooksUseRef() {
  const ref = useRef<Element>(null);
  const ref2 = useRef<HTMLElement>(null);
  const buttonRef = useRef<HTMLButtonElement>(null);

  const numberRef = useRef<number>();
  console.log('numberRef:', numberRef?.current);

  const stringRef = useRef<string>('Hello World');
  console.log('stringRef:', stringRef.current);

  return <button ref={buttonRef}>Click</button>;
}
tsx

Typescript Tips

Constants

If we have constants we can make them readonly (we can not add any more values) using as const, that way TypeScript provides better autocomplete because it makes the type more specific.

import React from 'react';

const statuses = ['success', 'error', 'loading'] as const;

export default function Constants() {
  return (
    <section>
      {statuses.map((option) => (
        <h1 key={option}>{option}</h1>
      ))}
    </section>
  );
}
tsx

Discriminated Unions

Sometimes we are going to have props that change based on different circumstances, for example: a modal with different alerts or an api response with success and error statuses.

We can use discriminated unions to get better auto complete when passing certain values and do not want the others.

'use client';

// First Example:
type ModalProps = { type: 'alert' } | { type: 'confirm'; confirmButtonText: string };

const Modal = ({ type }: ModalProps) => {
  let message = '';
  if (type === 'alert') {
    message = 'Alert Action required!';
  } else if (type === 'confirm') {
    message = 'Confirm Action';
  }

  return (
    <dialog open>
      <p>{message}</p>
      <form method='dialog'>
        <button>OK</button>
      </form>
    </dialog>
  );
};

// Second Example:
type ApiResponse<T> =
  | { status: 'success'; data: T; timestamp: Date }
  | { status: 'error'; message: string; timestamp: Date };

export default function ParentComponent() {
  let successResponse: ApiResponse<number> = {
    status: 'error',
    message: 'Something went wrong',
    timestamp: new Date(),
  };

  let errorResponse: ApiResponse<number> = {
    status: 'success',
    data: 200,
    timestamp: new Date(),
  };
  return (
    <Modal
      type='confirm'
      confirmButtonText='Confirm'></Modal>
  );
}
tsx

Omit

What if we have something like a general User type with a name and a sessionId?

We could then need a Guest type which does not have the property name but has a sessionId.

We can use Omit to "omit" the name from the type.

import React from 'react';

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

type Guest = Omit<User, 'name'>;

export default function Omit({ sessionId = '123456789' }: Guest) {
  return <section>{sessionId}</section>;
}
tsx

Type Assertion

Sometimes we will have information about the type of a value that TypeScript can't know about.

The as keyword is used to tell TypeScript we know better.

In the example getting something from the local Storage gets returned as a string, but if we know what values will be stored beforehand, we can tell TypeScript what those values are.

We have to be careful though, as doing this removes TypeScript's type checking.

'use client'; // used in nextjs

import React, { useEffect } from 'react';

type ThemeColor = 'dark' | 'white';

export default function AsAssertion() {
  const getTheme = localStorage.getItem('themeColor') as ThemeColor;

  useEffect(() => {
    const getTheme = localStorage.getItem('themeColor') as ThemeColor;

    if (!getTheme) {
      localStorage.setItem('themeColor', 'dark');
    }
  }, []);

  const handleTheme = () => {
    if (getTheme === 'dark') {
      localStorage.setItem('themeColor', 'white');
    } else {
      localStorage.setItem('themeColor', 'dark');
    }
  };

  return <button onClick={handleTheme}>{getTheme}</button>;
}
tsx

Import Type Files

There are times when we need to share types between many components.

We might de tempted to create a file named index.d.ts and put all our global types in there, but this is not a good practice. They are special files, declaration files and they are mostly helpful to type third party libraries that don't come with their own types.

Instead we can just create a types file lib/types.ts, put our types in there and export it. We can now import it in the files that need it. We can be more explicit by adding the keyword type when importing it like this: import { type Color } from '../lib/types';.

export type Color = 'bg-green-400' | 'bg-red-400' | 'bg-blue-400';
app/lib/types.ts
import React from 'react';

import { type Color } from '../lib/types';

type TextProps = {
  color: Color;
  fontSize: 'text-sm' | 'text-2xl';
  children: React.ReactNode;
};

const Text = ({ color, fontSize, children }: TextProps) => {
  return <h1 className={`${color} ${fontSize}`}>{children}</h1>;
};

export default function TypeFile() {
  return (
    <Text
      color='bg-blue-400'
      fontSize='text-2xl'>
      Type File
    </Text>
  );
}
tsx