Ir al contenido principal

Fundamentos de Redux, Parte 6: Lógica Asíncrona y Obtención de Datos

Traducción Beta No Oficial

Esta página fue traducida por PageTurner AI (beta). No está respaldada oficialmente por el proyecto. ¿Encontraste un error? Reportar problema →

Qué aprenderás
  • Cómo funciona el flujo de datos de Redux con datos asíncronos
  • Cómo usar middleware de Redux para lógica asíncrona
  • Patrones para manejar el estado de solicitudes asíncronas
Requisitos previos
  • Familiaridad con solicitudes HTTP para obtener y actualizar datos de un servidor
  • Comprensión de la lógica asíncrona en JS, incluyendo Promesas

Introducción

En la Parte 5: UI y React, vimos cómo usar la biblioteca React-Redux para que nuestros componentes de React interactúen con un almacén Redux, incluyendo llamar a useSelector para leer el estado de Redux, llamar a useDispatch para darnos acceso a la función dispatch, y envolver nuestra app en un componente <Provider> para dar acceso a esos hooks al almacén.

Hasta ahora, todos los datos con los que hemos trabajado estaban directamente dentro de nuestra aplicación cliente React+Redux. Sin embargo, la mayoría de las aplicaciones reales necesitan trabajar con datos de un servidor, realizando llamadas HTTP a APIs para obtener y guardar elementos.

En esta sección, actualizaremos nuestra app de tareas para obtener las tareas desde una API, y añadir nuevas tareas guardándolas en la API.

Traducción Beta No Oficial

Esta página fue traducida por PageTurner AI (beta). No está respaldada oficialmente por el proyecto. ¿Encontraste un error? Reportar problema →

precaución

Ten en cuenta que este tutorial muestra intencionadamente patrones de lógica de Redux de estilo antiguo que requieren más código que los patrones de "modern Redux" con Redux Toolkit que enseñamos como el enfoque correcto para construir aplicaciones con Redux hoy en día, con el fin de explicar los principios y conceptos detrás de Redux. No está pensado para ser un proyecto listo para producción.

Consulta estas páginas para aprender a usar "modern Redux" con Redux Toolkit:

consejo

Redux Toolkit incluye la API de obtención y caché de datos RTK Query. RTK Query es una solución construida específicamente para obtener datos y manejar caché en apps Redux, y puede eliminar la necesidad de escribir thunks o reductores para gestionar la obtención de datos. Enseñamos específicamente RTK Query como el enfoque predeterminado para obtener datos, y RTK Query se basa en los mismos patrones mostrados en esta página.

Aprende a usar RTK Query para obtener datos en Redux Essentials, Parte 7: Conceptos Básicos de RTK Query.

Ejemplo de API REST y cliente

Para mantener el proyecto de ejemplo aislado pero realista, la configuración inicial del proyecto ya incluye una API REST falsa en memoria para nuestros datos (configurada usando la herramienta Mirage.js para simular APIs). La API usa /fakeApi como URL base para los endpoints, y soporta los métodos HTTP típicos GET/POST/PUT/DELETE para /fakeApi/todos. Está definida en src/api/server.js.

El proyecto también incluye un pequeño objeto cliente HTTP API que expone métodos client.get() y client.post(), similares a bibliotecas HTTP populares como axios. Está definido en src/api/client.js.

Usaremos el objeto client para realizar llamadas HTTP a nuestra API REST falsa en memoria en esta sección.

Middleware de Redux y Efectos Secundarios

Por sí mismo, un almacén Redux no sabe nada sobre lógica asíncrona. Solo sabe cómo enviar acciones de forma síncrona, actualizar el estado llamando a la función reductora raíz, y notificar a la UI que algo ha cambiado. Cualquier asincronía debe ocurrir fuera del almacén.

Anteriormente, dijimos que los reductores de Redux nunca deben contener "efectos secundarios". Un "efecto secundario" es cualquier cambio en el estado o comportamiento que pueda verse fuera de devolver un valor desde una función. Algunos tipos comunes de efectos secundarios son cosas como:

  • Registrar un valor en la consola

  • Guardar un archivo

  • Establecer un temporizador asíncrono

  • Realizar una solicitud HTTP

  • Modificar algún estado que exista fuera de una función, o mutar argumentos de una función

  • Generar números aleatorios o IDs únicos aleatorios (como Math.random() o Date.now())

Sin embargo, cualquier aplicación real necesitará hacer este tipo de cosas en algún sitio. Entonces, si no podemos poner efectos secundarios en los reductores, ¿dónde podemos ponerlos?

El middleware de Redux fue diseñado para permitir escribir lógica que tiene efectos secundarios.

Como dijimos en la Parte 4, un middleware de Redux puede hacer cualquier cosa cuando detecta una acción despachada: registrar algo, modificar la acción, retrasarla, realizar llamadas asíncronas y más. Además, dado que los middleware forman una canalización alrededor de la función real store.dispatch, esto también significa que podríamos pasar algo que no sea un objeto de acción simple a dispatch, siempre que un middleware intercepte ese valor y evite que llegue a los reductores.

Los middleware también tienen acceso a dispatch y getState. Esto significa que puedes escribir lógica asíncrona en un middleware y aún tener la capacidad de interactuar con el almacén de Redux mediante el despacho de acciones.

Uso de Middleware para Habilitar Lógica Asíncrona

Veamos un par de ejemplos de cómo los middleware nos permiten escribir cierta lógica asíncrona que interactúa con el almacén de Redux.

Una posibilidad es escribir un middleware que busque tipos de acción específicos y ejecute lógica asíncrona cuando los detecte, como en estos ejemplos:

import { client } from '../api/client'

const delayedActionMiddleware = storeAPI => next => action => {
if (action.type === 'todos/todoAdded') {
setTimeout(() => {
// Delay this action by one second
next(action)
}, 1000)
return
}

return next(action)
}

const fetchTodosMiddleware = storeAPI => next => action => {
if (action.type === 'todos/fetchTodos') {
// Make an API call to fetch todos from the server
client.get('todos').then(todos => {
// Dispatch an action with the todos we received
storeAPI.dispatch({ type: 'todos/todosLoaded', payload: todos })
})
}

return next(action)
}
información

Para más detalles sobre por qué y cómo Redux usa middleware para lógica asíncrona, consulta estas respuestas de StackOverflow de Dan Abramov, creador de Redux:

Escritura de un Middleware de Función Asíncrona

Ambos middleware de la última sección eran muy específicos y solo hacían una cosa. Sería útil tener una forma de escribir cualquier lógica asíncrona por adelantado, separada del middleware en sí, y aún tener acceso a dispatch y getState para interactuar con el almacén.

¿Y si escribiéramos un middleware que nos permitiera pasar una función a dispatch, en lugar de un objeto de acción? Podríamos hacer que nuestro middleware verifique si la "acción" es realmente una función y, de ser así, la llame inmediatamente. Esto nos permitiría escribir lógica asíncrona en funciones separadas, fuera de la definición del middleware.

Así podría verse ese middleware:

Example async function middleware
const asyncFunctionMiddleware = storeAPI => next => action => {
// If the "action" is actually a function instead...
if (typeof action === 'function') {
// then call the function and pass `dispatch` and `getState` as arguments
return action(storeAPI.dispatch, storeAPI.getState)
}

// Otherwise, it's a normal action - send it onwards
return next(action)
}

Y entonces podríamos usar ese middleware así:

const middlewareEnhancer = applyMiddleware(asyncFunctionMiddleware)
const store = createStore(rootReducer, middlewareEnhancer)

// Write a function that has `dispatch` and `getState` as arguments
const fetchSomeData = (dispatch, getState) => {
// Make an async HTTP request
client.get('todos').then(todos => {
// Dispatch an action with the todos we received
dispatch({ type: 'todos/todosLoaded', payload: todos })
// Check the updated store state after dispatching
const allTodos = getState().todos
console.log('Number of todos after loading: ', allTodos.length)
})
}

// Pass the _function_ we wrote to `dispatch`
store.dispatch(fetchSomeData)
// logs: 'Number of todos after loading: ###'

De nuevo, fíjate en que este "middleware de función asíncrona" nos permite pasar una función a dispatch! Dentro de esa función, pudimos escribir lógica asíncrona (una petición HTTP), y luego despachar un objeto de acción normal cuando se completó la solicitud.

Flujo de Datos Asíncrono en Redux

Entonces, ¿cómo afectan los middleware y la lógica asíncrona al flujo general de datos en una aplicación Redux?

Al igual que con una acción normal, primero debemos manejar un evento del usuario en la aplicación, como un clic en un botón. Luego, llamamos a dispatch() y pasamos algo, ya sea un objeto de acción simple, una función u otro valor que un middleware pueda detectar.

Una vez que ese valor despachado llega a un middleware, puede realizar una llamada asíncrona y luego despachar un objeto de acción real cuando se complete la llamada asíncrona.

Anteriormente, vimos un diagrama que representa el flujo de datos síncrono normal de Redux. Cuando añadimos lógica asíncrona a una aplicación Redux, agregamos un paso adicional donde los middleware pueden ejecutar lógica como solicitudes HTTP y luego despachar acciones. Esto hace que el flujo de datos asíncrono se vea así:

Diagrama de flujo asíncrono en Redux

Uso del middleware Redux Thunk

Resulta que Redux ya tiene una versión oficial de ese "middleware de función asíncrona", llamado Middleware Redux "Thunk". El middleware thunk nos permite escribir funciones que reciben dispatch y getState como argumentos. Las funciones thunk pueden contener cualquier lógica asíncrona que queramos, y esa lógica puede despachar acciones y leer el estado del almacén según sea necesario.

Escribir lógica asíncrona como funciones thunk nos permite reutilizar esa lógica sin saber de antemano qué almacén Redux estamos usando.

información

El término "thunk" es un concepto de programación que significa "un fragmento de código que realiza trabajo diferido". Para más detalles sobre cómo usar thunks, consulta la guía de uso:

así como estos artículos:

Configuración del Store

El middleware Redux thunk está disponible en NPM como un paquete llamado redux-thunk. Necesitamos instalar este paquete para usarlo en nuestra aplicación:

npm install redux-thunk

Una vez instalado, podemos actualizar el almacén Redux en nuestra app de tareas para usar este middleware:

src/store.js
import { createStore, applyMiddleware } from 'redux'
import { thunk } from 'redux-thunk'
import { composeWithDevTools } from 'redux-devtools-extension'
import rootReducer from './reducer'

const composedEnhancer = composeWithDevTools(applyMiddleware(thunk))

// The store now has the ability to accept thunk functions in `dispatch`
const store = createStore(rootReducer, composedEnhancer)
export default store

Obteniendo tareas desde un servidor

Actualmente nuestras tareas solo existen en el navegador del cliente. Necesitamos una forma de cargar una lista de tareas desde el servidor cuando la aplicación se inicia.

Comenzaremos escribiendo una función thunk que realiza una llamada HTTP a nuestro endpoint /fakeApi/todos para solicitar un array de objetos de tareas, y luego despacha una acción conteniendo ese array como payload. Como esto está relacionado con la funcionalidad de tareas en general, escribiremos la función thunk en el archivo todosSlice.js:

src/features/todos/todosSlice.js
import { client } from '../../api/client'

const initialState = []

export default function todosReducer(state = initialState, action) {
// omit reducer logic
}

// Thunk function
export async function fetchTodos(dispatch, getState) {
const response = await client.get('/fakeApi/todos')
dispatch({ type: 'todos/todosLoaded', payload: response.todos })
}

Solo queremos realizar esta llamada API una vez, cuando la aplicación se carga por primera vez. Hay varios lugares donde podríamos poner esto:

  • En el componente <App>, dentro de un hook useEffect

  • En el componente <TodoList>, dentro de un hook useEffect

  • Directamente en el archivo index.js, justo después de importar el store

Por ahora, intentemos colocarlo directamente en index.js:

src/index.js
import React from 'react'
import { createRoot } from 'react-dom/client'
import { Provider } from 'react-redux'
import './index.css'
import App from './App'

import './api/server'

import store from './store'
import { fetchTodos } from './features/todos/todosSlice'

store.dispatch(fetchTodos)

const root = createRoot(document.getElementById('root'))

root.render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>
)

Si recargamos la página, no hay cambios visibles en la UI. Sin embargo, si abrimos la extensión Redux DevTools, deberíamos ver que se despachó una acción 'todos/todosLoaded' que contiene algunos objetos de tareas generados por nuestra API de servidor falsa:

DevTools - contenido de la acción todosLoaded

Observa que aunque hemos despachado una acción, nada ocurre para cambiar el estado. Necesitamos manejar esta acción en nuestro reductor de tareas para actualizar el estado.

Añadamos un caso al reductor para cargar estos datos en el store. Como estamos obteniendo datos del servidor, queremos reemplazar completamente cualquier tarea existente, así que devolvemos el array action.payload como nuevo valor del estado state:

src/features/todos/todosSlice.js
import { client } from '../../api/client'

const initialState = []

export default function todosReducer(state = initialState, action) {
switch (action.type) {
// omit other reducer cases
case 'todos/todosLoaded': {
// Replace the existing state entirely by returning the new value
return action.payload
}
default:
return state
}
}

export async function fetchTodos(dispatch, getState) {
const response = await client.get('/fakeApi/todos')
dispatch({ type: 'todos/todosLoaded', payload: response.todos })
}

Como despachar una acción actualiza inmediatamente el store, también podemos llamar a getState en el thunk para leer el valor actualizado del estado después del despacho. Por ejemplo, podríamos registrar en consola el número total de tareas antes y después de despachar la acción 'todos/todosLoaded':

export async function fetchTodos(dispatch, getState) {
const response = await client.get('/fakeApi/todos')

const stateBefore = getState()
console.log('Todos before dispatch: ', stateBefore.todos.length)

dispatch({ type: 'todos/todosLoaded', payload: response.todos })

const stateAfter = getState()
console.log('Todos after dispatch: ', stateAfter.todos.length)
}

Guardando elementos de tareas

También necesitamos actualizar el servidor cada vez que intentamos crear una nueva tarea. En lugar de despachar inmediatamente la acción 'todos/todoAdded', deberíamos realizar una llamada API al servidor con los datos iniciales, esperar que el servidor envíe una copia de la nueva tarea guardada, y luego despachar una acción con ese elemento.

Sin embargo, si intentamos escribir esta lógica como función thunk, encontraremos un problema: como estamos escribiendo el thunk como función separada en todosSlice.js, el código que realiza la llamada API no sabe cuál debería ser el texto de la nueva tarea:

src/features/todos/todosSlice.js
async function saveNewTodo(dispatch, getState) {
// ❌ We need to have the text of the new todo, but where is it coming from?
const initialTodo = { text }
const response = await client.post('/fakeApi/todos', { todo: initialTodo })
dispatch({ type: 'todos/todoAdded', payload: response.todo })
}

Necesitamos una forma de escribir una función que acepte text como parámetro, pero que luego cree la función thunk real para que pueda usar el valor de text para realizar la llamada a la API. Nuestra función externa debería devolver la función thunk para que podamos pasarla a dispatch en nuestro componente.

src/features/todos/todosSlice.js
// Write a synchronous outer function that receives the `text` parameter:
export function saveNewTodo(text) {
// And then creates and returns the async thunk function:
return async function saveNewTodoThunk(dispatch, getState) {
// ✅ Now we can use the text value and send it to the server
const initialTodo = { text }
const response = await client.post('/fakeApi/todos', { todo: initialTodo })
dispatch({ type: 'todos/todoAdded', payload: response.todo })
}
}

Ahora podemos usar esto en nuestro componente <Header>:

src/features/header/Header.js
import React, { useState } from 'react'
import { useDispatch } from 'react-redux'

import { saveNewTodo } from '../todos/todosSlice'

const Header = () => {
const [text, setText] = useState('')
const dispatch = useDispatch()

const handleChange = e => setText(e.target.value)

const handleKeyDown = e => {
// If the user pressed the Enter key:
const trimmedText = text.trim()
if (e.which === 13 && trimmedText) {
// Create the thunk function with the text the user wrote
const saveNewTodoThunk = saveNewTodo(trimmedText)
// Then dispatch the thunk function itself
dispatch(saveNewTodoThunk)
setText('')
}
}

// omit rendering output
}

Como sabemos que vamos a pasar inmediatamente la función thunk a dispatch en el componente, podemos omitir la creación de una variable temporal. En su lugar, podemos llamar a saveNewTodo(text) y pasar la función thunk resultante directamente a dispatch:

src/features/header/Header.js
const handleKeyDown = e => {
// If the user pressed the Enter key:
const trimmedText = text.trim()
if (e.which === 13 && trimmedText) {
// Create the thunk function and immediately dispatch it
dispatch(saveNewTodo(trimmedText))
setText('')
}
}

Ahora el componente no sabe realmente que está enviando una función thunk: la función saveNewTodo encapsula lo que realmente sucede. El componente <Header> solo sabe que necesita enviar algún valor cuando el usuario presiona Enter.

Este patrón de escribir una función para preparar algo que se pasará a dispatch se llama patrón "creador de acciones", y hablaremos más sobre ello en la siguiente sección.

Ahora podemos ver la acción actualizada 'todos/todoAdded' siendo enviada:

DevTools - contenido de la acción todoAdded asíncrona

Lo último que debemos cambiar aquí es actualizar nuestro reductor de tareas. Cuando hacemos una petición POST a /fakeApi/todos, el servidor devolverá un objeto de tarea completamente nuevo (incluyendo un nuevo valor de ID). Esto significa que nuestro reductor no tiene que calcular un nuevo ID ni rellenar los otros campos: solo necesita crear un nuevo array state que incluya el nuevo elemento de tarea:

src/features/todos/todosSlice.js
const initialState = []

export default function todosReducer(state = initialState, action) {
switch (action.type) {
case 'todos/todoAdded': {
// Return a new todos state array with the new todo item at the end
return [...state, action.payload]
}
// omit other cases
default:
return state
}
}

Y ahora añadir una nueva tarea funcionará correctamente:

DevTools - diferencia de estado de todoAdded asíncrono

consejo

Las funciones thunk pueden usarse tanto para lógica asíncrona como síncrona. Los thunks proporcionan una forma de escribir cualquier lógica reutilizable que necesite acceso a dispatch y getState.

Lo que has aprendido

Hemos actualizado con éxito nuestra aplicación de tareas para que pueda obtener una lista de elementos y guardar nuevos elementos, usando funciones "thunk" para realizar las peticiones HTTP a nuestra API de servidor simulada.

En el proceso, vimos cómo los middleware de Redux nos permiten hacer llamadas asíncronas e interactuar con el almacén mediante el envío de acciones después de que las llamadas asíncronas hayan finalizado.

Así es como se ve la aplicación actual:

Resumen
  • Los middleware de Redux fueron diseñados para permitir escribir lógica con efectos secundarios
    • Los "efectos secundarios" son código que cambia estado/comportamiento fuera de una función, como peticiones HTTP, modificar argumentos o generar valores aleatorios
  • Los middleware añaden un paso extra al flujo de datos estándar de Redux
    • Pueden interceptar otros valores pasados a dispatch
    • Tienen acceso a dispatch y getState, permitiendo enviar más acciones como parte de lógica asíncrona
  • El middleware "Thunk" de Redux nos permite pasar funciones a dispatch
    • Las funciones "thunk" permiten escribir lógica asíncrona con antelación, sin conocer el almacén Redux específico
    • Una función thunk de Redux recibe dispatch y getState como argumentos, y puede enviar acciones como "estos datos fueron recibidos de una API"

¿Qué sigue?

¡Hemos cubierto todas las piezas centrales de cómo usar Redux! Has visto cómo:

  • Escribir reductores que actualizan el estado basándose en acciones enviadas,

  • Crear y configurar un almacén Redux con un reductor, potenciadores (enhancers) y middleware

  • Usar middleware para escribir lógica asíncrona que envía acciones

En Parte 7: Patrones estándar de Redux, veremos varios patrones de código que suelen usar las aplicaciones Redux del mundo real para hacer nuestro código más consistente y escalar mejor a medida que la aplicación crece.