Consejos esenciales de TypeScript para desarrolladores de React y Nextjs

Published on
Consejos esenciales de TypeScript para desarrolladores de React y Nextjs

Tipado de una aplicación React/Next.js

TypeScript nos ayuda a detectar errores en tiempo de compilación, haciendo nuestro código más robusto. Comencemos con la guia.

En caso de que quieras ver solo los ejemplos puedes visitar el repositorio: React TypescriptExternal Link

Variables

Lo más común que haremos en React es declarar variables. TypeScript lanza un error cuando una variable se reasigna con un tipo diferente. Puede inferir los tipos de las variables a partir de los valores iniciales, reduciendo la necesidad de escribir los tipos manualmente.

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

Funciones

Los parámetros de las funciones deben tener un tipo especificado; de lo contrario, por defecto serán 'any'. Si intentamos enviar un tipo incorrecto en alguno de los argumentos, TypeScript lanzará una advertencia.

Podemos especificar el tipo de retorno de una función, aunque no siempre es necesario.

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

Componentes de React

Los componentes de React son funciones que comienzan con una letra mayúscula y reciben parámetros que llamamos props.

Los props son objetos, y para tiparlos en un componente de React, podemos usar llaves {}.

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

Podemos añadir un signo de interrogación para hacer que las propiedades sean opcionales: 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

Cuando usamos o importamos un componente y no conocemos sus propiedades, podemos presionar ctrl + space en editores como Visual Studio para ver todas las propiedades posibles del componente.

TypeScript admite métodos integrados como textColor.toUpperCase() para cadenas de texto y lanza errores para operaciones no válidas.

La desestructuración inmediata puede ser difícil de leer si tiene muchas props. Podemos extraerlas en un "Type" separado para que sea más claro.

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

El tipo de retorno de los componentes típicos de React es: JSX.Element. No es necesario que lo tipemos manualmente.

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

Un tipo puede ser más específico usando un Tipo de Unión, por ejemplo: type Color = 'white' | 'black' | 'red';.

También podemos tipar arreglos de esta manera: margin: number[]; y convertirlo en un arreglo de ese tipo.

Podemos usar tipos en un arreglo para obtener una tupla, que es simplemente un arreglo más específico: padding: [number, number, number, number];. Solo debemos especificar cuántos valores debe tener, su orden y qué tipos.

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

Ahora hablemos de CSS y de cómo podemos estilizar un componente con muchas propiedades diferentes. Podemos hacerlo con un objeto de estilo, como este:

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

¿Qué pasa si tenemos muchas propiedades CSS que necesitamos especificar? Sería problemático hacerlo para todas de manera individual. Podemos obtener algo de ayuda de React y usamos un tipo que React proporciona.

En lugar de especificar nuestro propio objeto, podemos usar uno de los tipos que obtenemos de 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

Pasar Funciones como Props

A veces necesitamos pasar una función, tal vez queremos hacer algo cuando hacemos clic en un botón.

Solo necesitamos crear una función manejadora (puede tener cualquier nombre) y pasarla como una prop.

En el componente hijo, necesitamos aceptarla y usar una sintaxis de función de flecha (o arrow function) en la definición del tipo.

Especificamos los parámetros, por ejemplo: smallCalculation: (numberToAdd: number) => number; y lo que la función devuelve. Si la función no devuelve nada, simplemente usamos 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

Tipado de hijos (children)

Hay algunos casos en los que nos gustaría hacer que el contenido sea más dinámico. Así que, en lugar de codificar cosas como texto u otros componentes, podemos aceptar la prop children dentro de nuestros componentes personalizados.

React.ReactNode es un tipo general, lo que significa que permite: booleanos (que se ignoran), null o undefined (que se ignoran), números, cadenas de texto, un elemento de React (resultado de JSX) y un arreglo de cualquiera de los anteriores, posiblemente anidado. "

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

Si queremos hacer que nuestros componentes sean más específicos/restrictivos y tal vez permitir solo que se pase JSX, podemos hacerlo así:

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

Tipado de useState

Cuando tenemos un estado en un componente padre pero necesitamos que el componente hijo lo modifique, podemos tipar el hook useState de esta manera en el componente hijo:

'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

¿Cómo podemos saber que debería ser tipado así? Bueno, Visual Studio y TypeScript nos ayudan. Al pasar el cursor sobre la función setter setCount, podemos ver el tipo y simplemente copiarlo.

Props de Componentes y Ref

¿Qué pasa si queremos envolver un elemento nativo con un componente personalizado? Necesitamos permitir que todos los atributos predeterminados se pasen como props.

React nos proporciona algunos tipos auxiliares como ComponentProps<ElementType>, especificamos el elemento dentro de los < >.

Pero hay un problema, React nos dará una descripción al pasar el cursor sobre ComponentProps: Prefer ComponentPropsWithRef<ElementType> if ref is forwarded and ComponentPropsWithoutRef<ElementType> when ref is not forwarded.

Así que, si queremos reenviar un ref a un componente hijo, deberíamos usar el tipo 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

El ejemplo anterior también utiliza un nuevo concepto de intersección con el uso del & y el tipo adicional que queremos agregar.

Y si no necesitamos refs, lo hacemos explícito:

'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 y Rest

¿Deberíamos especificar todas las propiedades una por una? La respuesta es no, podemos usar el operador rest { type, autoFocus, className, ...props } y eso se convierte en un arreglo con todas las propiedades y luego simplemente las esparcimos {...props} en el elemento nativo.

'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

Intersección y Extensión

Podemos combinar diferentes tipos, tal vez tengamos un tipo base y queramos agregarle más.

Podemos hacerlo con types usando el & y con interfaces usando la palabra clave extends.

'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

Manejadores de Eventos

En muchos elementos HTML, como inputs o botones, tenemos manejadores de eventos. Podemos definir funciones que queremos que se ejecuten cuando ocurra el evento en línea o tener la función extraída.

Cuando no sabemos cuál es el tipo, podemos obtener ayuda de la versión en línea onChange={(e) => console.log(e.target.value)} y pasar el cursor sobre el parámetro para ver el tipo y copiarlo.

'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

Podemos dejar que TypeScript infiera el tipo cuando establecemos un valor inicial. Si queremos ser explícitos, podemos usar los corchetes angulares < >.

Es un poco diferente cuando tenemos un objeto, la mayoría de las veces el valor inicial va a ser null porque quizás podemos necesitar obtener los datos primero. En este caso, queremos especificar nuestro propio tipo de objeto y también necesitamos agregar null así: const [cat, setCat] = useState<Cat | null>(null);.

También debemos tener cuidado y usar encadenamiento opcional (optional chaining) porque si no lo hacemos y hacemos algo como esto: const name = cat.name;, obtendremos un 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 por defecto es undefined, lo que significa que no podemos asignarle nada. Puede ser tipado usando los corchetes angulares < > y comenzará a funcionar.

Cuando especificamos null como el valor inicial, le decimos a React que se encargue del useRef.

Al manipular el DOM, el valor inicial null representará un elemento HTML una vez que esté correctamente adjuntado.

TypeScript nos ayuda proporcionando un tipo más general: <Element>, un tipo menos general: <HTMLElement> o tipos más específicos como: <HTMLButtonElement>.

Además de la manipulación del DOM, también podemos especificar cualquier valor de cualquier tipo.

'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

Consejos de TypeScript

Constantes

Si tenemos constantes, podemos hacerlas readonly (solo lectura, no podemos agregar más valores) usando as const, de esa manera TypeScript proporciona un mejor autocompletado porque hace el tipo más específico.

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

Uniones Discriminadas

A veces tendremos props que cambian según diferentes circunstancias, por ejemplo: un modal con diferentes alertas o una respuesta de API con estados de éxito y error.

Podemos usar uniones discriminadas para obtener un mejor autocompletado al pasar ciertos valores y no querer los demás.

'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

¿Qué pasa si tenemos algo como un tipo general User (usuario) con un nombre y un sessionId?

Entonces podríamos necesitar un tipo Guest (invitado) que no tenga la propiedad name pero sí tenga un sessionId.

Podemos usar Omit para "omitir" el nombre del tipo.

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

Aserción de Tipo

A veces tendremos información sobre el tipo de un valor que TypeScript no puede conocer.

La palabra clave as se usa para decirle a TypeScript que nosotros sabemos mejor.

En el ejemplo, obtener algo del almacenamiento local se devuelve como una cadena de texto, pero si sabemos qué valores se almacenarán de antemano, podemos decirle a TypeScript cuáles son esos valores.

Debemos tener cuidado, ya que hacer esto elimina la comprobación de tipos de TypeScript.

'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

Importar Archivos de Tipos

Hay veces en que necesitamos compartir tipos entre muchos componentes.

Podríamos sentirnos tentados a crear un archivo llamado index.d.ts y poner todos nuestros tipos globales allí, pero esto no es una buena práctica. Estos son archivos especiales, archivos de declaración, y son útiles principalmente para tipar bibliotecas de terceros que no vienen con sus propios tipos.

En su lugar, podemos crear un archivo de tipos lib/types.ts, poner nuestros tipos allí y exportarlo. Ahora podemos importarlo en los archivos que lo necesiten. Podemos ser más explícitos al agregar la palabra clave type al importarlo así: 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