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.
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.
🛠️ 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
}
📦 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.
⚡ 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:
-
Returned: El tipo de valor que el thunk resuelve con éxito (Todo[]en este caso). -
ThunkArg: El tipo del argumento que pasas al thunk cuando lo dispatches (voidaquí porque no pasamos argumentos). -
ThunkApiConfig: Un objeto con tipos parastate,dispatchyrejectValue.state: RootState: Permite que el thunk acceda al estado global tipado congetState().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.
⚛️ 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}>×</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:
| Tipo | Propósito | Dónde se define/usa |
|---|---|---|
Todo | Interfaz para un objeto individual de tarea. | src/types.ts, todosSlice.ts, componentes React |
TodosState | Interfaz para el estado de un slice de tareas. | src/types.ts, todosSlice.ts |
AuthState | Interfaz para el estado de un slice de autenticación. | src/types.ts, authSlice.ts |
RootState | Tipo inferido para el estado global de Redux. | src/app/store.ts, src/app/hooks.ts, createAsyncThunk |
AppDispatch | Tipo 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 |
✨ 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.tscentral, ú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 deanyderrota el propósito de TypeScript. Es mejor invertir tiempo en encontrar el tipo correcto que usaranycomo 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🔚 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
RootStateyAppDispatch. - Crear y tipar slices de Redux con
createSliceyPayloadAction. - Manejar la lógica asíncrona con
createAsyncThunky 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
- Tipado de Eventos en el DOM con TypeScript: Guía Completa para Interfaces y Manejadoresintermediate10 min
- Tipos Utilitarios en TypeScript: Potenciando Tu Código con Mapped Types y Condicionalesadvanced18 min
- Dominando los Decoradores en TypeScript: Una Guía Práctica con Ejemplos Realesintermediate15 min
Comentarios (0)
Aún no hay comentarios. ¡Sé el primero!