Ir al contenido principal

Escritura de lógica con Thunks

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 →

Lo que aprenderás
  • Qué son los "thunks" y por qué se usan para escribir lógica en Redux
  • Cómo funciona el middleware de thunks
  • Técnicas para escribir lógica síncrona y asíncrona en thunks
  • Patrones comunes de uso de thunks

Introducción a los Thunks

¿Qué es un "thunk"?

El término "thunk" es un concepto de programación que significa "un fragmento de código que realiza trabajo diferido". En lugar de ejecutar cierta lógica ahora, podemos escribir un cuerpo de función o código que pueda usarse para realizar el trabajo más tarde.

Específicamente en Redux, los "thunks" son un patrón para escribir funciones con lógica interna que pueden interactuar con los métodos dispatch y getState de un store de Redux.

Usar thunks requiere que el middleware redux-thunk se añada al store de Redux como parte de su configuración.

Los thunks son un enfoque estándar para escribir lógica asíncrona en aplicaciones Redux y se usan comúnmente para obtener datos. Sin embargo, pueden usarse para diversas tareas y pueden contener tanto lógica síncrona como asíncrona.

Escritura de Thunks

Una función thunk es una función que acepta dos argumentos: el método dispatch del store de Redux y el método getState del store. Las funciones thunk no son llamadas directamente por el código de la aplicación. En su lugar, se pasan a store.dispatch():

Dispatching thunk functions
const thunkFunction = (dispatch, getState) => {
// logic here that can dispatch actions or read state
}

store.dispatch(thunkFunction)

Una función thunk puede contener cualquier lógica arbitraria, síncrona o asíncrona, y puede llamar a dispatch o getState en cualquier momento.

De la misma manera que el código Redux normalmente usa creadores de acciones para generar objetos de acción en lugar de escribirlos manualmente, normalmente usamos creadores de acciones thunk para generar las funciones thunk que se despachan. Un creador de acciones thunk es una función que puede tener algunos argumentos y devuelve una nueva función thunk. El thunk normalmente encapsula cualquier argumento pasado al creador de acciones para que puedan usarse en la lógica:

Thunk action creators and thunk functions
// fetchTodoById is the "thunk action creator"
export function fetchTodoById(todoId) {
// fetchTodoByIdThunk is the "thunk function"
return async function fetchTodoByIdThunk(dispatch, getState) {
const response = await client.get(`/fakeApi/todo/${todoId}`)
dispatch(todosLoaded(response.todos))
}
}

Las funciones thunk y los creadores de acciones pueden escribirse usando tanto la palabra clave function como funciones flecha; no hay diferencia significativa. El mismo thunk fetchTodoById podría escribirse usando funciones flecha, así:

Writing thunks using arrow functions
export const fetchTodoById = todoId => async dispatch => {
const response = await client.get(`/fakeApi/todo/${todoId}`)
dispatch(todosLoaded(response.todos))
}

En cualquier caso, el thunk se despacha llamando al creador de acciones, de la misma manera que despacharías cualquier otra acción de Redux:

function TodoComponent({ todoId }) {
const dispatch = useDispatch()

const onFetchClicked = () => {
// Calls the thunk action creator, and passes the thunk function to dispatch
dispatch(fetchTodoById(todoId))
}
}

¿Por qué usar Thunks?

Los thunks nos permiten escribir lógica adicional relacionada con Redux separada de la capa de UI. Esta lógica puede incluir efectos secundarios, como solicitudes asíncronas o generación de valores aleatorios, así como lógica que requiere despachar múltiples acciones o acceder al estado del store de Redux.

Los reductores de Redux no deben contener efectos secundarios, pero las aplicaciones reales requieren lógica que tiene efectos secundarios. Parte de esta lógica puede residir dentro de componentes, pero otra parte puede necesitar vivir fuera de la capa de UI. Los thunks (y otros middleware de Redux) nos dan un lugar para colocar esos efectos secundarios.

Es común tener lógica directamente en componentes, como hacer una solicitud asíncrona en un manejador de clics o en un hook useEffect y luego procesar los resultados. Sin embargo, a menudo es necesario mover tanta lógica como sea posible fuera de la capa de UI. Esto puede hacerse para mejorar la capacidad de prueba de la lógica, mantener la capa de UI lo más delgada y "presentacional" posible, o para mejorar la reutilización y compartición de código.

En cierto sentido, un thunk es un recurso que permite escribir cualquier código que necesite interactuar con el almacén de Redux con antelación, sin necesidad de saber qué almacén de Redux se utilizará. Esto evita que la lógica quede vinculada a una instancia específica del almacén de Redux y la mantiene reutilizable.

Detailed Explanation: Thunks, Connect, and "Container Components"

Historically, another reason to use thunks was to help keep React components "unaware of Redux". The connect API allowed passing action creators and "binding" them to automatically dispatch actions when called. Since components typically did not have access to dispatch internally, passing thunks to connect made it possible for components to just call this.props.doSomething(), without needing to know if it was a callback from a parent, dispatching a plain Redux action, dispatching a thunk performing sync or async logic, or a mock function in a test.

With the arrival of the React-Redux hooks API, that situation has changed. The community has switched away from the "container/presentational" pattern in general, and components now have access to dispatch directly via the useDispatch hook. This does mean that it's possible to have more logic directly inside of a component, such as an async fetch + dispatch of the results. However, thunks have access to getState, which components do not, and there's still value in moving that logic outside of components.

Casos de uso de los thunks

Dado que los thunks son herramientas de propósito general que pueden contener lógica arbitraria, tienen una amplia variedad de aplicaciones. Los casos de uso más comunes son:

  • Extraer lógica compleja de los componentes

  • Realizar solicitudes asíncronas u otra lógica asíncrona

  • Escribir lógica que necesite despachar múltiples acciones consecutivas o a lo largo del tiempo

  • Escribir lógica que requiera acceso a getState para tomar decisiones o incluir otros valores del estado en una acción

Los thunks son funciones de "un solo uso" sin ciclo de vida definido. Tampoco pueden detectar otras acciones despachadas. Por lo tanto, no deben usarse para inicializar conexiones persistentes como websockets, ni para responder a otras acciones.

Los thunks son ideales para lógica síncrona compleja y lógica asíncrona de simple a moderada, como realizar solicitudes AJAX estándar y despachar acciones según los resultados.

Middleware Redux Thunk

Despachar funciones thunk requiere que el middleware redux-thunk esté incluido en la configuración del almacén de Redux.

Añadir el middleware

La API configureStore de Redux Toolkit añade automáticamente el middleware thunk durante la creación del almacén, por lo que normalmente está disponible sin configuración adicional.

Si necesitas añadir manualmente el middleware thunk, puedes hacerlo pasándolo a applyMiddleware() durante la configuración.

¿Cómo funciona el middleware?

Primero, repasemos cómo funcionan los middlewares de Redux en general.

Los middlewares de Redux se estructuran como tres funciones anidadas:

  • La función externa recibe un objeto "API del almacén" con {dispatch, getState}

  • La función intermedia recibe el next middleware en la cadena (o el método store.dispatch real)

  • La función interna se ejecuta con cada action que atraviesa la cadena de middlewares

Es crucial entender que los middlewares permiten pasar valores que no son objetos de acción a store.dispatch(), siempre que intercepten esos valores e impidan que lleguen a los reductores.

Con este contexto, examinemos el funcionamiento específico del middleware thunk.

La implementación real del middleware thunk es muy breve (aprox. 10 líneas). Aquí el código fuente con comentarios adicionales:

Redux thunk middleware implementation, annotated
// standard middleware definition, with 3 nested functions:
// 1) Accepts `{dispatch, getState}`
// 2) Accepts `next`
// 3) Accepts `action`
const thunkMiddleware =
({ dispatch, getState }) =>
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(dispatch, getState)
}

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

En otras palabras:

  • Si se pasa una función a dispatch, el middleware thunk detecta que es una función (no un objeto de acción), la intercepta y la ejecuta con (dispatch, getState) como argumentos

  • Si es un objeto de acción normal (u otro tipo), se reenvía al siguiente middleware en la cadena

Inyectar valores de configuración en los thunks

El middleware thunk ofrece una opción de personalización: puedes crear una instancia personalizada durante la configuración e inyectar un "argumento extra". Este valor se pasará como tercer argumento a todas las funciones thunk. Se usa comúnmente para inyectar una capa de servicio API, evitando dependencias fijas en los métodos de la API:

Thunk setup with an extra argument
import { withExtraArgument } from 'redux-thunk'

const serviceApi = createServiceApi('/some/url')

const thunkMiddlewareWithArg = withExtraArgument({ serviceApi })

El configureStore de Redux Toolkit admite esto como parte de la personalización de middleware en getDefaultMiddleware:

Thunk extra argument with configureStore
const store = configureStore({
reducer: rootReducer,
middleware: getDefaultMiddleware =>
getDefaultMiddleware({
thunk: {
extraArgument: { serviceApi }
}
})
})

Solo puede haber un valor de argumento extra. Si necesitas pasar múltiples valores, pasa un objeto que los contenga.

La función thunk recibirá entonces ese valor adicional como su tercer argumento:

Thunk function with extra argument
export const fetchTodoById =
todoId => async (dispatch, getState, extraArgument) => {
// In this example, the extra arg is an object with an API service inside
const { serviceApi } = extraArgument
const response = await serviceApi.getTodo(todoId)
dispatch(todosLoaded(response.todos))
}

Patrones de uso de thunks

Despacho de acciones

Los thunks tienen acceso al método dispatch. Esto puede usarse para despachar acciones u otros thunks. Es útil para despachar múltiples acciones consecutivas (aunque este es un patrón que debería minimizarse) u orquestar lógica compleja que requiera despachar en varios puntos del proceso.

Example: thunks dispatching actions and thunks
// An example of a thunk dispatching other action creators,
// which may or may not be thunks themselves. No async code, just
// orchestration of higher-level synchronous logic.
function complexSynchronousThunk(someValue) {
return (dispatch, getState) => {
dispatch(someBasicActionCreator(someValue))
dispatch(someThunkActionCreator())
}
}

Acceso al estado

A diferencia de los componentes, los thunks también tienen acceso a getState. Puede llamarse en cualquier momento para obtener el valor actual del estado raíz de Redux. Es útil para ejecutar lógica condicional basada en el estado actual. Es común usar funciones selectoras al leer el estado dentro de thunks en lugar de acceder directamente a campos anidados, pero ambos enfoques son válidos.

Example: Conditional dispatching based on state
const MAX_TODOS = 5

function addTodosIfAllowed(todoText) {
return (dispatch, getState) => {
const state = getState()

// Could also check `state.todos.length < MAX_TODOS`
if (selectCanAddNewTodo(state, MAX_TODOS)) {
dispatch(todoAdded(todoText))
}
}
}

Es preferible poner tanta lógica como sea posible en los reducers, pero también está bien que los thunks tengan lógica adicional interna.

Dado que el estado se actualiza de forma síncrona tan pronto como los reducers procesan una acción, puedes llamar a getState después de un despacho para obtener el estado actualizado.

Example: checking state after dispatch
function checkStateAfterDispatch() {
return (dispatch, getState) => {
const firstState = getState()
dispatch(firstAction())

const secondState = getState()

if (secondState.someField != firstState.someField) {
dispatch(secondAction())
}
}
}

Otra razón para acceder al estado en un thunk es completar una acción con información adicional. A veces, un slice reducer necesita leer un valor que no está en su propio segmento del estado. Una solución posible es despachar un thunk, extraer los valores necesarios del estado y luego despachar una acción simple con la información adicional.

Example: actions containing cross-slice data
// One solution to the "cross-slice state in reducers" problem:
// read the current state in a thunk, and include all the necessary
// data in the action
function crossSliceActionThunk() {
return (dispatch, getState) => {
const state = getState()
// Read both slices out of state
const { a, b } = state

// Include data from both slices in the action
dispatch(actionThatNeedsMoreData(a, b))
}
}

Lógica asíncrona y efectos secundarios

Los thunks pueden contener lógica asíncrona y efectos secundarios como actualizar localStorage. Esta lógica puede usar encadenamiento de Promise como someResponsePromise.then(), aunque la sintaxis async/await suele ser preferible por legibilidad.

Al hacer solicitudes asíncronas, es estándar despachar acciones antes y después para ayudar a rastrear el estado de carga. Normalmente, se despacha una acción "pending" antes de la solicitud y se marca un enumerado de carga como "in progress". Si la solicitud tiene éxito, se despacha una acción "fulfilled" con los datos resultantes, o una acción "rejected" con la información del error.

El manejo de errores aquí puede ser más complejo de lo que parece. Si encadenas resPromise.then(dispatchFulfilled).catch(dispatchRejected), podrías despachar una acción "rejected" si ocurre algún error no relacionado con la red durante el manejo de la acción "fulfilled". Es mejor usar el segundo argumento de .then() para asegurarte de manejar solo errores relacionados con la solicitud:

Example: async request with promise chaining
function fetchData(someValue) {
return (dispatch, getState) => {
dispatch(requestStarted())

myAjaxLib.post('/someEndpoint', { data: someValue }).then(
response => dispatch(requestSucceeded(response.data)),
error => dispatch(requestFailed(error.message))
)
}
}

Con async/await, esto puede ser aún más complejo debido a cómo se organiza normalmente la lógica try/catch. Para asegurarte de que el bloque catch solo maneje errores de nivel de red, puede ser necesario reorganizar la lógica para que el thunk termine temprano si hay un error, y la acción "fulfilled" solo ocurra al final:

Example: error handling with async/await
function fetchData(someValue) {
return async (dispatch, getState) => {
dispatch(requestStarted())

// Have to declare the response variable outside the try block
let response

try {
response = await myAjaxLib.post('/someEndpoint', { data: someValue })
} catch (error) {
// Ensure we only catch network errors
dispatch(requestFailed(error.message))
// Bail out early on failure
return
}

// We now have the result and there's no error. Dispatch "fulfilled".
dispatch(requestSucceeded(response.data))
}
}

Este problema no es exclusivo de Redux o thunks: puede aplicarse incluso si solo trabajas con el estado de componentes de React, o cualquier otra lógica que requiera procesamiento adicional de un resultado exitoso.

Este patrón es admitidamente incómodo de escribir y leer. En la mayoría de los casos probablemente podrías usar un patrón try/catch más típico donde la solicitud y el dispatch(requestSucceeded()) estén consecutivos. Aún así vale la pena conocer que esto puede ser un problema.

Devolución de valores desde thunks

Por defecto, store.dispatch(action) devuelve el objeto de acción real. Los middleware pueden sobrescribir el valor retornado por dispatch y sustituirlo por cualquier otro valor que deseen retornar. Por ejemplo, un middleware podría decidir siempre retornar 42 en su lugar:

Middleware return values
const return42Middleware = storeAPI => next => action => {
const originalReturnValue = next(action)
return 42
}

// later
const result = dispatch(anyAction())
console.log(result) // 42

El middleware de thunks hace esto, devolviendo lo que sea que retorne la función thunk llamada.

El caso de uso más común es retornar una promesa desde un thunk. Esto permite que el código que despachó el thunk pueda esperar a que la promesa se resuelva para saber que el trabajo asíncrono del thunk ha finalizado. Esto se usa frecuentemente en componentes para coordinar trabajo adicional:

Example: Awaiting a thunk result promise
const onAddTodoClicked = async () => {
await dispatch(saveTodo(todoText))
setTodoText('')
}

También hay un truco ingenioso que puedes hacer con esto: puedes reutilizar un thunk para realizar una selección puntual del estado de Redux cuando solo tienes acceso a dispatch. Dado que despachar un thunk retorna su valor de retorno, podrías escribir un thunk que acepte un selector, llame inmediatamente al selector con el estado y retorne el resultado. Esto puede ser útil en un componente React, donde tienes acceso a dispatch pero no a getState.

Example: reusing thunks for selecting data
// In your Redux slices:
const getSelectedData = selector => (dispatch, getState) => {
return selector(getState())
}

// in a component
const onClick = () => {
const todos = dispatch(getSelectedData(selectTodos))
// do more logic with this data
}

Esto no es una práctica recomendada per se, pero es semánticamente válido y funcionará correctamente.

Usando createAsyncThunk

Escribir lógica asíncrona con thunks puede ser algo tedioso. Cada thunk típicamente requiere definir tres tipos de acción diferentes + sus creadores de acciones correspondientes para "pendiente/completado/rechazado", además del propio creador de acciones del thunk + la función thunk. También hay casos extremos de manejo de errores que abordar.

Redux Toolkit tiene una API createAsyncThunk que abstrae el proceso de generar esas acciones, despacharlas según el ciclo de vida de una Promise y manejar los errores correctamente. Acepta un string parcial de tipo de acción (usado para generar los tipos de acción pending, fulfilled y rejected) y un "callback de creación de payload" que realiza la petición asíncrona y retorna una Promise. Luego despacha automáticamente las acciones antes y después de la petición con los argumentos correctos.

Dado que es una abstracción para el caso de uso específico de peticiones asíncronas, createAsyncThunk no cubre todos los casos de uso posibles para thunks. Si necesitas escribir lógica síncrona u otro comportamiento personalizado, deberías seguir escribiendo un thunk "normal" manualmente.

El creador de acciones del thunk tiene adjuntos los creadores de acciones para pending, fulfilled y rejected. Puedes usar la opción extraReducers en createSlice para escuchar esos tipos de acción y actualizar el estado del slice en consecuencia.

Example: createAsyncThunk
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'

// omit imports and state

export const fetchTodos = createAsyncThunk('todos/fetchTodos', async () => {
const response = await client.get('/fakeApi/todos')
return response.todos
})

const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
// omit reducer cases
},
extraReducers: builder => {
builder
.addCase(fetchTodos.pending, (state, action) => {
state.status = 'loading'
})
.addCase(fetchTodos.fulfilled, (state, action) => {
const newEntities = {}
action.payload.forEach(todo => {
newEntities[todo.id] = todo
})
state.entities = newEntities
state.status = 'idle'
})
}
})

Obtención de datos con RTK Query

Redux Toolkit tiene una nueva API de obtención de datos RTK Query. RTK Query es una solución específica para obtención y caché de datos en apps Redux, y puede eliminar la necesidad de escribir cualquier thunk o reducer para gestionar la obtención de datos.

RTK Query internamente usa createAsyncThunk para todas las peticiones, junto con un middleware personalizado para gestionar el ciclo de vida de los datos en caché.

Primero, crea un "slice de API" con definiciones para los endpoints del servidor con los que tu app se comunicará. Cada endpoint generará automáticamente un hook de React con un nombre basado en el endpoint y tipo de petición, como useGetPokemonByNameQuery:

RTK Query: API slice (pokemonSlice.js)
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'

export const pokemonApi = createApi({
reducerPath: 'pokemonApi',
baseQuery: fetchBaseQuery({ baseUrl: 'https://pokeapi.co/api/v2/' }),
endpoints: builder => ({
getPokemonByName: builder.query({
query: (name: string) => `pokemon/${name}`
})
})
})

export const { useGetPokemonByNameQuery } = pokemonApi

Luego, añade el reducer del slice de API generado y el middleware personalizado al store:

RTK Query: store setup
import { configureStore } from '@reduxjs/toolkit'
// Or from '@reduxjs/toolkit/query/react'
import { setupListeners } from '@reduxjs/toolkit/query'
import { pokemonApi } from './services/pokemon'

export const store = configureStore({
reducer: {
// Add the generated reducer as a specific top-level slice
[pokemonApi.reducerPath]: pokemonApi.reducer
},
// Adding the api middleware enables caching, invalidation, polling,
// and other useful features of `rtk-query`.
middleware: getDefaultMiddleware =>
getDefaultMiddleware().concat(pokemonApi.middleware)
})

Finalmente, importa el hook de React generado automáticamente en tu componente y llámalo. El hook obtendrá automáticamente los datos cuando el componente se monte, y si múltiples componentes usan el mismo hook con los mismos argumentos, compartirán los resultados en caché:

RTK Query: using fetching hooks
import { useGetPokemonByNameQuery } from './services/pokemon'

export default function Pokemon() {
// Using a query hook automatically fetches data and returns query values
const { data, error, isLoading } = useGetPokemonByNameQuery('bulbasaur')

// rendering logic
}

Te animamos a probar RTK Query y ver si puede ayudarte a simplificar el código de obtención de datos en tus propias aplicaciones.

Más información