Las aplicaciones React tienden a complicarse rápidamente. Los props comienzan a fluir en todas direcciones, los componentes que necesitan los mismos datos quedan separados en el árbol, y depurar los cambios de estado se convierte en un juego de adivinanzas. Redux resuelve esto poniendo todo el estado de la aplicación en un único lugar e imponiendo un modelo de actualización estricto y predecible.
¿Qué es Redux?
Redux es un contenedor de estado predecible para aplicaciones JavaScript. Centraliza el estado de tu aplicación en un único store, de modo que los cambios de estado son trazables y fáciles de reproducir. Redux funciona con React a través de los bindings de react-redux, pero no es exclusivo de React.
Conceptos Fundamentales de Redux
Store
El store es la fuente única de verdad. Contiene todo el árbol de estado de tu aplicación. Un único store significa un único lugar donde buscar cuando algo falla.
Actions
Las actions son objetos JavaScript simples que describen una intención de cambiar el estado. Toda action debe tener una propiedad type. Todo lo demás que añadas queda a tu criterio.
const addAction = {
type: 'ADD_TODO',
payload: {
text: 'Learn Redux',
},
};
Reducers
Los reducers son funciones puras. Reciben el estado anterior y una action, y devuelven un nuevo estado. Nunca mutan el estado existente.
function todoReducer(state = [], action) {
switch (action.type) {
case 'ADD_TODO':
return [...state, action.payload];
default:
return state;
}
}
Árbol de Estado
El árbol de estado es un objeto JavaScript simple que representa todo lo que conoce tu aplicación. Como los reducers siempre devuelven nuevos objetos en lugar de modificar los existentes, los cambios de estado son predecibles y diferenciables.
Dispatch
Dispatch es el método del store que envía una action. El reducer del store la procesa y produce el siguiente estado.
store.dispatch(addAction);
Configurando Redux en una Aplicación React
Instalando las Dependencias
Instala Redux y React Redux:
npm install redux react-redux
Para usuarios de TypeScript, instala también los tipos:
npm install --save-dev @types/redux @types/react-redux
Creando el Store
Crea un archivo store.js y configura el store:
import { createStore } from 'redux';
import rootReducer from './reducers';
const store = createStore(rootReducer);
export default store;
Proporcionando el Store a la Aplicación
Envuelve tu componente raíz con Provider de react-redux. Todos los componentes por debajo podrán acceder al store.
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import store from './store';
import App from './App';
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root')
);
Trabajando con Actions y Action Creators
Define los tipos de actions:
export const ADD_TODO = 'ADD_TODO';
export const REMOVE_TODO = 'REMOVE_TODO';
Crea action creators:
export const addTodo = (text) => ({
type: ADD_TODO,
payload: { text },
});
export const removeTodo = (id) => ({
type: REMOVE_TODO,
payload: { id },
});
Entendiendo los Reducers
Crea reducers para manejar los cambios de estado:
import { ADD_TODO, REMOVE_TODO } from '../actions';
const initialState = {
todos: [],
};
function todoReducer(state = initialState, action) {
switch (action.type) {
case ADD_TODO:
const newTodo = {
id: Date.now(),
text: action.payload.text,
};
return { ...state, todos: [...state.todos, newTodo] };
case REMOVE_TODO:
return {
...state,
todos: state.todos.filter((todo) => todo.id !== action.payload.id),
};
default:
return state;
}
}
export default todoReducer;
Conectando Componentes React al Redux Store
Usando los Hooks useSelector y useDispatch
useSelector extrae datos del store. useDispatch te proporciona la función dispatch. Estos dos hooks son todo lo que necesitas en la mayoría de los componentes.
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { addTodo, removeTodo } from './actions';
function TodoList() {
const todos = useSelector((state) => state.todos);
const dispatch = useDispatch();
const handleAddTodo = (text) => {
dispatch(addTodo(text));
};
const handleRemoveTodo = (id) => {
dispatch(removeTodo(id));
};
// Lógica de renderizado del componente...
}
Middleware y Actions Asíncronas
El middleware de Redux se sitúa entre el despacho de una action y su procesamiento por el reducer. Aquí es donde pertenecen las operaciones asíncronas.
Usando Redux Thunk
Redux Thunk permite que los action creators devuelvan una función en lugar de un objeto action. Esa función recibe dispatch y puede realizar trabajo asíncrono antes de despachar.
Instala Redux Thunk:
npm install redux-thunk
Configura el store con middleware:
import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
import rootReducer from './reducers';
const store = createStore(rootReducer, applyMiddleware(thunk));
export default store;
Crea un action creator asíncrono:
export const fetchTodos = () => {
return async (dispatch) => {
dispatch({ type: 'FETCH_TODOS_REQUEST' });
try {
const response = await fetch('/api/todos');
const data = await response.json();
dispatch({ type: 'FETCH_TODOS_SUCCESS', payload: data });
} catch (error) {
dispatch({ type: 'FETCH_TODOS_FAILURE', payload: error });
}
};
};
Ejemplo: Construyendo una Aplicación de Tareas con Redux
Configuración del Proyecto
Inicializa una nueva aplicación React:
npx create-react-app redux-todo-app
cd redux-todo-app
npm install redux react-redux redux-thunk
Definiendo Actions y Action Creators
En src/actions/index.js:
export const ADD_TODO = 'ADD_TODO';
export const REMOVE_TODO = 'REMOVE_TODO';
export const FETCH_TODOS_REQUEST = 'FETCH_TODOS_REQUEST';
export const FETCH_TODOS_SUCCESS = 'FETCH_TODOS_SUCCESS';
export const FETCH_TODOS_FAILURE = 'FETCH_TODOS_FAILURE';
export const addTodo = (text) => ({
type: ADD_TODO,
payload: { text },
});
export const removeTodo = (id) => ({
type: REMOVE_TODO,
payload: { id },
});
export const fetchTodos = () => {
return async (dispatch) => {
dispatch({ type: FETCH_TODOS_REQUEST });
try {
const response = await fetch('/api/todos');
const data = await response.json();
dispatch({ type: FETCH_TODOS_SUCCESS, payload: data });
} catch (error) {
dispatch({ type: FETCH_TODOS_FAILURE, payload: error });
}
};
};
Creando los Reducers
En src/reducers/todoReducer.js:
import {
ADD_TODO,
REMOVE_TODO,
FETCH_TODOS_REQUEST,
FETCH_TODOS_SUCCESS,
FETCH_TODOS_FAILURE,
} from '../actions';
const initialState = {
todos: [],
loading: false,
error: null,
};
function todoReducer(state = initialState, action) {
switch (action.type) {
case FETCH_TODOS_REQUEST:
return { ...state, loading: true };
case FETCH_TODOS_SUCCESS:
return { ...state, loading: false, todos: action.payload };
case FETCH_TODOS_FAILURE:
return { ...state, loading: false, error: action.payload };
case ADD_TODO:
const newTodo = {
id: Date.now(),
text: action.payload.text,
};
return { ...state, todos: [...state.todos, newTodo] };
case REMOVE_TODO:
return {
...state,
todos: state.todos.filter((todo) => todo.id !== action.payload.id),
};
default:
return state;
}
}
export default todoReducer;
En src/reducers/index.js:
import { combineReducers } from 'redux';
import todoReducer from './todoReducer';
const rootReducer = combineReducers({
todoState: todoReducer,
});
export default rootReducer;
Configurando el Store
En src/store.js:
import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
import rootReducer from './reducers';
const store = createStore(rootReducer, applyMiddleware(thunk));
export default store;
Conectando los Componentes
En src/App.js:
import React, { useEffect, useState } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { addTodo, removeTodo, fetchTodos } from './actions';
function App() {
const [input, setInput] = useState('');
const { todos, loading, error } = useSelector((state) => state.todoState);
const dispatch = useDispatch();
useEffect(() => {
dispatch(fetchTodos());
}, [dispatch]);
const handleAddTodo = () => {
if (input.trim() !== '') {
dispatch(addTodo(input));
setInput('');
}
};
const handleRemoveTodo = (id) => {
dispatch(removeTodo(id));
};
if (loading) return <p>Loading todos...</p>;
if (error) return <p>Error fetching todos: {error.message}</p>;
return (
<div>
<h1>Todo List</h1>
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Add todo..."
/>
<button onClick={handleAddTodo}>Add</button>
<ul>
{todos.map((todo) => (
<li key={todo.id}>
{todo.text}{' '}
<button onClick={() => handleRemoveTodo(todo.id)}>Remove</button>
</li>
))}
</ul>
</div>
);
}
export default App;
Añadiendo Actions Asíncronas
El action creator fetchTodos de arriba simula la obtención de tareas desde una API. En un proyecto real, reemplaza la URL del fetch con el endpoint real de tu API.
Buenas Prácticas con Redux
- Siempre devuelve nuevos objetos de estado desde los reducers. Nunca mutes el estado directamente.
- Normaliza la estructura de tu estado. Las estructuras planas son más fáciles de actualizar que las profundamente anidadas.
- Mantén el estado serializable. Los valores no serializables rompen el debug de viaje en el tiempo.
- Escribe funciones selectoras para encapsular el acceso al estado. Esto te permite cambiar la estructura del estado más adelante sin actualizar cada componente.
- Usa
combineReducerspara dividir un reducer grande en piezas enfocadas. - Maneja las operaciones asíncronas en el middleware, no en los reducers.
Conclusión
Redux impone una disciplina que da sus frutos a medida que las aplicaciones crecen. Un store, un sentido de flujo de datos, reducers puros: estas restricciones hacen que los bugs sean reproducibles y los cambios de estado auditables. La aplicación de tareas anterior cubre el ciclo completo desde las actions, pasando por los reducers, hasta los componentes conectados, además de la obtención asíncrona de datos mediante Thunk. Ese patrón escala directamente a aplicaciones más grandes sin cambios fundamentales.