Redux Essentials, Parte 5: Lógica Asíncrona y Obtención de Datos
Esta página fue traducida por PageTurner AI (beta). No está respaldada oficialmente por el proyecto. ¿Encontraste un error? Reportar problema →
- Cómo usar el middleware "thunk" de Redux para lógica asíncrona
- Patrones para manejar el estado de solicitudes asíncronas
- Cómo usar la API
createAsyncThunkde Redux Toolkit para gestionar llamadas asíncronas
- Conocimiento del uso de solicitudes HTTP para obtener y actualizar datos desde una API REST de servidor
Introducción
En Parte 4: Uso de datos Redux, vimos cómo usar múltiples datos del almacén Redux dentro de componentes React, personalizar el contenido de los objetos de acción antes de enviarlos, y manejar lógica de actualización más compleja en nuestros reductores.
Hasta ahora, todos los datos con los que hemos trabajado estaban directamente dentro de nuestra aplicación cliente React. Sin embargo, la mayoría de aplicaciones reales necesitan trabajar con datos de un servidor, haciendo llamadas API HTTP para obtener y guardar elementos.
En esta sección, convertiremos nuestra aplicación de redes sociales para obtener los datos de publicaciones y usuarios desde una API, y añadiremos nuevas publicaciones guardándolas en la API.
Redux Toolkit incluye la API de obtención y caché de datos RTK Query. RTK Query es una solución específica para obtención y caché de datos en aplicaciones Redux, y puede eliminar la necesidad de escribir cualquier lógica Redux adicional como thunks o reductores para gestionar la obtención de datos. Enseñamos específicamente RTK Query como el enfoque predeterminado para obtención de datos.
RTK Query está construido sobre los mismos patrones mostrados en esta página, por lo que esta sección te ayudará a entender la mecánica subyacente de cómo funciona la obtención de datos con Redux.
Cubriremos cómo usar RTK Query a partir de la 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 ya incluye una API REST falsa en memoria para nuestros datos (configurada usando la herramienta de simulación Mock Service Worker). La API usa /fakeApi como URL base para los endpoints, y soporta los métodos HTTP típicos GET/POST/PUT/DELETE para /fakeApi/posts, /fakeApi/users y fakeApi/notifications. Está definida en src/api/server.ts.
El proyecto también incluye un pequeño objeto cliente API HTTP que expone métodos client.get() y client.post(), similares a bibliotecas HTTP populares como axios. Está definido en src/api/client.ts.
Usaremos el objeto client para realizar llamadas HTTP a nuestra API REST falsa en memoria en esta sección.
Además, el servidor simulado está configurado para reutilizar la misma semilla aleatoria cada vez que se carga la página, generando así la misma lista de usuarios y publicaciones falsos. Si quieres resetear esto, borra el valor 'randomTimestampSeed' en el Local Storage de tu navegador y recarga la página, o puedes desactivarlo editando src/api/server.ts y estableciendo useSeededRNG a false.
Como recordatorio, los ejemplos de código se centran en los conceptos clave y cambios de cada sección. Consulta los proyectos de CodeSandbox y la rama tutorial-steps-ts en el repositorio del proyecto para ver los cambios completos en la aplicación.
Uso de middleware para habilitar la lógica asíncrona
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.
Pero, ¿y si quieres que la lógica asíncrona interactúe con el almacén enviando acciones, comprobando el estado actual del almacén o algún tipo de efecto secundario? Ahí es donde entra el middleware de Redux. Extienden el almacén para añadir capacidades adicionales y te permiten:
-
Ejecutar lógica adicional cuando se envía cualquier acción (como registrar la acción y el estado)
-
Pausar, modificar, retrasar, reemplazar o detener acciones enviadas
-
Escribir código adicional que tenga acceso a
dispatchygetState -
Enseñar a
dispatchcómo aceptar otros valores además de objetos de acción simples, como funciones y promesas, interceptándolos y despachando objetos de acción reales en su lugar -
Escribir código que use lógica asíncrona u otros efectos secundarios
La razón más común para usar middleware es permitir que diferentes tipos de lógica asíncrona interactúen con el almacén. Esto te permite escribir código que pueda despachar acciones y verificar el estado del almacén, manteniendo esa lógica separada de tu interfaz de usuario.
Para más detalles sobre cómo el middleware te permite personalizar el almacén de Redux, consulta:
Flujo de datos con middleware en Redux
Anteriormente, vimos cómo es el flujo de datos síncrono en Redux.
El middleware actualiza el flujo de datos de Redux añadiendo un paso adicional al inicio de dispatch. Así, el middleware puede ejecutar lógica como solicitudes HTTP y luego despachar acciones. Esto hace que el flujo de datos asíncrono se vea así:

Thunks y lógica asíncrona
Existen muchos tipos de middleware asíncrono para Redux, cada uno te permite escribir tu lógica con sintaxis diferente. El middleware asíncrono más común es redux-thunk, que te permite escribir funciones simples que pueden contener lógica asíncrona directamente. La función configureStore de Redux Toolkit configura automáticamente el middleware thunk por defecto, y recomendamos usar thunks como enfoque estándar para escribir lógica asíncrona con Redux.
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 en Redux, consulta la guía de uso de thunks:
así como estos artículos:
Funciones Thunk
Una vez que el middleware thunk se ha añadido al almacén de Redux, te permite pasar funciones thunk directamente a store.dispatch. Una función thunk siempre será llamada con (dispatch, getState) como argumentos, y puedes usarlos dentro del thunk según sea necesario.
Una función thunk puede contener cualquier lógica, síncrona o asíncrona.
Los thunks suelen despachar acciones simples usando creadores de acciones, como dispatch(increment()):
const store = configureStore({ reducer: counterReducer })
const exampleThunkFunction = (
dispatch: AppDispatch,
getState: () => RootState
) => {
const stateBefore = getState()
console.log(`Counter before: ${stateBefore.counter}`)
dispatch(increment())
const stateAfter = getState()
console.log(`Counter after: ${stateAfter.counter}`)
}
store.dispatch(exampleThunkFunction)
Para mantener consistencia al despachar objetos de acción normales, normalmente escribimos estos como creadores de acción thunk, que devuelven la función thunk. Estos creadores de acciones pueden tomar argumentos que se usan dentro del thunk.
const logAndAdd = (amount: number) => {
return (dispatch: AppDispatch, getState: () => RootState) => {
const stateBefore = getState()
console.log(`Counter before: ${stateBefore.counter}`)
dispatch(incrementByAmount(amount))
const stateAfter = getState()
console.log(`Counter after: ${stateAfter.counter}`)
}
}
store.dispatch(logAndAdd(5))
Los thunks suelen escribirse en archivos de "slices", ya que la obtención de datos del thunk generalmente está conceptualmente relacionada con la lógica de actualización de un slice particular. Veremos diferentes formas de definir thunks a lo largo de esta sección.
Escribiendo Thunks asíncronos
Los thunks pueden contener lógica asíncrona en su interior, como setTimeout, Promesas y async/await. Esto los convierte en un buen lugar para realizar llamadas HTTP a una API de servidor.
La lógica de obtención de datos en Redux suele seguir un patrón predecible:
-
Se despacha una acción "start" antes de la solicitud para indicar que está en curso. Esto permite rastrear el estado de carga para omitir solicitudes duplicadas o mostrar indicadores en la UI.
-
La solicitud asíncrona se realiza con
fetcho una librería wrapper, devolviendo una promesa con el resultado. -
Al resolver la promesa, la lógica asíncrona despacha una acción "success" con los datos o "failure" con detalles del error. El reducer limpia el estado de carga en ambos casos: procesa los datos en éxito o almacena el error para su visualización.
Estos pasos no son obligatorios, pero son comunes. (Si solo necesitas el resultado exitoso, puedes despachar únicamente la acción "success" al finalizar).
Redux Toolkit proporciona la API createAsyncThunk para implementar la creación y despacho de acciones de solicitudes asíncronas.
El uso básico de createAsyncThunk es:
import { createAsyncThunk } from '@reduxjs/toolkit'
export const fetchItemById = createAsyncThunk(
'items/fetchItemById',
async (itemId: string) => {
const item = await someHttpRequest(itemId)
return item
}
)
Consulta los detalles sobre cómo createAsyncThunk simplifica el código para despachar acciones en solicitudes asíncronas. Veremos su uso práctico en breve.
Detailed Explanation: Dispatching Request Status Actions in Thunks
If we were to write out the code for a typical async thunk by hand, it might look like this:
const getRepoDetailsStarted = () => ({
type: 'repoDetails/fetchStarted'
})
const getRepoDetailsSuccess = (repoDetails: RepoDetails) => ({
type: 'repoDetails/fetchSucceeded',
payload: repoDetails
})
const getRepoDetailsFailed = (error: any) => ({
type: 'repoDetails/fetchFailed',
error
})
const fetchIssuesCount = (org: string, repo: string) => {
return async (dispatch: AppDispatch) => {
dispatch(getRepoDetailsStarted())
try {
const repoDetails = await getRepoDetails(org, repo)
dispatch(getRepoDetailsSuccess(repoDetails))
} catch (err) {
dispatch(getRepoDetailsFailed(err.toString()))
}
}
}
However, writing code using this approach is tedious. Each separate type of request needs repeated similar implementation:
- Unique action types need to be defined for the three different cases
- Each of those action types usually has a corresponding action creator function
- A thunk has to be written that dispatches the correct actions in the right sequence
createAsyncThunk abstracts this pattern by generating the action types and action creators, and generating a thunk that dispatches those actions automatically. You provide a callback function that makes the async call and returns a Promise with the result.
It's also easy to make mistakes with error handling when writing thunk logic yourself. In this case, the try block will actually catch errors from both a failed request, and any errors while dispatching. Handling this correctly would require restructuring the logic to separate those. createAsyncThunk already handles errors correctly for you internally.
Tipado de Thunks en Redux
Tipado de Thunks manuales
Al escribir thunks manualmente, puedes tipar explícitamente los argumentos como (dispatch: AppDispatch, getState: () => RootState). Como es común, también puedes definir un tipo reusable AppThunk:
import { Action, ThunkAction, configureStore } from '@reduxjs/toolkit'
// omit actual store setup
// Infer the type of `store`
export type AppStore = typeof store
// Infer the `AppDispatch` type from the store itself
export type AppDispatch = typeof store.dispatch
// Same for the `RootState` type
export type RootState = ReturnType<typeof store.getState>
// Export a reusable type for handwritten thunks
export type AppThunk = ThunkAction<void, RootState, unknown, Action>
Luego úsalo para describir tus funciones thunk:
// Use `AppThunk` as the return type, since we return a thunk function
const logAndAdd = (amount: number): AppThunk => {
return (dispatch, getState) => {
const stateBefore = getState()
console.log(`Counter before: ${stateBefore.counter}`)
dispatch(incrementByAmount(amount))
const stateAfter = getState()
console.log(`Counter after: ${stateAfter.counter}`)
}
}
Tipado de createAsyncThunk
En el caso específico de createAsyncThunk: si tu función de carga útil acepta un argumento, proporciona un tipo para ese argumento, como async (userId: string). No necesitas proporcionar un tipo de retorno por defecto - TS inferirá el tipo de retorno automáticamente.
Si necesitas acceder a dispatch o getState dentro de createAsyncThunk, RTK proporciona una forma de definir una versión "pre-tipada" que tiene los tipos correctos de dispatch y getState incorporados llamando a createAsyncThunk.withTypes(), de manera equivalente a cómo definimos versiones pre-tipadas de useSelector y useDispatch. Crearemos un nuevo archivo src/app/withTypes, y lo exportaremos desde allí:
import { createAsyncThunk } from '@reduxjs/toolkit'
import type { RootState, AppDispatch } from './store'
export const createAppAsyncThunk = createAsyncThunk.withTypes<{
state: RootState
dispatch: AppDispatch
}>()
Para más detalles sobre cómo definir thunks con TypeScript, consulta:
Carga de publicaciones
Hasta ahora, nuestro postsSlice usaba datos de ejemplo codificados. Cambiaremos esto para comenzar con un array vacío de publicaciones y luego obtenerlas del servidor.
Para lograrlo, modificaremos la estructura del estado en postsSlice para rastrear el estado actual de la solicitud API.
Estado de carga para solicitudes
Al hacer una llamada API, podemos visualizar su progreso como una máquina de estados con cuatro posibles estados:
-
La solicitud no ha comenzado
-
La solicitud está en curso
-
La solicitud tuvo éxito y tenemos los datos
-
La solicitud falló con un mensaje de error
Podríamos rastrear esa información usando booleanos, como isLoading: true, pero es mejor seguir estos estados como un único valor de unión. Un buen patrón para esto es tener una sección de estado que se vea así (usando notación de tipo unión de cadenas de TypeScript):
{
// Multiple possible status string union values
status: 'idle' | 'pending' | 'succeeded' | 'failed',
error: string | null
}
Estos campos existirían junto con los datos reales que se están almacenando. Estos nombres específicos de estado no son obligatorios: siéntete libre de usar otros nombres si lo prefieres, como 'loading' en lugar de 'pending', o 'completed' en lugar de 'succeeded'.
Podemos usar esta información para decidir qué mostrar en nuestra interfaz de usuario a medida que avanza la solicitud, y también añadir lógica en nuestros reducers para prevenir casos como cargar datos dos veces.
Actualicemos nuestro postsSlice para usar este patrón y rastrear el estado de carga para una solicitud de "obtener posts". Cambiaremos nuestro estado de ser un simple array de posts a tener esta estructura: {posts, status, error}. También eliminaremos las entradas de posts de ejemplo de nuestro estado inicial, y añadiremos un par de nuevos selectores para los campos de carga y error:
import { createSlice, nanoid } from '@reduxjs/toolkit'
// omit reactions and other types
interface PostsState {
posts: Post[]
status: 'idle' | 'pending' | 'succeeded' | 'failed'
error: string | null
}
const initialState: PostsState = {
posts: [],
status: 'idle',
error: null
}
const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
postAdded: {
reducer(state, action: PayloadAction<Post>) {
state.posts.push(action.payload)
},
prepare(title: string, content: string, userId: string) {
// omit prepare logic
}
},
postUpdated(state, action: PayloadAction<PostUpdate>) {
const { id, title, content } = action.payload
const existingPost = state.posts.find(post => post.id === id)
if (existingPost) {
existingPost.title = title
existingPost.content = content
}
},
reactionAdded(
state,
action: PayloadAction<{ postId: string; reaction: ReactionName }>
) {
const { postId, reaction } = action.payload
const existingPost = state.posts.find(post => post.id === postId)
if (existingPost) {
existingPost.reactions[reaction]++
}
}
},
extraReducers: builder => {
builder.addCase(userLoggedOut, state => {
// Clear out the list of posts whenever the user logs out
return initialState
})
}
})
export const { postAdded, postUpdated, reactionAdded } = postsSlice.actions
export default postsSlice.reducer
export const selectAllPosts = (state: RootState) => state.posts.posts
export const selectPostById = (state: RootState, postId: string) =>
state.posts.posts.find(post => post.id === postId)
export const selectPostsStatus = (state: RootState) => state.posts.status
export const selectPostsError = (state: RootState) => state.posts.error
Como parte de este cambio, también necesitamos modificar cualquier uso de state como array para que sea state.posts en su lugar, porque el array ahora está un nivel más profundo.
Sí, esto significa que ahora tenemos una ruta de objeto anidado que luce como state.posts.posts, lo cual es un poco redundante y tonto :) Podríamos cambiar el nombre del array anidado a items o data si quisiéramos evitar esto, pero por ahora lo dejaremos así.
Obtención de datos con createAsyncThunk
La API createAsyncThunk de Redux Toolkit genera thunks que automáticamente despachan esas acciones de "inicio/éxito/fallo" por ti.
Empecemos añadiendo un thunk que hará una solicitud HTTP para obtener una lista de posts. Importaremos la utilidad client de la carpeta src/api, y la usaremos para hacer una solicitud a '/fakeApi/posts'.
import { createSlice, nanoid, PayloadAction } from '@reduxjs/toolkit'
import { client } from '@/api/client'
import type { RootState } from '@/app/store'
import { createAppAsyncThunk } from '@/app/withTypes'
// omit other imports and types
export const fetchPosts = createAppAsyncThunk('posts/fetchPosts', async () => {
const response = await client.get<Post[]>('/fakeApi/posts')
return response.data
})
const initialState: PostsState = {
posts: [],
status: 'idle',
error: null
}
createAsyncThunk acepta dos argumentos:
-
Una cadena que se usará como prefijo para los tipos de acción generados
-
Una función callback "creadora del payload" que debe devolver una Promise conteniendo datos, o una Promise rechazada con un error
La creadora del payload usualmente hará algún tipo de solicitud HTTP, y puede devolver directamente la Promise de la solicitud HTTP, o extraer algunos datos de la respuesta de la API y devolverlos. Normalmente escribimos esto usando la sintaxis JS async/await, que nos permite escribir funciones que usan promesas con lógica estándar try/catch en lugar de cadenas somePromise.then().
En este caso, pasamos 'posts/fetchPosts' como prefijo del tipo de acción.
En este caso, la callback de creación de payload para fetchPosts no necesita argumentos, y todo lo que debe hacer es esperar a que la llamada a la API devuelva una respuesta. El objeto de respuesta luce como {data: []}, y queremos que nuestra acción Redux despachada tenga un payload que sea solo el array de posts. Así que extraemos response.data y lo devolvemos desde el callback.
Si intentamos llamar dispatch(fetchPosts()), el thunk fetchPosts primero despachará una acción de tipo 'posts/fetchPosts/pending':

Podemos escuchar esta acción en nuestro reducer y marcar el estado de la solicitud como 'pending'.
Una vez que la Promise se resuelve, el thunk fetchPosts toma el array response.data que devolvimos desde el callback, y despacha una acción 'posts/fetchPosts/fulfilled' que contiene el array de posts como action.payload:

Reducers y acciones de carga
A continuación, necesitamos manejar ambas acciones en nuestros reducers. Esto requiere una mirada más profunda a la API createSlice que hemos estado usando.
Ya hemos visto que createSlice generará un creador de acciones por cada función reducer que definamos en el campo reducers, y que los tipos de acción generados incluyen el nombre del slice, como:
console.log(
postUpdated({ id: '123', title: 'First Post', content: 'Some text here' })
)
/*
{
type: 'posts/postUpdated',
payload: {
id: '123',
title: 'First Post',
content: 'Some text here'
}
}
*/
También hemos visto que podemos usar el campo extraReducers en createSlice para responder a acciones definidas fuera del slice.
En este caso, necesitamos escuchar los tipos de acción "pending" y "fulfilled" despachados por nuestro thunk fetchPosts. Estos creadores de acciones están asociados a nuestra función fetchPost actual, y podemos pasarlos a extraReducers para escuchar esas acciones:
export const fetchPosts = createAsyncThunk('posts/fetchPosts', async () => {
const response = await client.get<Post[]>('/fakeApi/posts')
return response.data
})
const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
// omit existing reducers here
},
extraReducers: builder => {
builder
.addCase(userLoggedOut, state => {
// Clear out the list of posts whenever the user logs out
return initialState
})
.addCase(fetchPosts.pending, (state, action) => {
state.status = 'pending'
})
.addCase(fetchPosts.fulfilled, (state, action) => {
state.status = 'succeeded'
// Add any fetched posts to the array
state.posts.push(...action.payload)
})
.addCase(fetchPosts.rejected, (state, action) => {
state.status = 'failed'
state.error = action.error.message ?? 'Unknown Error'
})
}
})
Manejaremos los tres tipos de acciones que podría despachar el thunk, basándonos en la Promise que devolvimos:
-
Cuando la solicitud comienza, estableceremos el
statusen'pending' -
Si la solicitud tiene éxito, marcamos el
statuscomo'succeeded'y añadimos los posts obtenidos astate.posts -
Si la solicitud falla, marcamos el
statuscomo'failed'y guardamos cualquier mensaje de error en el estado para mostrarlo
Despachando Thunks desde Componentes
Ahora que tenemos el thunk fetchPosts escrito y el slice actualizado para manejar esas acciones, actualicemos nuestro componente <PostsList> para iniciar realmente la obtención de datos.
Importaremos el thunk fetchPosts en el componente. Como todos nuestros creadores de acciones, tenemos que despacharlo, así que también necesitaremos añadir el hook useAppDispatch. Como queremos obtener estos datos cuando se monte <PostsList>, necesitamos importar el hook React useEffect y despachar la acción.
Es importante que intentemos obtener la lista de publicaciones solo una vez. Si lo hacemos cada vez que el componente <PostsList> se renderiza, o se vuelve a crear porque hemos cambiado entre vistas, podríamos terminar obteniendo las publicaciones varias veces. Podemos usar el valor posts.status para ayudar a decidir si realmente necesitamos comenzar la obtención, seleccionándolo en el componente y comenzando la obtención solo si el estado es 'idle', lo que significa que aún no ha comenzado.
import React, { useEffect } from 'react'
import { Link } from 'react-router-dom'
import { useAppSelector, useAppDispatch } from '@/app/hooks'
import { TimeAgo } from '@/components/TimeAgo'
import { PostAuthor } from './PostAuthor'
import { ReactionButtons } from './ReactionButtons'
import { fetchPosts, selectAllPosts, selectPostsStatus } from './postsSlice'
export const PostsList = () => {
const dispatch = useAppDispatch()
const posts = useAppSelector(selectAllPosts)
const postStatus = useAppSelector(selectPostsStatus)
useEffect(() => {
if (postStatus === 'idle') {
dispatch(fetchPosts())
}
}, [postStatus, dispatch])
// omit rendering logic
}
¡Y con eso, ahora deberíamos ver una nueva lista de posts después de iniciar sesión en nuestra aplicación!

Evitando Obtenciones Duplicadas
La buena noticia es que hemos obtenido con éxito esos objetos de posts desde nuestra API de servidor simulada.
Desafortunadamente, tenemos un problema. En este momento nuestra lista de posts muestra duplicados de cada publicación:

De hecho, si miramos las Redux DevTools, podemos ver que se despacharon dos conjuntos de acciones 'pending' y 'fulfilled':

¿Por qué sucede esto? ¿No acabamos de añadir una verificación para postStatus === 'idle'? ¿No debería ser suficiente para asegurarnos de despachar el thunk solo una vez?
Bueno, sí... y no :)
La lógica real aquí en el useEffect es correcta. El problema es que ahora estamos viendo una compilación de desarrollo de nuestra aplicación, y en desarrollo, React ejecutará todos los hooks useEffect dos veces al montar cuando estén dentro de su componente <StrictMode> para que ciertos tipos de errores sean más evidentes.
En este caso, lo que sucedió es:
-
El componente
<PostsList>se montó -
El hook
useEffectse ejecutó por primera vez. El valorpostStatuses'idle', así que despacha el thunkfetchPosts. -
fetchPostsdespacha inmediatamente su acciónfetchPosts.pending, por lo que el store de Redux sí actualizó el estado a'pending'de inmediato... -
pero React ejecuta el
useEffectde nuevo sin volver a renderizar el componente, por lo que el efecto sigue pensando quepostStatuses'idle'y despachafetchPostspor segunda vez -
Ambos thunks terminan de obtener sus datos y hacen dispatch de la acción
fetchPosts.fulfilled; en consecuencia, el reducerfulfilledse ejecuta dos veces, lo que provoca que se añada un conjunto duplicado de publicaciones al state
Entonces, ¿cómo podemos solucionar esto?
Una opción sería eliminar la etiqueta <StrictMode> de nuestra app. Pero el equipo de React recomienda usarla, y es útil para detectar otros problemas.
Podríamos escribir lógica compleja con el hook useRef para rastrear si este componente se está renderizando realmente por primera vez, y usar eso para despachar fetchPosts solo una vez. Pero eso sería un poco feo.
La última opción sería usar el valor real de state.posts.status del estado de Redux para comprobar si ya hay una solicitud en curso, y hacer que el propio thunk cancele si ese es el caso. Afortunadamente, createAsyncThunk nos ofrece una forma de hacerlo.
Comprobación de condiciones en thunks asíncronos
createAsyncThunk acepta un callback opcional condition que podemos usar para realizar esa comprobación. Si se proporciona, se ejecuta al inicio de la llamada al thunk y cancelará todo el thunk si condition devuelve false.
En este caso, sabemos que queremos evitar ejecutar el thunk si el campo state.posts.status no es 'idle'. Ya tenemos el selector selectPostsStatus que podemos usar aquí, así que podemos añadir la opción condition y comprobar ese valor:
export const fetchPosts = createAppAsyncThunk(
'posts/fetchPosts',
async () => {
const response = await client.get<Post[]>('/fakeApi/posts')
return response.data
},
{
condition(arg, thunkApi) {
const postsStatus = selectPostsStatus(thunkApi.getState())
if (postsStatus !== 'idle') {
return false
}
}
}
)
Ahora, al recargar la página y mirar <PostsList>, deberíamos ver solo un conjunto de posts sin duplicados, y solo un conjunto de acciones despachadas en Redux DevTools.
No necesitas añadir condition a todos los thunks, pero puede haber momentos en que sea útil para garantizar que solo se realice una solicitud a la vez.
¡Ten en cuenta que RTK Query gestionará esto por ti! Deduplica solicitudes en todos los componentes, por lo que cada solicitud ocurre solo una vez y no tienes que preocuparte de hacerlo tú mismo.
Mostrando el estado de carga
Nuestro componente <PostsList> ya está comprobando actualizaciones de los posts almacenados en Redux y se vuelve a renderizar cada vez que esa lista cambia. Así que, al refrescar la página, deberíamos ver un conjunto aleatorio de posts de nuestra API falsa. Pero parece haber cierto retraso: <PostsList> está vacío al principio y tras unos segundos se muestran los posts.
Una llamada API real probablemente tardará en devolver una respuesta, así que normalmente es buena idea mostrar algún indicador de "cargando..." en la UI para que el usuario sepa que estamos esperando datos.
Podemos actualizar nuestro <PostsList> para mostrar una parte diferente de la UI basada en el valor de state.posts.status: un indicador de carga si estamos cargando, un mensaje de error si falló, o la lista real de publicaciones si tenemos los datos.
Ya que estamos, probablemente sea buen momento para extraer un componente <PostExcerpt> que encapsule el renderizado de cada elemento de la lista.
El resultado podría verse así:
import React, { useEffect } from 'react'
import { Link } from 'react-router-dom'
import { useAppSelector, useAppDispatch } from '@/app/hooks'
import { Spinner } from '@/components/Spinner'
import { TimeAgo } from '@/components/TimeAgo'
import { PostAuthor } from './PostAuthor'
import { ReactionButtons } from './ReactionButtons'
import {
Post,
selectAllPosts,
selectPostsError,
fetchPosts
} from './postsSlice'
interface PostExcerptProps {
post: Post
}
function PostExcerpt({ post }: PostExcerptProps) {
return (
<article className="post-excerpt" key={post.id}>
<h3>
<Link to={`/posts/${post.id}`}>{post.title}</Link>
</h3>
<div>
<PostAuthor userId={post.user} />
<TimeAgo timestamp={post.date} />
</div>
<p className="post-content">{post.content.substring(0, 100)}</p>
<ReactionButtons post={post} />
</article>
)
}
export const PostsList = () => {
const dispatch = useAppDispatch()
const posts = useAppSelector(selectAllPosts)
const postStatus = useAppSelector(selectPostsStatus)
const postsError = useAppSelector(selectPostsError)
useEffect(() => {
if (postStatus === 'idle') {
dispatch(fetchPosts())
}
}, [postStatus, dispatch])
let content: React.ReactNode
if (postStatus === 'pending') {
content = <Spinner text="Loading..." />
} else if (postStatus === 'succeeded') {
// Sort posts in reverse chronological order by datetime string
const orderedPosts = posts
.slice()
.sort((a, b) => b.date.localeCompare(a.date))
content = orderedPosts.map(post => (
<PostExcerpt key={post.id} post={post} />
))
} else if (postStatus === 'rejected') {
content = <div>{postsError}</div>
}
return (
<section className="posts-list">
<h2>Posts</h2>
{content}
</section>
)
}
Puede que notes que las llamadas API tardan en completarse y que el spinner de carga permanece visible durante unos segundos. Nuestro servidor API simulado está configurado para añadir un retraso de 2 segundos a todas las respuestas, específicamente para ayudar a visualizar los momentos en que un spinner es visible. Si quieres cambiar este comportamiento, puedes abrir api/server.ts y modificar esta línea:
// Add an extra delay to all endpoints, so loading spinners show up.
const ARTIFICIAL_DELAY_MS = 2000
Siéntete libre de activar y desactivar esto según avancemos si quieres que las llamadas API se completen más rápido.
Opcional: Definición de Thunks dentro de createSlice
Actualmente, nuestro thunk fetchPosts está definido en el archivo postsSlice.ts, pero fuera de la llamada createSlice().
Existe una forma opcional de definir thunks dentro de createSlice, lo que requiere cambiar cómo se define el campo reducers. Consulta esta explicación si deseas probarlo:
Defining Thunks in createSlice
We've seen that the standard way to write the createSlice.reducers field is as an object, where the keys become the action names, and the values are reducers. We also saw that the values can be an object with the {reducer, prepare} functions for creating an action object with the values we want.
Alternately, the reducers field can be a callback function that receives a create object. This is somewhat similar to what we saw with extraReducers, but with a different set of methods for creating reducers and actions:
create.reducer<PayloadType>(caseReducer): defines a case reducercreate.preparedReducer(prepare, caseReducer): defines a reducer with a prepare callback
Then, return an object like before with the reducer names as the fields, but call the create methods to make each reducer. Here's what the postsSlice would look like converted to this syntax:
const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: create => {
return {
postAdded: create.preparedReducer(
(title: string, content: string, userId: string) => {
return {
payload: {
id: nanoid(),
date: new Date().toISOString(),
title,
content,
user: userId,
reactions: initialReactions
}
}
},
(state, action) => {
state.posts.push(action.payload)
}
),
postUpdated: create.reducer<PostUpdate>((state, action) => {
const { id, title, content } = action.payload
const existingPost = state.posts.find(post => post.id === id)
if (existingPost) {
existingPost.title = title
existingPost.content = content
}
}),
reactionAdded: create.reducer<{ postId: string; reaction: ReactionName }>(
(state, action) => {
const { postId, reaction } = action.payload
const existingPost = state.posts.find(post => post.id === postId)
if (existingPost) {
existingPost.reactions[reaction]++
}
}
)
}
},
extraReducers: builder => {
// same as before
}
})
Writing reducers as a callback opens the door for extending the capabilities of createSlice. In particular, it's possible to make a special version of createSlice that has the ability to use createAsyncThunk baked in.
First, import buildCreateSlice and asyncThunkCreator, then call buildCreateSlice like this:
import { buildCreateSlice, asyncThunkCreator } from '@reduxjs/toolkit'
export const createAppSlice = buildCreateSlice({
creators: { asyncThunk: asyncThunkCreator }
})
That gives you a version of createSlice with the ability to write thunks inside.
Finally, we can use that createAppSlice method to define our postsSlice with the fetchPosts thunk inside. When we do that, a couple other things change:
- We can't pass in the
RootStategeneric directly, so we have to dogetState() as RootStateto cast it - We can pass in all of the reducers that handle the thunk actions as part of the options to
create.asyncThunk(), and remove those from theextraReducersfield:
const postsSlice = createAppSlice({
name: 'posts',
initialState,
reducers: create => {
return {
// omit the other reducers
fetchPosts: create.asyncThunk(
// Payload creator function to fetch the data
async () => {
const response = await client.get<Post[]>('/fakeApi/posts')
return response.data
},
{
// Options for `createAsyncThunk`
options: {
condition(arg, thunkApi) {
const { posts } = thunkApi.getState() as RootState
if (posts.status !== 'idle') {
return false
}
}
},
// The case reducers to handle the dispatched actions.
// Each of these is optional, but must use these names.
pending: (state, action) => {
state.status = 'pending'
},
fulfilled: (state, action) => {
state.status = 'succeeded'
// Add any fetched posts to the array
state.posts.push(...action.payload)
},
rejected: (state, action) => {
state.status = 'rejected'
state.error = action.error.message ?? 'Unknown Error'
}
}
)
}
},
extraReducers: builder => {
builder.addCase(userLoggedOut, state => {
// Clear out the list of posts whenever the user logs out
return initialState
})
// The thunk handlers have been removed here
}
})
Remember, the create callback syntax is optional! The only time you have to use it is if you really want to write thunks inside of createSlice. That said, it does remove the need to use the PayloadAction type, and cuts down on extraReducers as well.
Carga de Usuarios
Actualmente estamos obteniendo y mostrando nuestra lista de publicaciones. Pero si observamos las publicaciones, hay un problema: todas muestran "Autor desconocido":

Esto ocurre porque las publicaciones son generadas aleatoriamente por el servidor API falso, que también genera un conjunto de usuarios ficticios cada vez que recargamos la página. Necesitamos actualizar nuestro slice de usuarios para obtener esos usuarios al iniciar la aplicación.
Como antes, crearemos otro thunk asíncrono para obtener los usuarios de la API y devolverlos, luego manejaremos la acción fulfilled en el campo extraReducers del slice. Por ahora omitiremos el estado de carga:
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import { client } from '@/api/client'
import type { RootState } from '@/app/store'
import { createAppAsyncThunk } from '@/app/withTypes'
interface User {
id: string
name: string
}
export const fetchUsers = createAppAsyncThunk('users/fetchUsers', async () => {
const response = await client.get<User[]>('/fakeApi/users')
return response.data
})
const initialState: User[] = []
const usersSlice = createSlice({
name: 'users',
initialState,
reducers: {},
extraReducers(builder) {
builder.addCase(fetchUsers.fulfilled, (state, action) => {
return action.payload
})
}
})
export default usersSlice.reducer
// omit selectors
Puedes haber notado que esta vez el reducer no utiliza la variable state. En su lugar, devolvemos directamente el action.payload. Immer nos permite actualizar el estado de dos formas: mutando el valor existente o devolviendo un nuevo resultado. Si devolvemos un nuevo valor, este reemplazará completamente el estado existente. (Nota: si devuelves manualmente un nuevo valor, eres responsable de escribir cualquier lógica de actualización inmutable necesaria).
El estado inicial era un array vacío, y podríamos haber hecho state.push(...action.payload) para mutarlo. Pero en este caso queremos reemplazar la lista de usuarios con lo que devuelva el servidor, evitando duplicaciones accidentales.
Para aprender más sobre actualizaciones de estado con Immer, consulta la guía "Writing Reducers with Immer" en la documentación de RTK.
Solo necesitamos obtener la lista de usuarios una vez, al iniciar la aplicación. Podemos hacerlo en nuestro archivo main.tsx, despachando directamente el thunk fetchUsers ya que tenemos el store disponible:
// omit other imports
import store from './app/store'
import { fetchUsers } from './features/users/usersSlice'
import { worker } from './api/server'
async function start() {
// Start our mock API server
await worker.start({ onUnhandledRequest: 'bypass' })
store.dispatch(fetchUsers())
const root = createRoot(document.getElementById('root')!)
root.render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>
)
}
start()
Esta es una forma válida de obtener datos al inicio. Comienza el proceso de obtención antes de renderizar los componentes React, por lo que los datos estarán disponibles antes. (Nota: este principio también se puede aplicar usando React Router data loaders).
Ahora cada publicación debería mostrar un nombre de usuario, y tendremos la misma lista en el selector "Autor" de nuestro <AddPostForm>.
Añadiendo Nuevas Publicaciones
Nos queda un paso más en esta sección. Cuando añadimos una nueva publicación desde <AddPostForm>, esta solo se agrega al store de Redux en nuestra app. Necesitamos hacer una llamada API real que cree la nueva publicación en nuestro servidor API falso para "guardarla". (Como es una API falsa, la publicación no persistirá al recargar, pero con un backend real sí estaría disponible).
Envío de Datos con Thunks
Podemos usar createAsyncThunk no solo para obtener datos, sino también para enviarlos. Crearemos un thunk que acepte los valores de <AddPostForm> como argumento y haga una llamada HTTP POST para guardar los datos.
En el proceso, vamos a cambiar cómo trabajamos con el nuevo objeto de publicación en nuestros reductores. Actualmente, nuestro postsSlice crea un nuevo objeto de publicación en el callback prepare para postAdded, generando un ID único para esa publicación. En la mayoría de aplicaciones que guardan datos en un servidor, este se encargará de generar IDs únicos y completar campos adicionales, devolviendo generalmente los datos completados en su respuesta. Así que podemos enviar un cuerpo de solicitud como { title, content, user: userId } al servidor, luego tomar el objeto completo que devuelve y añadirlo al estado de nuestro postsSlice. También extraeremos un tipo NewPost para representar el objeto que se pasa al thunk.
type PostUpdate = Pick<Post, 'id' | 'title' | 'content'>
type NewPost = Pick<Post, 'title' | 'content' | 'user'>
export const addNewPost = createAppAsyncThunk(
'posts/addNewPost',
// The payload creator receives the partial `{title, content, user}` object
async (initialPost: NewPost) => {
// We send the initial data to the fake API server
const response = await client.post<Post>('/fakeApi/posts', initialPost)
// The response includes the complete post object, including unique ID
return response.data
}
)
const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
// The existing `postAdded` reducer and prepare callback were deleted
reactionAdded(state, action) {}, // omit logic
postUpdated(state, action) {} // omit logic
},
extraReducers(builder) {
builder
// omit the cases for `fetchPosts` and `userLoggedOut`
.addCase(addNewPost.fulfilled, (state, action) => {
// We can directly add the new post object to our posts array
state.posts.push(action.payload)
})
}
})
// Remove `postAdded`
export const { postUpdated, reactionAdded } = postsSlice.actions
Verificando Resultados de Thunks en Componentes
Finalmente, actualizaremos <AddPostForm> para que despache el thunk addNewPost en lugar de la acción antigua postAdded. Como es otra llamada API al servidor, tomará tiempo y podría fallar. El thunk addNewPost() despachará automáticamente sus acciones pending/fulfilled/rejected al store de Redux, que ya estamos manejando.
Podríamos rastrear el estado de la solicitud en postsSlice usando un segundo tipo unión de carga si quisiéramos. Pero, para este ejemplo, mantengamos el seguimiento del estado de carga limitado al componente, para mostrar qué más es posible.
Sería bueno deshabilitar al menos el botón "Guardar publicación" mientras esperamos la solicitud, evitando que el usuario intente guardar dos veces por accidente. Si falla la solicitud, podríamos mostrar un mensaje de error en el formulario o simplemente registrarlo en la consola.
Podemos hacer que la lógica del componente espere a que el thunk asíncrono termine y verificar el resultado:
import React, { useState } from 'react'
import { useAppDispatch, useAppSelector } from '@/app/hooks'
import { selectCurrentUsername } from '@/features/auth/authSlice'
import { addNewPost } from './postsSlice'
// omit field types
export const AddPostForm = () => {
const [addRequestStatus, setAddRequestStatus] = useState<'idle' | 'pending'>(
'idle'
)
const dispatch = useAppDispatch()
const userId = useAppSelector(selectCurrentUsername)!
const handleSubmit = async (e: React.FormEvent<AddPostFormElements>) => {
// Prevent server submission
e.preventDefault()
const { elements } = e.currentTarget
const title = elements.postTitle.value
const content = elements.postContent.value
const form = e.currentTarget
try {
setAddRequestStatus('pending')
await dispatch(addNewPost({ title, content, user: userId })).unwrap()
form.reset()
} catch (err) {
console.error('Failed to save the post: ', err)
} finally {
setAddRequestStatus('idle')
}
}
// omit rendering logic
}
Podemos añadir un estado de carga como un hook de React useState, similar a cómo estamos siguiendo el estado de carga en postsSlice para obtener publicaciones. En este caso, solo queremos saber si la solicitud está en progreso o no.
Cuando llamamos dispatch(addNewPost()), el thunk asíncrono devuelve una Promise desde dispatch. Podemos usar await con esa Promise para saber cuándo el thunk ha completado su solicitud. Pero aún no sabemos si la solicitud tuvo éxito o falló.
createAsyncThunk maneja internamente los errores, evitando mensajes sobre "Promesas rechazadas" en nuestros logs. Luego devuelve la acción final que despachó: la acción fulfilled si tuvo éxito, o rejected si falló. Esto significa que await dispatch(someAsyncThunk()) siempre "tiene éxito", siendo el resultado el objeto de acción en sí.
Sin embargo, es común querer escribir lógica que verifique el éxito o fracaso de la solicitud real. Redux Toolkit añade una función .unwrap() a la Promise devuelta, que retorna una nueva Promise que resuelve con el valor real de action.payload de una acción fulfilled, o lanza un error si es una acción rejected. Esto nos permite manejar éxito y fracaso en el componente usando lógica try/catch normal. Así que borraremos los campos de entrada para reiniciar el formulario si se creó con éxito la publicación, y registraremos el error en consola si falló.
Si quieres ver qué pasa cuando falla la llamada API addNewPost, intenta crear una publicación donde el campo "Contenido" tenga solo la palabra "error" (sin comillas). El servidor detectará esto y devolverá una respuesta fallida, por lo que deberías ver un mensaje registrado en la consola.
Lo que has aprendido
La lógica asíncrona y obtención de datos son siempre temas complejos. Como has visto, Redux Toolkit incluye herramientas que automatizan los patrones típicos de obtención de datos en Redux.
Así luce nuestra aplicación ahora que obtenemos datos de esa API simulada:
Como recordatorio, esto es lo que cubrimos en esta sección:
- Redux usa complementos llamados "middleware" para habilitar la lógica asíncrona
- El middleware asíncrono estándar es
redux-thunk, incluido en Redux Toolkit - Las funciones thunk reciben
dispatchygetStatecomo argumentos, y pueden usarlos en la lógica asíncrona
- El middleware asíncrono estándar es
- Puedes despachar acciones adicionales para rastrear el estado de carga de llamadas API
- El patrón típico es despachar una acción "pending" antes de la llamada, luego una de "success" con datos o "failure" con error
- El estado de carga debe almacenarse como unión de literales:
'idle' | 'pending' | 'succeeded' | 'rejected'
- Redux Toolkit incluye la API
createAsyncThunkque despacha estas acciones automáticamentecreateAsyncThunkacepta un callback "creador de payload" que devuelve una Promise, generando tipospending/fulfilled/rejected- Los creadores de acción generados (como
fetchPosts) despachan acciones según la Promise devuelta - Puedes escuchar estos tipos en
createSliceusandoextraReducers, actualizando el estado en reducers createAsyncThunktiene opciónconditionpara cancelar solicitudes según el estado de Redux- Las thunks pueden devolver promises. Específicamente en
createAsyncThunk, puedes usarawait dispatch(someThunk()).unwrap()para manejar éxito/error a nivel de componente.
¿Qué sigue?
Nos queda un último conjunto de temas para cubrir las API principales de Redux Toolkit y patrones de uso. En Parte 6: Rendimiento y Normalización de Datos, veremos cómo el uso de Redux afecta al rendimiento de React, y algunas formas de optimizar nuestra aplicación para mejorarlo.