tutoriales.com

Tipado Avanzado de Redux y Redux Toolkit con TypeScript: Una Guía Completa

Este tutorial te guiará a través del tipado avanzado de Redux y Redux Toolkit utilizando TypeScript. Aprenderás a definir interfaces para tu estado, acciones y cómo tipar correctamente tus reducers y thunks, asegurando la robustez de tu aplicación.

Intermedio15 min de lectura9 views
Reportar error

Redux y TypeScript son dos potentes herramientas que, cuando se combinan, pueden elevar la calidad y la mantenibilidad de tus aplicaciones a un nuevo nivel. Mientras que Redux proporciona un patrón predecible para la gestión del estado, TypeScript añade la seguridad de tipos, detectando errores comunes en tiempo de desarrollo.

Sin embargo, integrar ambos puede ser un desafío, especialmente cuando se trata de configurar el tipado correcto para el estado global, las acciones, los reducers y los thunks. En este tutorial, desglosaremos cómo lograr un tipado robusto y eficiente utilizando Redux Toolkit, la forma recomendada de escribir lógica Redux.

🚀 ¿Por qué tipar Redux con TypeScript?

La principal razón es la seguridad y la detectabilidad de errores. Sin TypeScript, es fácil cometer errores de tipeo o acceder a propiedades inexistentes en tu estado Redux, lo que puede llevar a bugs difíciles de depurar en tiempo de ejecución. Con TypeScript:

  • Autocompletado inteligente: Tu editor de código te sugerirá propiedades y métodos disponibles, mejorando la productividad.
  • Refactorización segura: Cambiar la estructura de tu estado o acciones es menos arriesgado, ya que TypeScript te alertará sobre los lugares que necesitan actualización.
  • Mayor claridad: Las interfaces y los tipos explícitos actúan como documentación viva de tu código.
  • Menos bugs en producción: Muchos errores lógicos relacionados con tipos se capturan antes de que el código llegue a producción.
💡 Consejo: Usar Redux Toolkit simplifica enormemente el proceso de tipado, ya que muchas de las configuraciones boilerplate se abstraen y se tipan automáticamente por ti.

🛠️ Configuración Inicial del Proyecto

Antes de sumergirnos en el tipado avanzado, necesitamos un proyecto base. Si aún no tienes uno, puedes crear una aplicación React con TypeScript y Redux Toolkit usando Create React App o Vite.

npx create-react-app my-redux-app --template typescript --use-npm
cd my-redux-app
npm install @reduxjs/toolkit react-redux

O con Vite:

npm create vite my-redux-app --template react-ts
cd my-redux-app
npm install
npm install @reduxjs/toolkit react-redux

Una vez creado, tu package.json debería incluir @reduxjs/toolkit y react-redux.


🎯 Tipando el Estado de la Aplicación

El primer paso crucial es definir la forma de tu estado global. Imagina que estamos construyendo una aplicación de lista de tareas (todos).

📝 Definiendo Interfaces para el Estado

Vamos a crear un archivo src/types.ts o src/app/types.ts para centralizar nuestras interfaces de tipo.

// src/types.ts

export interface Todo {
  id: string;
  text: string;
  completed: boolean;
}

export interface TodosState {
  todos: Todo[];
  status: 'idle' | 'loading' | 'succeeded' | 'failed';
  error: string | null;
}

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

export interface AuthState {
  user: User | null;
  isAuthenticated: boolean;
  loading: boolean;
  error: string | null;
}

// Estado global de la aplicación
export interface RootState {
  todos: TodosState;
  auth: AuthState;
  // ...otros slices de estado
}
⚠️ Advertencia: Es fundamental ser lo más preciso posible al definir tus interfaces. Tipos como `any` o `Object` deben evitarse a toda costa, ya que anulan los beneficios de TypeScript.

📦 Tipando el Store de Redux

Ahora, con nuestras interfaces de estado definidas, podemos tipar el store de Redux. Usaremos Redux Toolkit para esto.

// src/app/store.ts
import { configureStore } from '@reduxjs/toolkit';
import todosReducer from '../features/todos/todosSlice';
import authReducer from '../features/auth/authSlice';
import { RootState } from '../types'; // Importamos RootState

export const store = configureStore({
  reducer: {
    todos: todosReducer,
    auth: authReducer,
  },
});

// Inferimos los tipos `RootState` y `AppDispatch` del propio store
// Esto es crucial para un tipado correcto en toda la aplicación
export type AppDispatch = typeof store.dispatch;

// Podemos usar RootState directamente o inferirla, pero para consistencia
// y para tener un lugar centralizado, ya la hemos definido arriba.
// export type RootState = ReturnType<typeof store.getState>;

Aquí, AppDispatch se infiere del método store.dispatch. Esto es importante porque dispatch puede manejar tanto acciones simples como thunks, y su tipo inferido reflejará esto correctamente.

RootState AuthState user: User | null token: string status: 'idle' | 'loading' TodosState items: Todo[] filter: 'all' | 'completed' lastUpdate: number COMBINE REDUCERS

⚡ Tipando Slices con Redux Toolkit

Redux Toolkit hace que la creación de slices sea muy sencilla. El tipado también se beneficia enormemente de createSlice.

📝 Creando un Slice de Todos

Vamos a crear src/features/todos/todosSlice.ts.

// src/features/todos/todosSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { TodosState, Todo } from '../../types'; // Importamos las interfaces

const initialState: TodosState = {
  todos: [],
  status: 'idle',
  error: null,
};

const todosSlice = createSlice({
  name: 'todos',
  initialState,
  reducers: {
    addTodo: (state, action: PayloadAction<string>) => {
      // action.payload es el texto de la nueva tarea
      state.todos.push({
        id: new Date().toISOString(), // ID simple por simplicidad
        text: action.payload,
        completed: false,
      });
    },
    toggleTodo: (state, action: PayloadAction<string>) => {
      // action.payload es el ID de la tarea a alternar
      const todo = state.todos.find(t => t.id === action.payload);
      if (todo) {
        todo.completed = !todo.completed;
      }
    },
    removeTodo: (state, action: PayloadAction<string>) => {
      // action.payload es el ID de la tarea a eliminar
      state.todos = state.todos.filter(t => t.id !== action.payload);
    },
    setTodosStatus: (state, action: PayloadAction<TodosState['status']>) => {
      state.status = action.payload;
    },
    setTodosError: (state, action: PayloadAction<string | null>) => {
      state.error = action.payload;
    },
  },
  // Aquí irían los extraReducers si tuviéramos lógica asíncrona (thunks) aquí directamente.
});

export const { addTodo, toggleTodo, removeTodo, setTodosStatus, setTodosError } = todosSlice.actions;

export default todosSlice.reducer;

Observa cómo PayloadAction<T> se utiliza para tipar el action.payload de cada reducer. Esto garantiza que el tipo de datos que esperas en cada acción sea el correcto.

👤 Creando un Slice de Autenticación

Similarmente, para src/features/auth/authSlice.ts:

// src/features/auth/authSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { AuthState, User } from '../../types';

const initialState: AuthState = {
  user: null,
  isAuthenticated: false,
  loading: false,
  error: null,
};

const authSlice = createSlice({
  name: 'auth',
  initialState,
  reducers: {
    loginStart: (state) => {
      state.loading = true;
      state.error = null;
    },
    loginSuccess: (state, action: PayloadAction<User>) => {
      state.loading = false;
      state.isAuthenticated = true;
      state.user = action.payload;
    },
    loginFailure: (state, action: PayloadAction<string>) => {
      state.loading = false;
      state.isAuthenticated = false;
      state.error = action.payload;
      state.user = null;
    },
    logout: (state) => {
      state.isAuthenticated = false;
      state.user = null;
      state.error = null;
      state.loading = false;
    },
  },
});

export const { loginStart, loginSuccess, loginFailure, logout } = authSlice.actions;

export default authSlice.reducer;

Aquí también usamos PayloadAction<User> y PayloadAction<string> para tipar las acciones de éxito y fallo, respectivamente.


📡 Tipando Thunks Asíncronos con createAsyncThunk

Los thunks son acciones que permiten interactuar con el store de forma asíncrona, por ejemplo, haciendo llamadas a API. createAsyncThunk de Redux Toolkit simplifica esto enormemente y proporciona un excelente tipado.

🔄 Configuración de Tipos para Thunks

Para tipar correctamente createAsyncThunk, necesitamos definir los tipos para el argumento de la función payload, el valor de retorno en caso de éxito y los tipos de estado global y dispatch de nuestro store.

Podemos crear un archivo de utilidades para nuestros hooks tipados y también para estos tipos de thunk.

// src/app/hooks.ts (o src/app/reduxTypedHooks.ts)
import { useDispatch, useSelector, type TypedUseSelectorHook } from 'react-redux';
import { RootState, AppDispatch } from './store'; // Asumiendo que store.ts está en el mismo nivel

// Exporta un hook useDispatch con el tipo correcto
export const useAppDispatch: () => AppDispatch = useDispatch;

// Exporta un hook useSelector con el tipo RootState
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

En src/app/store.ts (asegúrate de que RootState y AppDispatch estén correctamente exportados):

// src/app/store.ts
import { configureStore } from '@reduxjs/toolkit';
import todosReducer from '../features/todos/todosSlice';
import authReducer from '../features/auth/authSlice';

export const store = configureStore({
  reducer: {
    todos: todosReducer,
    auth: authReducer,
  },
});

// Inferimos los tipos `RootState` y `AppDispatch` del propio store
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

Aquí, RootState se infiere del store.getState y AppDispatch del store.dispatch. Esto es lo más robusto porque se adapta automáticamente si añades o quitas slices.

🌐 Un Ejemplo de Thunk Asíncrono: Cargar Todos

Ahora, volvamos a src/features/todos/todosSlice.ts para añadir un thunk para cargar tareas desde una API.

// src/features/todos/todosSlice.ts (continuación)
import { createSlice, PayloadAction, createAsyncThunk } from '@reduxjs/toolkit';
import { TodosState, Todo, RootState, AppDispatch } from '../../types'; // Ajusta la importación según tu estructura

// ... (initialState y slice definitions como antes)

// Define el thunk asíncrono para cargar todos
export const fetchTodos = createAsyncThunk<
  Todo[], // Tipo de retorno del thunk (valor de éxito)
  void,   // Tipo del argumento de la función payload (ninguno en este caso)
  {
    state: RootState;    // Tipo de RootState para getState
    dispatch: AppDispatch; // Tipo de AppDispatch para dispatch
    rejectValue: string; // Tipo del valor de rechazo (error)
  }
>(
  'todos/fetchTodos',
  async (_, { rejectWithValue }) => {
    try {
      const response = await fetch('https://jsonplaceholder.typicode.com/todos?_limit=5');
      if (!response.ok) {
        throw new Error('Failed to fetch todos.');
      }
      const data: Todo[] = await response.json();
      return data;
    } catch (error: any) {
      return rejectWithValue(error.message || 'An unknown error occurred');
    }
  }
);

const todosSlice = createSlice({
  name: 'todos',
  initialState,
  reducers: {
    // ... (reducers síncronos como antes)
  },
  extraReducers: (builder) => {
    builder
      .addCase(fetchTodos.pending, (state) => {
        state.status = 'loading';
        state.error = null;
      })
      .addCase(fetchTodos.fulfilled, (state, action: PayloadAction<Todo[]>) => {
        state.status = 'succeeded';
        state.todos = action.payload;
      })
      .addCase(fetchTodos.rejected, (state, action: PayloadAction<string | undefined>) => {
        state.status = 'failed';
        state.error = action.payload || 'Failed to fetch todos';
      });
  },
});

export const { addTodo, toggleTodo, removeTodo, setTodosStatus, setTodosError } = todosSlice.actions;

export default todosSlice.reducer;

Explicación del tipado de createAsyncThunk:

createAsyncThunk toma tres argumentos de tipo genérico:

  1. Returned: El tipo de valor que el thunk resuelve con éxito (Todo[] en este caso).

  2. ThunkArg: El tipo del argumento que pasas al thunk cuando lo dispatches (void aquí porque no pasamos argumentos).

  3. ThunkApiConfig: Un objeto con tipos para state, dispatch y rejectValue.

    • state: RootState: Permite que el thunk acceda al estado global tipado con getState().
    • dispatch: AppDispatch: Permite al thunk dispatchar otras acciones o thunks tipados.
    • rejectValue: string: El tipo del valor que se devuelve cuando el thunk es rechazado (útil para manejar errores).

Los extraReducers son donde manejamos los diferentes estados del thunk: pending, fulfilled y rejected. Observa cómo PayloadAction<Todo[]> se usa para tipar la acción fulfilled.

🔥 Importante: Define los tipos `RootState` y `AppDispatch` una única vez y expórtalos para reutilizarlos en `createAsyncThunk` y en tus hooks personalizados.

⚛️ Usando Hooks Tipados en Componentes React

Para que TypeScript entienda los tipos de tu estado en los componentes React, necesitas usar versiones tipadas de useSelector y useDispatch.

Ya definimos esto en src/app/hooks.ts:

// src/app/hooks.ts
import { useDispatch, useSelector, type TypedUseSelectorHook } from 'react-redux';
import type { RootState, AppDispatch } from './store'; // Importamos los tipos

export const useAppDispatch: () => AppDispatch = useDispatch;
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

💡 Ejemplo de Componente con Hooks Tipados

// src/components/TodoList.tsx
import React, { useEffect } from 'react';
import { useAppSelector, useAppDispatch } from '../app/hooks'; // Importamos nuestros hooks tipados
import { fetchTodos, toggleTodo, removeTodo, Todo } from '../features/todos/todosSlice';
import './TodoList.css'; // Asumiendo que tienes un archivo CSS para estilos

interface TodoItemProps {
  todo: Todo;
}

const TodoItem: React.FC<TodoItemProps> = ({ todo }) => {
  const dispatch = useAppDispatch();

  const handleToggle = () => {
    dispatch(toggleTodo(todo.id));
  };

  const handleRemove = () => {
    dispatch(removeTodo(todo.id));
  };

  return (
    <li className={`todo-item ${todo.completed ? 'completed' : ''}`}>
      <span onClick={handleToggle}>{todo.text}</span>
      <button onClick={handleRemove}>&times;</button>
    </li>
  );
};

const TodoList: React.FC = () => {
  const todos = useAppSelector(state => state.todos.todos); // Autocompletado para state.todos.todos
  const status = useAppSelector(state => state.todos.status);
  const error = useAppSelector(state => state.todos.error);
  const dispatch = useAppDispatch();

  useEffect(() => {
    if (status === 'idle') {
      dispatch(fetchTodos()); // TypeScript sabe que fetchTodos es un thunk
    }
  }, [status, dispatch]);

  if (status === 'loading') {
    return <div className="loading-message">Cargando tareas...</div>;
  }

  if (status === 'failed') {
    return <div className="error-message">Error: {error}</div>;
  }

  return (
    <div className="todo-list-container">
      <h2>Mis Tareas</h2>
      {todos.length === 0 && status === 'succeeded' ? (
        <p>No hay tareas. ¡Añade algunas!</p>
      ) : (
        <ul className="todos-list">
          {todos.map(todo => (
            <TodoItem key={todo.id} todo={todo} /> // 'todo' es de tipo Todo gracias a TS
          ))}
        </ul>
      )}
    </div>
  );
};

export default TodoList;

En este ejemplo, state.todos.todos tiene el tipo Todo[] y state.todos.status tiene el tipo 'idle' | 'loading' | 'succeeded' | 'failed' gracias a useAppSelector<RootState>. Esto significa que obtienes autocompletado y detección de errores si intentas acceder a una propiedad inexistente o con un tipo incorrecto.

También, dispatch(fetchTodos()) es reconocido correctamente como un thunk y TypeScript no se queja.


📏 Resumen de Tipos Clave y Su Uso

Aquí tienes una tabla que resume los tipos clave que hemos utilizado y su propósito:

TipoPropósitoDónde se define/usa
TodoInterfaz para un objeto individual de tarea.src/types.ts, todosSlice.ts, componentes React
TodosStateInterfaz para el estado de un slice de tareas.src/types.ts, todosSlice.ts
AuthStateInterfaz para el estado de un slice de autenticación.src/types.ts, authSlice.ts
RootStateTipo inferido para el estado global de Redux.src/app/store.ts, src/app/hooks.ts, createAsyncThunk
AppDispatchTipo inferido para la función dispatch de Redux.src/app/store.ts, src/app/hooks.ts, createAsyncThunk
PayloadAction<T>Tipo genérico para acciones que llevan una carga (payload).createSlice reducers, extraReducers
TypedUseSelectorHook<RootState>Para tipar useSelector con el estado global.src/app/hooks.ts
createAsyncThunk<Returned, ThunkArg, ThunkApiConfig>Para definir y tipar thunks asíncronos.createSlice extraReducers, todosSlice.ts
Tipado Avanzado Dominado (90%)

✨ Mejores Prácticas y Consejos Adicionales

  • Consistencia: Mantén un patrón consistente para la definición de tus tipos e interfaces. Si decides tener un archivo src/types.ts central, úsalo.
  • Pequeños slices: Divide tu estado en slices pequeños y manejables. Esto no solo mejora la organización sino que también simplifica el tipado de cada slice individualmente.
  • Evita any: Como se mencionó, el uso de any derrota el propósito de TypeScript. Es mejor invertir tiempo en encontrar el tipo correcto que usar any como un atajo.
  • Comentarios de JSDoc: Para lógicas más complejas, considera añadir comentarios de JSDoc para describir el propósito de tus tipos, interfaces y funciones.
  • Pruebas Unitarias: Al igual que con cualquier código, escribe pruebas unitarias para tus reducers y thunks. Esto te ayudará a verificar que tu lógica funciona como se espera, y también a validar implícitamente que tus tipos son correctos.
¿Por qué inferir `RootState` y `AppDispatch` directamente del `store`? Inferir `RootState` con `ReturnType` y `AppDispatch` con `typeof store.dispatch` es la forma más robusta y recomendada por la documentación de Redux Toolkit. Esto asegura que si tu `store` cambia (añades o quitas reducers), tus tipos se actualizarán automáticamente sin necesidad de cambios manuales en `RootState`.

🔚 Conclusión

Integrar TypeScript con Redux y Redux Toolkit puede parecer abrumador al principio, pero los beneficios a largo plazo en términos de mantenibilidad, escalabilidad y reducción de errores son inmensos. Al seguir los patrones de tipado descritos en este tutorial, tendrás una base sólida para construir aplicaciones robustas y fáciles de entender.

Has aprendido a:

  • Definir interfaces para el estado de tu aplicación.
  • Tipar el store de Redux con RootState y AppDispatch.
  • Crear y tipar slices de Redux con createSlice y PayloadAction.
  • Manejar la lógica asíncrona con createAsyncThunk y tipar sus argumentos y valores de retorno.
  • Usar hooks tipados (useAppSelector, useAppDispatch) en tus componentes React.

Ahora tienes las herramientas para aplicar un tipado riguroso a tus aplicaciones Redux, garantizando una mejor experiencia de desarrollo y menos sorpresas en producción. ¡Feliz tipado! 🎉

Tutoriales relacionados

Comentarios (0)

Aún no hay comentarios. ¡Sé el primero!