Guía Completa: Cómo Usar TypeScript con ReactJS

  Advertencia

TypeScript es una extensión de JavaScript que agrega tipado estático, lo cual es muy útil al trabajar con React. A continuación, te mostraré cómo configurar y usar TypeScript en un proyecto de React, junto con ejemplos prácticos.

Esta guía cubre los aspectos más importantes y actualizados de usar TypeScript con React, incluyendo:

  • Configuración inicial del proyecto usando Vite
  • Tipado de componentes funcionales
  • Uso de Hooks con TypeScript
  • Implementación de Custom Hooks
  • Utilidades avanzadas de TypeScript
  • Mejores prácticas y patrones comunes

Crear un nuevo proyecto con Vite

bash
npm create vite@latest my-react-ts-app -- --template react-ts
cd my-react-ts-app
npm install

Configuración del tsconfig.json

json
{
"compilerOptions": {
  "target": "ESNext",
  "lib": ["DOM", "DOM.Iterable", "ESNext"],
  "module": "ESNext",
  "skipLibCheck": true,
  "moduleResolution": "bundler",
  "allowImportingTsExtensions": true,
  "resolveJsonModule": true,
  "isolatedModules": true,
  "noEmit": true,
  "jsx": "react-jsx",
  "strict": true,
  "noUnusedLocals": true,
  "noUnusedParameters": true,
  "noFallthroughCasesInSwitch": true,
  "baseUrl": ".",
  "paths": {
    "@/*": ["src/*"]
  }
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

Tipado de Componentes


Componente Funcional Básico

Un Componente Funcional Básico en React es una función de JavaScript que recibe props y retorna JSX, lo que permite renderizar UI sin usar clases.

typescript
import { FC } from 'react';

interface GreetingProps {
name: string;
age?: number; // Propiedad opcional
}

const Greeting: FC<GreetingProps> = ({ name, age }) => {
return (
  <div>
    <h1>Hello, {name}!</h1>
    {age && <p>You are {age} years old</p>}
  </div>
);
};

export default Greeting;

Componente con Eventos

Un Componente con Eventos en React es un componente que incluye manejadores para interactuar con el usuario, como onClick, onChange o onSubmit. Estos eventos permiten ejecutar funciones cuando el usuario realiza acciones, como hacer clic o ingresar texto.

typescript
import { FC, ChangeEvent, FormEvent } from 'react';

interface LoginFormProps {
onSubmit: (username: string, password: string) => void;
}

const LoginForm: FC<LoginFormProps> = ({ onSubmit }) => {
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
  e.preventDefault();
  const formData = new FormData(e.currentTarget);
  onSubmit(
    formData.get('username') as string,
    formData.get('password') as string
  );
};

const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
  console.log(e.target.value);
};

return (
  <form onSubmit={handleSubmit}>
    <input
      name="username"
      type="text"
      onChange={handleInputChange}
    />
    <input
      name="password"
      type="password"
      onChange={handleInputChange}
    />
    <button type="submit">Login</button>
  </form>
);
};

Hooks con TypeScript


Hook useState

El hook useState en React permite agregar estado a los componentes funcionales. Se usa para declarar una variable de estado y una función para actualizar su valor, permitiendo que el componente mantenga y cambie datos reactivos entre renderizados.

typescript
import { useState } from 'react';

interface User {
id: number;
name: string;
email: string;
}

const UserProfile = () => {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(false);

const loadUser = async (id: number) => {
  setLoading(true);
  try {
    const response = await fetch(`/api/users/${id}`);
    const data = await response.json();
    setUser(data);
  } finally {
    setLoading(false);
  }
};

return (
  <div>
    {loading && <p>Loading...</p>}
    {user && (
      <div>
        <h2>{user.name}</h2>
        <p>{user.email}</p>
      </div>
    )}
  </div>
);
};

Hook useEffect

El hook useEffect en React permite ejecutar efectos secundarios en componentes funcionales, como hacer llamadas a APIs, actualizar el DOM o suscribirse a eventos. Se ejecuta después de renderizar el componente, y puede configurarse para ejecutarse en cada render, solo una vez (como componentDidMount) o cuando cambian dependencias específicas.

typescript
import { useEffect, useState } from 'react';

interface Post {
id: number;
title: string;
body: string;
}

const PostList = () => {
const [posts, setPosts] = useState<Post[]>([]);
const [error, setError] = useState<Error | null>(null);

useEffect(() => {
  const fetchPosts = async () => {
    try {
      const response = await fetch('https://api.example.com/posts');
      const data = await response.json();
      setPosts(data);
    } catch (err) {
      setError(err instanceof Error ? err : new Error('Unknown error'));
    }
  };

  fetchPosts();
}, []);

if (error) return <div>Error: {error.message}</div>;

return (
  <ul>
    {posts.map(post => (
      <li key={post.id}>{post.title}</li>
    ))}
  </ul>
);
};

Hook useReducer

El hook useReducer en React es una alternativa a useState para manejar el estado complejo en componentes funcionales. Utiliza un patrón similar a Redux, donde se define un reductor (función que recibe el estado actual y una acción) para decidir cómo actualizar el estado. Es útil para manejar múltiples valores de estado relacionados o cuando las actualizaciones de estado dependen de lógica más avanzada.

typescript
import { useReducer } from 'react';

interface TodoItem {
id: number;
text: string;
completed: boolean;
}

type TodoAction =
| { type: 'ADD'; text: string }
| { type: 'TOGGLE'; id: number }
| { type: 'DELETE'; id: number };

interface TodoState {
todos: TodoItem[];
nextId: number;
}

const todoReducer = (state: TodoState, action: TodoAction): TodoState => {
switch (action.type) {
  case 'ADD':
    return {
      ...state,
      todos: [...state.todos, { id: state.nextId, text: action.text, completed: false }],
      nextId: state.nextId + 1
    };
  case 'TOGGLE':
    return {
      ...state,
      todos: state.todos.map(todo =>
        todo.id === action.id ? { ...todo, completed: !todo.completed } : todo
      )
    };
  case 'DELETE':
    return {
      ...state,
      todos: state.todos.filter(todo => todo.id !== action.id)
    };
}
};

const TodoList = () => {
const [state, dispatch] = useReducer(todoReducer, { todos: [], nextId: 1 });

return (
  <div>
    <button onClick={() => dispatch({ type: 'ADD', text: 'New Todo' })}>
      Add Todo
    </button>
    <ul>
      {state.todos.map(todo => (
        <li key={todo.id}>
          <span
            style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}
            onClick={() => dispatch({ type: 'TOGGLE', id: todo.id })}
          >
            {todo.text}
          </span>
          <button onClick={() => dispatch({ type: 'DELETE', id: todo.id })}>
            Delete
          </button>
        </li>
      ))}
    </ul>
  </div>
);
};

Custom Hooks


Los Custom Hooks en React son funciones personalizadas que permiten reutilizar lógica de estado y efectos en varios componentes. Son como los hooks integrados (useState, useEffect, etc.), pero creados por el usuario para encapsular funcionalidad específica, facilitando un código más limpio y modular.

typescript
import { useState, useEffect } from 'react';

interface UseFetchResult<T> {
data: T | null;
loading: boolean;
error: Error | null;
}

function useFetch<T>(url: string): UseFetchResult<T> {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);

useEffect(() => {
  const fetchData = async () => {
    try {
      const response = await fetch(url);
      const json = await response.json();
      setData(json);
    } catch (err) {
      setError(err instanceof Error ? err : new Error('Failed to fetch'));
    } finally {
      setLoading(false);
    }
  };

  fetchData();
}, [url]);

return { data, loading, error };
}

// Uso del custom hook
interface Post {
id: number;
title: string;
}

const PostComponent = () => {
const { data, loading, error } = useFetch<Post[]>('/api/posts');

if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
if (!data) return null;

return (
  <ul>
    {data.map(post => (
      <li key={post.id}>{post.title}</li>
    ))}
  </ul>
);
};

Utilidades de TypeScript en React


Discriminated Unions para Props

Discriminated Unions en TypeScript son una característica que permite crear tipos que pueden ser uno de varios tipos posibles, diferenciados por un campo común. Esto es útil para definir props en componentes de React donde se necesita manejar diferentes tipos de datos de manera segura.

typescript
interface SuccessState {
status: 'success';
data: string[];
}

interface LoadingState {
status: 'loading';
}

interface ErrorState {
status: 'error';
error: string;
}

type State = SuccessState | LoadingState | ErrorState;

const StatusMessage: FC<State> = (state) => {
switch (state.status) {
  case 'loading':
    return <div>Loading...</div>;
  case 'success':
    return <div>Data: {state.data.join(', ')}</div>;
  case 'error':
    return <div>Error: {state.error}</div>;
}
};

Utility Types

Utility Types en TypeScript son tipos predefinidos que facilitan la manipulación y transformación de otros tipos. Permiten crear nuevos tipos basados en tipos existentes mediante operaciones como la omisión, la inclusión o la modificación de propiedades. Algunos de los utility types más comunes son:

  Utility Types

  • Partial<T>: Crea un tipo donde todas las propiedades de T son opcionales.
  • Required<T>: Crea un tipo donde todas las propiedades de T son requeridas.
  • Readonly<T>: Crea un tipo donde todas las propiedades de T son de solo lectura.
  • Record<K, T>: Crea un tipo que representa un objeto con claves de tipo K y valores de tipo T.
  • Pick<T, K>: Crea un tipo con un subconjunto de propiedades de T que se especifican en K.
  • Omit<T, K>: Crea un tipo que omite las propiedades de T especificadas en K.

Estos tipos son muy útiles para mantener un código más limpio y reducir la duplicación al trabajar con tipos complejos en aplicaciones TypeScript.

typescript
// Partial
interface Theme {
color: string;
backgroundColor: string;
fontSize: number;
}

const updateTheme = (theme: Theme, updates: Partial<Theme>) => {
return { ...theme, ...updates };
};

// Pick
interface User {
id: number;
name: string;
email: string;
password: string;
}

type UserPreview = Pick<User, 'id' | 'name'>;

// Omit
type PublicUser = Omit<User, 'password'>;

// Record
type ValidStatus = 'draft' | 'published' | 'archived';
const postCounts: Record<ValidStatus, number> = {
draft: 5,
published: 10,
archived: 3
};

Mejores Prácticas


Typing children prop

Typing children prop en React se refiere a la práctica de definir el tipo de la propiedad children en los componentes funcionales. En React, children representa cualquier contenido que se pasa a un componente, ya sea texto, elementos JSX, o incluso otros componentes.

typescript
import { ReactNode, FC } from 'react';

interface LayoutProps {
children: ReactNode;
sidebar?: ReactNode;
}

const Layout: FC<LayoutProps> = ({ children, sidebar }) => {
return (
  <div className="layout">
    {sidebar && <div className="sidebar">{sidebar}</div>}
    <main>{children}</main>
  </div>
);
};

Generic Components

Generic Components en React son componentes que utilizan tipos genéricos para proporcionar flexibilidad y reutilización en el código. Esto permite a los desarrolladores crear componentes que pueden funcionar con diferentes tipos de datos sin tener que duplicar el código.

typescript
interface SelectProps<T> {
items: T[];
value: T;
onChange: (value: T) => void;
getLabel: (item: T) => string;
}

function Select<T>({ items, value, onChange, getLabel }: SelectProps<T>) {
return (
  <select
    value={items.indexOf(value)}
    onChange={(e) => {
      const index = parseInt(e.target.value);
      onChange(items[index]);
    }}
  >
    {items.map((item, index) => (
      <option key={index} value={index}>
        {getLabel(item)}
      </option>
    ))}
  </select>
);
}

// Uso
interface User {
id: number;
name: string;
}

const UserSelect = () => {
const users: User[] = [
  { id: 1, name: 'John' },
  { id: 2, name: 'Jane' }
];

return (
  <Select<User>
    items={users}
    value={users[0]}
    onChange={console.log}
    getLabel={(user) => user.name}
  />
);
};

Type Guards

Type Guards son una característica de TypeScript que permite comprobar y restringir los tipos de las variables en tiempo de ejecución. Se utilizan para hacer que el código sea más seguro y predecible al garantizar que una variable sea de un tipo específico antes de realizar operaciones sobre ella.

¿Cómo funcionan?

Los Type Guards permiten a TypeScript inferir el tipo de una variable dentro de un bloque de código, lo que ayuda a prevenir errores. Esto se puede hacer a través de varias técnicas, como:

1. Operadores typeof:

Comprobar el tipo primitivo de una variable.

typescript
function esNumero(x: number | string) {
  if (typeof x === 'number') {
      console.log(x + 1); // Aquí TypeScript sabe que x es un número
  } else {
      console.log(x.length); // Aquí TypeScript sabe que x es un string
  }
}

2. Operadores instanceof:

Comprobar si un objeto es una instancia de una clase específica.

typescript
class Perro {
  ladrar() {
      console.log("¡Guau!");
  }
}

class Gato {
  maullar() {
      console.log("¡Miau!");
  }
}

function hacerSonido(animal: Perro | Gato) {
  if (animal instanceof Perro) {
      animal.ladrar(); // Aquí TypeScript sabe que animal es un Perro
  } else {
      animal.maullar(); // Aquí TypeScript sabe que animal es un Gato
  }
}

3. Funciones de Type Guard personalizadas:

Crear funciones que devuelven un valor booleano y usan una declaración is para ayudar a TypeScript a inferir el tipo.

typescript
interface Perro {
  tipo: 'perro';
  ladrar: () => void;
}

interface Gato {
  tipo: 'gato';
  maullar: () => void;
}

function esPerro(animal: Perro | Gato): animal is Perro {
  return animal.tipo === 'perro';
}

function hacerSonido(animal: Perro | Gato) {
  if (esPerro(animal)) {
      animal.ladrar(); // TypeScript sabe que animal es un Perro
  } else {
      animal.maullar(); // TypeScript sabe que animal es un Gato
  }
}
  Beneficios

  • Seguridad de tipos: Ayuda a prevenir errores en tiempo de ejecución al asegurar que se operan tipos correctos.
  • Mejor autocompletado: Aumenta la eficacia del autocompletado en editores de código, lo que mejora la productividad del desarrollador.
  • Claridad del código: Hace que el código sea más legible y fácil de mantener, ya que los tipos se manejan de manera más explícita.

  Nota

Este ejemplo en TypeScript muestra cómo utilizar discriminated unions para definir diferentes tipos de usuarios en una aplicación. Aquí, se definen dos interfaces, AdminUser y RegularUser, que representan a los usuarios con distintos atributos. La función isAdmin actúa como un type guard, permitiendo verificar si un usuario es un administrador.

El componente UserInfo muestra la información del usuario y, si es un administrador, también lista sus permisos. Este enfoque mejora la claridad del código y la seguridad de tipos al manejar diferentes tipos de datos.

typescript
interface AdminUser {
type: 'admin';
name: string;
permissions: string[];
}

interface RegularUser {
type: 'regular';
name: string;
}

type User = AdminUser | RegularUser;

const isAdmin = (user: User): user is AdminUser => {
return user.type === 'admin';
};

const UserInfo: FC<{ user: User }> = ({ user }) => {
return (
  <div>
    <h2>{user.name}</h2>
    {isAdmin(user) && (
      <div>
        Permissions: {user.permissions.join(', ')}
      </div>
    )}
  </div>
);
};