Conceptos básicos de Redux, Parte 2: Estructura de aplicaciones con Redux Toolkit
Esta página fue traducida por PageTurner AI (beta). No está respaldada oficialmente por el proyecto. ¿Encontraste un error? Reportar problema →
- La estructura típica de una aplicación React + Redux Toolkit
- Cómo ver cambios de estado en la extensión Redux DevTools
Introducción
En la Parte 1: Visión general y conceptos de Redux, examinamos por qué Redux es útil, los términos y conceptos que describen diferentes partes del código Redux, y cómo fluyen los datos en una aplicación Redux.
Ahora, veamos un ejemplo real en funcionamiento para entender cómo encajan estas piezas.
La aplicación de ejemplo: Contador
El proyecto de ejemplo que analizaremos es una pequeña aplicación contador que nos permite sumar o restar un número al hacer clic en botones. Puede no parecer muy emocionante, pero muestra todas las piezas importantes de una aplicación React+Redux en acción.
El proyecto se ha creado usando una versión reducida de la plantilla oficial de Redux Toolkit para Vite. Listo para usar, ya viene configurado con una estructura estándar de aplicación Redux, empleando Redux Toolkit para crear el store y la lógica de Redux, y React-Redux para conectar el store de Redux con los componentes React.
Esta es la versión en vivo del proyecto. Puedes interactuar con él haciendo clic en los botones de la vista previa a la derecha, y explorar los archivos fuente a la izquierda.
Si deseas configurar este proyecto en tu propio ordenador, puedes crear una copia local con este comando:
npx degit reduxjs/redux-templates/packages/rtk-app-structure-example my-app
También puedes crear un nuevo proyecto usando la plantilla completa de Redux Toolkit para Vite:
npx degit reduxjs/redux-templates/packages/vite-template-redux my-app
Usando la aplicación Contador
La aplicación contador ya está configurada para que podamos observar qué sucede internamente mientras la usamos.
Abre las herramientas de desarrollo de tu navegador. Luego, selecciona la pestaña "Redux" en DevTools y haz clic en el botón "State" de la barra superior derecha. Deberías ver algo similar a esto:

A la derecha, podemos ver que nuestro store de Redux comienza con un valor de estado de aplicación que luce así:
{
counter: {
value: 0
status: 'idle'
}
}
Las DevTools nos mostrarán cómo cambia el estado del store mientras usamos la aplicación.
Primero interactuemos con la aplicación para ver qué hace. Haz clic en el botón "+" de la aplicación, luego mira la pestaña "Diff" en Redux DevTools:

Podemos observar dos cosas importantes aquí:
-
Al hacer clic en el botón "+", se despachó al store una acción de tipo
"counter/increment" -
Cuando se despachó esa acción, el campo
state.counter.valuecambió de0a1
Ahora prueba estos pasos:
-
Haz clic en el botón "+" nuevamente. El valor mostrado debería ser ahora 2.
-
Haz clic en el botón "-" una vez. El valor mostrado debería ser ahora 1.
-
Haz clic en el botón "Add Amount". El valor mostrado debería ser ahora 3.
-
Cambia el número "2" en el cuadro de texto por un "3"
-
Haz clic en el botón "Add Async". Deberías ver una barra de progreso llenando el botón, y después de unos segundos, el valor mostrado debería cambiar a 6.
Vuelve a las Redux DevTools. Deberías ver un total de cinco acciones despachadas, una por cada vez que hicimos clic en un botón. Ahora selecciona la última entrada "counter/incrementByAmount" de la lista de la izquierda y haz clic en la pestaña "Action" en el lado derecho:

Podemos ver que este objeto de acción tenía este aspecto:
{
type: 'counter/incrementByAmount',
payload: 3
}
Y si haces clic en la pestaña "Diff", verás que el campo state.counter.value cambió de 3 a 6 en respuesta a esa acción.
¡La capacidad de ver lo que ocurre dentro de nuestra aplicación y cómo cambia nuestro estado con el tiempo es muy poderosa!
Las DevTools tienen varios comandos y opciones más para ayudarte a depurar tu aplicación. Prueba a hacer clic en la pestaña "Trace" en la parte superior derecha. Deberías ver un rastreo de la pila de funciones de JavaScript en el panel, con varias secciones de código fuente que muestran las líneas que se estaban ejecutando cuando la acción llegó al store. Una línea en concreto debería estar resaltada: la línea de código donde despachamos esta acción desde el componente <Counter>:

Esto facilita rastrear qué parte del código despachó una acción específica.
Contenido de la aplicación
Ahora que sabes lo que hace la aplicación, veamos cómo funciona.
Estos son los archivos clave que componen esta aplicación:
/srcmain.tsx: punto de entrada de la aplicaciónApp.tsx: el componente React de nivel superior/appstore.ts: crea la instancia del store de Reduxhooks.ts: exporta hooks de React-Redux pre-tipados
/features/counterCounter.tsx: un componente React que muestra la interfaz de usuario para la funcionalidad de contadorcounterSlice.ts: la lógica de Redux para la funcionalidad de contador
Empecemos viendo cómo se crea el store de Redux.
Creación del store de Redux
Abre app/store.ts, que debería tener este aspecto:
import type { Action, ThunkAction } from '@reduxjs/toolkit'
import { configureStore } from '@reduxjs/toolkit'
import counterReducer from '@/features/counter/counterSlice'
export const store = configureStore({
reducer: {
counter: counterReducer
}
})
// Infer the type of `store`
export type AppStore = typeof store
export type RootState = ReturnType<AppStore['getState']>
// Infer the `AppDispatch` type from the store itself
export type AppDispatch = AppStore['dispatch']
// Define a reusable type describing thunk functions
export type AppThunk<ThunkReturnType = void> = ThunkAction<
ThunkReturnType,
RootState,
unknown,
Action
>
El store de Redux se crea mediante la función configureStore de Redux Toolkit. configureStore requiere que pasemos un argumento reducer.
Nuestra aplicación puede estar compuesta por muchas funcionalidades diferentes, y cada una de ellas puede tener su propia función reductora. Cuando llamamos a configureStore, podemos pasar todos los reductores diferentes en un objeto. Los nombres de las claves en el objeto definirán las claves en nuestro valor de estado final.
Tenemos un archivo llamado features/counter/counterSlice.ts que exporta una función reductora para la lógica del contador como exportación 'default' de ESM. Podemos importar esa función en este archivo. Como es una exportación por defecto, podemos darle cualquier nombre a la variable al importarla. En este caso, la llamamos counterReducer aquí y la incluimos al crear el store. (Ten en cuenta que el comportamiento de importación/exportación es la sintaxis estándar de los módulos ES, y no es específico de Redux.)
Cuando pasamos un objeto como {counter: counterReducer}, estamos indicando que queremos tener una sección state.counter en nuestro objeto de estado de Redux, y que queremos que la función counterReducer se encargue de decidir si y cómo actualizar la sección state.counter cada vez que se despacha una acción.
Redux permite personalizar la configuración del store con diferentes tipos de complementos ("middleware" y "enhancers"). configureStore añade automáticamente varios middleware a la configuración del store por defecto para ofrecer una buena experiencia de desarrollo, y también configura el store para que la extensión Redux DevTools pueda inspeccionar su contenido.
Para el uso con TypeScript, también queremos exportar algunos tipos reutilizables basados en el Store, como los tipos RootState y AppDispatch. Veremos cómo se usan más adelante.
Redux Slices (Porciones)
Un "slice" es una colección de lógica de reducer y acciones de Redux para una única función en tu aplicación, típicamente definidos juntos en un solo archivo. El nombre proviene de dividir el objeto de estado raíz de Redux en múltiples "slices" (porciones) de estado.
Por ejemplo, en una aplicación de blogs, la configuración de nuestro almacén podría verse así:
import { configureStore } from '@reduxjs/toolkit'
import usersReducer from '../features/users/usersSlice'
import postsReducer from '../features/posts/postsSlice'
import commentsReducer from '../features/comments/commentsSlice'
export const store = configureStore({
reducer: {
users: usersReducer,
posts: postsReducer,
comments: commentsReducer
}
})
En ese ejemplo, state.users, state.posts y state.comments son cada una una "porción" separada del estado de Redux. Dado que usersReducer es responsable de actualizar la porción state.users, nos referimos a él como una función "slice reducer" (reductor de porción).
Detailed Explanation: Reducers and State Structure
A Redux store needs to have a single "root reducer" function passed in when it's created. So if we have many different slice reducer functions, how do we get a single root reducer instead, and how does this define the contents of the Redux store state?
If we tried calling all of the slice reducers by hand, it might look like this:
function rootReducer(state = {}, action) {
return {
users: usersReducer(state.users, action),
posts: postsReducer(state.posts, action),
comments: commentsReducer(state.comments, action)
}
}
That calls each slice reducer individually, passes in the specific slice of the Redux state, and includes each return value in the final new Redux state object.
Redux has a function called combineReducers that does this for us automatically. It accepts an object full of slice reducers as its argument, and returns a function that calls each slice reducer whenever an action is dispatched. The result from each slice reducer are all combined together into a single object as the final result. We can do the same thing as the previous example using combineReducers:
const rootReducer = combineReducers({
users: usersReducer,
posts: postsReducer,
comments: commentsReducer
})
When we pass an object of slice reducers to configureStore, it passes those to combineReducers for us to generate the root reducer.
As we saw earlier, you can also pass a reducer function directly as the reducer argument:
const store = configureStore({
reducer: rootReducer
})
Creación de reductores de porciones y acciones
Como sabemos que la función counterReducer proviene de features/counter/counterSlice.ts, veamos qué hay en ese archivo, parte por parte.
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'
import type { PayloadAction } from '@reduxjs/toolkit'
// Define the TS type for the counter slice's state
export interface CounterState {
value: number
status: 'idle' | 'loading' | 'failed'
}
// Define the initial value for the slice state
const initialState: CounterState = {
value: 0,
status: 'idle'
}
// Slices contain Redux reducer logic for updating state, and
// generate actions that can be dispatched to trigger those updates.
export const counterSlice = createSlice({
name: 'counter',
initialState,
// The `reducers` field lets us define reducers and generate associated actions
reducers: {
increment: state => {
// Redux Toolkit allows us to write "mutating" logic in reducers. It
// doesn't actually mutate the state because it uses the Immer library,
// which detects changes to a "draft state" and produces a brand new
// immutable state based off those changes
state.value += 1
},
decrement: state => {
state.value -= 1
},
// Use the PayloadAction type to declare the contents of `action.payload`
incrementByAmount: (state, action: PayloadAction<number>) => {
state.value += action.payload
}
}
})
// Export the generated action creators for use in components
export const { increment, decrement, incrementByAmount } = counterSlice.actions
// Export the slice reducer for use in the store configuration
export default counterSlice.reducer
Anteriormente, vimos que al hacer clic en los diferentes botones de la interfaz de usuario se despacharon tres tipos de acciones de Redux diferentes:
-
{type: "counter/increment"} -
{type: "counter/decrement"} -
{type: "counter/incrementByAmount"}
Sabemos que las acciones son objetos simples con un campo type, el campo type siempre es una cadena, y normalmente tenemos funciones "action creator" (creadoras de acciones) que crean y devuelven los objetos de acción. Entonces, ¿dónde se definen esos objetos de acción, cadenas de tipo y creadores de acciones?
Podríamos escribirlos todos a mano, cada vez. Pero eso sería tedioso. Además, lo que realmente importa en Redux son las funciones reductoras y la lógica que tienen para calcular el nuevo estado.
Redux Toolkit tiene una función llamada createSlice, que se encarga de generar cadenas de tipo de acción, funciones creadoras de acciones y objetos de acción. Todo lo que tienes que hacer es definir un nombre para esta porción, escribir un objeto que tenga algunas funciones reductoras, y genera automáticamente el código de acción correspondiente. La cadena de la opción name se usa como la primera parte de cada tipo de acción, y el nombre clave de cada función reductora se usa como la segunda parte. Así, el nombre "counter" + la función reductora "increment" generó un tipo de acción {type: "counter/increment"}. (Después de todo, ¿por qué escribir esto a mano si la computadora puede hacerlo por nosotros!)
Además del campo name, createSlice necesita que pasemos el valor de estado inicial para los reductores, para que haya un state la primera vez que se llama. En este caso, estamos proporcionando un objeto con un campo value que comienza en 0, y un campo status que comienza con 'idle'.
Podemos ver aquí que hay tres funciones reductoras, y eso corresponde a los tres tipos de acciones diferentes que se despacharon al hacer clic en los diferentes botones.
createSlice genera automáticamente creadores de acciones con los mismos nombres que las funciones reductoras que escribimos. Podemos comprobarlo llamando a uno de ellos y viendo lo que devuelve:
console.log(counterSlice.actions.increment())
// {type: "counter/increment"}
También genera la función reductora de segmento que sabe cómo responder a todos estos tipos de acciones:
const newState = counterSlice.reducer(
{ value: 10 },
counterSlice.actions.increment()
)
console.log(newState)
// {value: 11}
Reglas de los reductores
Dijimos antes que los reductores siempre deben seguir algunas reglas especiales:
-
Deben calcular el nuevo valor de estado basándose únicamente en los argumentos
stateyaction -
No pueden modificar el
stateexistente. En su lugar, deben realizar actualizaciones inmutables, copiando elstateexistente y haciendo cambios en los valores copiados -
Deben ser "puros": no pueden realizar lógica asíncrona ni otros "efectos secundarios"
Pero, ¿por qué son importantes estas reglas? Hay varias razones:
-
Uno de los objetivos de Redux es que tu código sea predecible. Cuando la salida de una función se calcula únicamente a partir de los argumentos de entrada, es más fácil entender cómo funciona ese código y probarlo.
-
Por otro lado, si una función depende de variables externas a ella misma o se comporta aleatoriamente, nunca se sabe qué pasará al ejecutarla.
-
Si una función modifica otros valores, incluidos sus argumentos, puede cambiar el funcionamiento de la aplicación de forma inesperada. Esto es una fuente común de errores, como "actualicé mi estado pero ahora mi interfaz no se actualiza cuando debería".
-
Algunas capacidades de Redux DevTools dependen de que tus reductores sigan estas reglas correctamente.
La regla sobre "actualizaciones inmutables" es particularmente importante y merece más atención.
Reductores y actualizaciones inmutables
Anteriormente, hablamos sobre "mutación" (modificar valores existentes de objetos/arrays) e "inmutabilidad" (tratar valores como algo que no puede cambiarse).
En Redux, ¡nuestros reductores nunca deben mutar los valores originales/actuales del estado!
// ❌ Illegal - by default, this will mutate the state!
state.value = 123
Hay varias razones por las que no debes mutar el estado en Redux:
-
Causa errores, como que la interfaz no se actualice correctamente para mostrar los últimos valores
-
Dificulta entender por qué y cómo se actualizó el estado
-
Complica la escritura de pruebas
-
Rompe la capacidad de usar correctamente la "depuración viaje en el tiempo"
-
Va en contra del espíritu y patrones de uso previstos para Redux
Entonces, si no podemos cambiar los originales, ¿cómo devolver un estado actualizado?
Los reductores solo pueden hacer copias de los valores originales y luego mutar esas copias.
// ✅ This is safe, because we made a copy
return {
...state,
value: 123
}
Ya vimos que podemos escribir actualizaciones inmutables manualmente usando operadores de propagación de JavaScript y funciones que devuelven copias de valores originales. Pero si piensas que "escribir actualizaciones inmutables manualmente parece difícil de recordar y hacer correctamente"... ¡sí, tienes razón! :)
Escribir lógica de actualización inmutable manualmente es difícil, y mutar accidentalmente el estado en reductores es el error más común que cometen los usuarios de Redux.
¡Por eso la función createSlice de Redux Toolkit permite escribir actualizaciones inmutables más fácilmente!
createSlice usa internamente una biblioteca llamada Immer. Immer emplea una herramienta especial de JS llamada Proxy para envolver tus datos, permitiéndote escribir código que "muta" estos datos envueltos. Pero Immer rastrea todos los cambios intentados y usa esa lista para devolver un valor actualizado inmutablemente seguro, como si hubieras escrito toda la lógica manualmente.
Entonces, en lugar de esto:
function handwrittenReducer(state, action) {
return {
...state,
first: {
...state.first,
second: {
...state.first.second,
[action.someId]: {
...state.first.second[action.someId],
fourth: action.someValue
}
}
}
}
}
Puedes escribir código así:
function reducerWithImmer(state, action) {
state.first.second[action.someId].fourth = action.someValue
}
¡Mucho más fácil de leer!
Pero recuerda algo muy importante:
¡Solo puedes escribir lógica "mutante" en createSlice y createReducer de Redux Toolkit porque usan Immer internamente! Si escribes lógica mutante sin Immer, ¡mutará el estado y causará errores!
Con esto en mente, revisemos los reductores reales del segmento (slice) del contador.
export const counterSlice = createSlice({
name: 'counter',
initialState,
// The `reducers` field lets us define reducers and generate associated actions
reducers: {
increment: state => {
// Redux Toolkit allows us to write "mutating" logic in reducers. It
// doesn't actually mutate the state because it uses the Immer library,
// which detects changes to a "draft state" and produces a brand new
// immutable state based off those changes
state.value += 1
},
decrement: state => {
state.value -= 1
},
// Use the PayloadAction type to declare the contents of `action.payload`
incrementByAmount: (state, action: PayloadAction<number>) => {
state.value += action.payload
}
}
})
Vemos que el reductor increment siempre suma 1 a state.value. Como Immer detecta los cambios en el borrador state, no necesitamos devolver nada explícitamente. Igualmente, el reductor decrement resta 1.
En ambos reductores, no necesitamos que nuestro código examine el objeto action. Se pasará de todos modos, pero como no lo requerimos, podemos omitir declarar action como parámetro en los reductores.
Por otro lado, el reductor incrementByAmount sí necesita saber algo: cuánto debe sumar al valor del contador. Por tanto, declaramos este reductor con los argumentos state y action. En este caso, sabemos que la cantidad que escribimos en el campo "amount" se coloca en el campo action.payload, así que podemos sumarlo a state.value.
Si estamos usando TypeScript, debemos indicarle a TS el tipo de action.payload. El tipo PayloadAction declara que "este es un objeto de acción, donde el tipo de action.payload es..." el tipo que especifiques. Aquí, sabemos que la interfaz tomó la cadena numérica escrita en el campo "amount", la convirtió en número e intenta despachar la acción con ese valor, así que declaramos que esto es action: PayloadAction<number>.
Para más información sobre inmutabilidad y actualizaciones inmutables, consulta la página de documentación "Patrones de actualización inmutable" y La guía completa de inmutabilidad en React y Redux.
Para detalles sobre el uso de Immer en actualizaciones inmutables "mutables", visita la documentación de Immer y la página Escribiendo reductores con Immer.
Lógica adicional de Redux
El núcleo de Redux son los reductores, las acciones y el almacén. También existen otros tipos de funciones de Redux que se usan comúnmente.
Lectura de datos con selectores
Podemos llamar a store.getState() para obtener todo el objeto de estado raíz actual y acceder a sus campos como state.counter.value.
Es estándar escribir funciones "selector" que realicen esas búsquedas de campos de estado por nosotros. En este caso, counterSlice.ts exporta dos funciones selector que pueden reutilizarse:
// Selector functions allows us to select a value from the Redux root state.
// Selectors can also be defined inline in the `useSelector` call
// in a component, or inside the `createSlice.selectors` field.
export const selectCount = (state: RootState) => state.counter.value
export const selectStatus = (state: RootState) => state.counter.status
Las funciones selector normalmente se llaman con todo el objeto de estado raíz de Redux como argumento. Pueden leer valores específicos del estado raíz o hacer cálculos y devolver nuevos valores.
Como usamos TypeScript, también debemos usar el tipo RootState exportado desde store.ts para definir el tipo del argumento state en cada selector.
¡Nota que no tienes que crear funciones selector separadas para cada campo en cada segmento! (Este ejemplo en particular lo hizo para mostrar la idea de escribir selectores, pero solo teníamos dos campos en counterSlice.ts). En su lugar, encuentra un equilibrio en cuántos selectores escribes.
Aprenderemos más sobre funciones selector en Parte 4: Usando datos de Redux, y veremos cómo optimizarlos en Parte 6: Rendimiento.
Consulta Derivando datos con selectores para una visión más detallada de por qué y cómo usar funciones selector.
Escritura de lógica asíncrona con thunks
Hasta ahora, toda la lógica en nuestra aplicación ha sido síncrona. Las acciones se despachan, el almacén ejecuta los reductores y calcula el nuevo estado, y la función de despacho finaliza. Pero JavaScript tiene muchas formas de escribir código asíncrono, y nuestras aplicaciones normalmente contienen lógica asíncrona para acciones como obtener datos de una API. Necesitamos un lugar para esa lógica asíncrona en nuestras aplicaciones Redux.
Un thunk es un tipo específico de función Redux que puede contener lógica asíncrona. Los thunks se escriben usando dos funciones:
-
Una función thunk interna, que recibe
dispatchygetStatecomo argumentos -
La función creadora externa, que crea y devuelve la función thunk
La siguiente función exportada desde counterSlice es un ejemplo de creador de acciones thunk:
// The function below is called a thunk, which can contain both sync and async logic
// that has access to both `dispatch` and `getState`. They can be dispatched like
// a regular action: `dispatch(incrementIfOdd(10))`.
// Here's an example of conditionally dispatching actions based on current state.
export const incrementIfOdd = (amount: number): AppThunk => {
return (dispatch, getState) => {
const currentValue = selectCount(getState())
if (currentValue % 2 === 1) {
dispatch(incrementByAmount(amount))
}
}
}
En este thunk, usamos getState() para obtener el estado raíz actual del store, y dispatch() para enviar otra acción. También podríamos incluir fácilmente lógica asíncrona aquí, como un setTimeout o un await.
Podemos usarlos de la misma manera que usamos un creador de acciones típico de Redux:
store.dispatch(incrementIfOdd(6))
Usar thunks requiere que el middleware redux-thunk (un tipo de complemento para Redux) se agregue al store de Redux al crearlo. Afortunadamente, la función configureStore de Redux Toolkit ya lo configura automáticamente por nosotros, así que podemos usarlos directamente aquí.
Al escribir thunks, debemos asegurarnos de que los métodos dispatch y getState estén tipados correctamente. Podríamos definir la función thunk como (dispatch: AppDispatch, getState: () => RootState), pero es estándar definir un tipo reutilizable AppThunk para esto en el archivo del store.
Cuando necesitas hacer llamadas HTTP para obtener datos del servidor, puedes poner esa llamada en un thunk. Aquí tienes un ejemplo escrito de forma más extensa para que veas cómo se define:
// the outside "thunk creator" function
const fetchUserById = (userId: string): AppThunk => {
// the inside "thunk function"
return async (dispatch, getState) => {
try {
dispatch(userPending())
// make an async call in the thunk
const user = await userAPI.fetchById(userId)
// dispatch an action when we get the response back
dispatch(userLoaded(user))
} catch (err) {
// If something went wrong, handle it here
}
}
}
Redux Toolkit incluye un método createAsyncThunk que maneja todo el proceso de despacho por ti. La siguiente función en counterSlice.ts es un thunk asíncrono que simula una petición API con un valor de contador. Cuando despachamos este thunk, primero enviará una acción pending antes de hacer la petición, y luego una acción fulfilled o rejected cuando termine la lógica asíncrona.
// Thunks are commonly used for async logic like fetching data.
// The `createAsyncThunk` method is used to generate thunks that
// dispatch pending/fulfilled/rejected actions based on a promise.
// In this example, we make a mock async request and return the result.
// The `createSlice.extraReducers` field can handle these actions
// and update the state with the results.
export const incrementAsync = createAsyncThunk(
'counter/fetchCount',
async (amount: number) => {
const response = await fetchCount(amount)
// The value we return becomes the `fulfilled` action payload
return response.data
}
)
Cuando usas createAsyncThunk, manejas sus acciones en createSlice.extraReducers. En este caso, manejamos los tres tipos de acciones, actualizamos el campo status y también el campo value:
export const counterSlice = createSlice({
name: 'counter',
initialState,
reducers: {
// omit reducers
},
// The `extraReducers` field lets the slice handle actions defined elsewhere,
// including actions generated by createAsyncThunk or in other slices.
extraReducers: builder => {
builder
// Handle the action types defined by the `incrementAsync` thunk defined below.
// This lets the slice reducer update the state with request status and results.
.addCase(incrementAsync.pending, state => {
state.status = 'loading'
})
.addCase(incrementAsync.fulfilled, (state, action) => {
state.status = 'idle'
state.value += action.payload
})
.addCase(incrementAsync.rejected, state => {
state.status = 'failed'
})
}
})
Si tienes curiosidad sobre por qué usamos thunks para lógica asíncrona, consulta esta explicación detallada:
Detailed Explanation: Thunks and Async Logic
We know that we're not allowed to put any kind of async logic in reducers. But, that logic has to live somewhere.
If we had access to the Redux store, we could write some async code and call store.dispatch() when we're done:
const store = configureStore({ reducer: counterReducer })
setTimeout(() => {
store.dispatch(increment())
}, 250)
But, in a real Redux app, we're not allowed to import the store into other files, especially in our React components, because it makes that code harder to test and reuse.
In addition, we often need to write some async logic that we know will be used with some store, eventually, but we don't know which store.
The Redux store can be extended with "middleware", which are a kind of add-on or plugin that can add extra abilities. The most common reason to use middleware is to let you write code that can have async logic, but still talk to the store at the same time. They can also modify the store so that we can call dispatch() and pass in values that are not plain action objects, like functions or Promises.
The Redux Thunk middleware modifies the store to let you pass functions into dispatch. In fact, it's short enough we can paste it here:
const thunkMiddleware =
({ dispatch, getState }) =>
next =>
action => {
if (typeof action === 'function') {
return action(dispatch, getState)
}
return next(action)
}
It looks to see if the "action" that was passed into dispatch is actually a function instead of a plain action object. If it's actually a function, it calls the function, and returns the result. Otherwise, since this must be an action object, it passes the action forward to the store.
This gives us a way to write whatever sync or async code we want, while still having access to dispatch and getState.
Veremos thunks en uso en Parte 5: Lógica asíncrona y obtención de datos
Consulta la documentación de Redux Thunk, el artículo ¿Qué diablos es un thunk? y la entrada de FAQ de Redux sobre "¿por qué usamos middleware para asincronía?" para más información.
El componente React Counter
Anteriormente, vimos cómo se ve un componente React <Counter> independiente. Nuestra aplicación React+Redux tiene un componente <Counter> similar, pero hace algunas cosas de manera diferente.
Comenzaremos observando el archivo del componente Counter.tsx:
import { useState } from 'react'
// Use pre-typed versions of the React-Redux
// `useDispatch` and `useSelector` hooks
import { useAppDispatch, useAppSelector } from '@/app/hooks'
import {
decrement,
increment,
incrementAsync,
incrementByAmount,
incrementIfOdd,
selectCount,
selectStatus
} from './counterSlice'
import styles from './Counter.module.css'
export function Counter() {
const dispatch = useAppDispatch()
const count = useAppSelector(selectCount)
const status = useAppSelector(selectStatus)
const [incrementAmount, setIncrementAmount] = useState('2')
const incrementValue = Number(incrementAmount) || 0
return (
<div>
<div className={styles.row}>
<button
className={styles.button}
aria-label="Decrement value"
onClick={() => {
dispatch(decrement())
}}
>
-
</button>
<span aria-label="Count" className={styles.value}>
{count}
</span>
<button
className={styles.button}
aria-label="Increment value"
onClick={() => {
dispatch(increment())
}}
>
+
</button>
{/* omit additional rendering output here */}
</div>
</div>
)
}
Como en el ejemplo anterior de React puro, tenemos un componente funcional llamado Counter, que almacena algunos datos en un hook useState.
Sin embargo, en nuestro componente, parece que no estamos almacenando el valor actual del contador como estado. Hay una variable llamada count, pero no proviene de un hook useState.
Aunque React incluye varios hooks incorporados como useState y useEffect, otras bibliotecas pueden crear sus propios hooks personalizados que utilizan los hooks de React para construir lógica personalizada.
La biblioteca React-Redux tiene un conjunto de hooks personalizados que permiten a tu componente React interactuar con un store de Redux.
Lectura de datos con useSelector
En primer lugar, el hook useSelector permite que nuestro componente extraiga los fragmentos de datos que necesite del estado de la tienda Redux.
Anteriormente, vimos que podemos escribir funciones "selector", que toman state como argumento y devuelven una parte del valor del estado. En concreto, nuestro archivo counterSlice.ts exporta selectCount y selectStatus
Si tuviéramos acceso a una tienda Redux, podríamos obtener el valor actual del contador así:
const count = selectCount(store.getState())
console.log(count)
// 0
Nuestros componentes no pueden comunicarse directamente con la tienda Redux, porque no podemos importarla en archivos de componentes. Pero useSelector se encarga de interactuar con la tienda Redux en segundo plano. Si le pasamos una función selector, llama a someSelector(store.getState()) por nosotros y devuelve el resultado.
Por tanto, podemos obtener el valor actual del contador así:
const count = useSelector(selectCount)
Tampoco estamos limitados a usar solo selectores ya exportados. Por ejemplo, podríamos escribir una función selector como argumento inline de useSelector:
const countPlusTwo = useSelector((state: RootState) => state.counter.value + 2)
Cada vez que se despacha una acción y se actualiza la tienda Redux, useSelector volverá a ejecutar nuestra función selector. Si el selector devuelve un valor distinto al de la última vez, useSelector asegurará que nuestro componente se vuelva a renderizar con el nuevo valor.
Despachando acciones con useDispatch
Del mismo modo, sabemos que si tuviéramos acceso a una tienda Redux, podríamos despachar acciones usando creadores de acciones, como store.dispatch(increment()). Como no tenemos acceso a la tienda en sí, necesitamos una forma de acceder solo al método dispatch.
El hook useDispatch hace eso por nosotros y nos proporciona el método dispatch real de la tienda Redux:
const dispatch = useDispatch()
Desde ahí, podemos despachar acciones cuando el usuario hace algo como hacer clic en un botón:
<button
className={styles.button}
aria-label="Increment value"
onClick={() => {
dispatch(increment())
}}
>
+
</button>
Definiendo hooks pre-tipados de React-Redux
Por defecto, el hook useSelector requiere declarar (state: RootState) para cada función selector. Podemos crear versiones pre-tipadas de los hooks useSelector y useDispatch para no tener que repetir la parte : RootState cada vez.
import { useDispatch, useSelector } from 'react-redux'
import type { AppDispatch, RootState } from './store'
// Use throughout your app instead of plain `useDispatch` and `useSelector`
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
export const useAppSelector = useSelector.withTypes<RootState>()
Luego, podemos importar los hooks useAppSelector y useAppDispatch en nuestros componentes y usarlos en lugar de las versiones originales.
Estado del componente y formularios
A estas alturas te estarás preguntando: "¿Siempre tengo que poner todo el estado de mi aplicación en la tienda Redux?"
La respuesta es NO. El estado global necesario en toda la aplicación debe ir en la tienda Redux. El estado que solo se necesita en un lugar debe mantenerse en el estado del componente.
En este ejemplo, tenemos un campo de texto donde el usuario puede escribir el próximo número a sumar al contador:
const [incrementAmount, setIncrementAmount] = useState('2')
const incrementValue = Number(incrementAmount) || 0
// later
return (
<div className={styles.row}>
<input
className={styles.textbox}
aria-label="Set increment amount"
value={incrementAmount}
onChange={e => setIncrementAmount(e.target.value)}
/>
<button
className={styles.button}
onClick={() => dispatch(incrementByAmount(incrementValue))}
>
Add Amount
</button>
<button
className={styles.asyncButton}
onClick={() => dispatch(incrementAsync(incrementValue))}
>
Add Async
</button>
</div>
)
Podríamos mantener la cadena del número actual en la tienda Redux, despachando una acción en el handler onChange del input y guardándola en nuestro reducer. Pero eso no nos aporta ningún beneficio. El único lugar donde se usa esa cadena de texto es aquí, en el componente <Counter>. (Claro, en este ejemplo solo hay otro componente: <App>. Pero incluso en una aplicación más grande con muchos componentes, solo a <Counter> le importa este valor del input).
Por tanto, tiene sentido mantener ese valor en un hook useState dentro del componente <Counter>.
Del mismo modo, si tuviéramos un flag booleano llamado isDropdownOpen, a ningún otro componente de la app le importaría; debería permanecer local a este componente.
En una aplicación React + Redux, tu estado global debe ir en la tienda Redux, y tu estado local debe permanecer en los componentes React.
Si no estás seguro de dónde colocar algo, aquí tienes algunas reglas prácticas comunes para determinar qué tipo de datos deben ir en Redux:
-
¿A otras partes de la aplicación les importa este dato?
-
¿Necesitas poder crear datos derivados basados en esta información original?
-
¿Se usan los mismos datos para manejar múltiples componentes?
-
¿Tiene valor para ti poder restaurar este estado a un momento dado (p. ej., depuración viaje en el tiempo)?
-
¿Quieres almacenar en caché los datos (p. ej., usar lo que ya está en el estado en lugar de volver a solicitarlo)?
-
¿Quieres mantener estos datos consistentes durante la recarga en caliente de componentes (que pueden perder su estado interno al ser reemplazados)?
Este también es un buen ejemplo de cómo pensar en formularios con Redux en general. La mayoría del estado de formularios probablemente no debería guardarse en Redux. En su lugar, mantén los datos en tus componentes de formulario mientras se editan, y luego despacha acciones de Redux para actualizar el store cuando el usuario termine.
Otra cosa a destacar antes de continuar: ¿recuerdas el thunk incrementAsync de counterSlice.ts? Lo estamos usando aquí en este componente. Fíjate que lo utilizamos igual que despachamos los otros creadores de acciones normales. A este componente no le importa si estamos despachando una acción normal o iniciando lógica asíncrona. Solo sabe que al hacer clic en ese botón, se despacha algo.
Proporcionando el Store
Hemos visto que nuestros componentes pueden usar los hooks useSelector y useDispatch para comunicarse con el store de Redux. Pero, ¿cómo saben estos hooks con qué store de Redux deben comunicarse si no lo hemos importado?
Ahora que hemos visto todas las piezas de esta aplicación, es momento de volver al punto de partida y ver cómo encajan las últimas piezas del rompecabezas.
import React from 'react'
import { createRoot } from 'react-dom/client'
import { Provider } from 'react-redux'
import App from './App'
import { store } from './app/store'
import './index.css'
const container = document.getElementById('root')!
const root = createRoot(container)
root.render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>
)
Siempre debemos llamar a root.render(<App />) para decirle a React que comience a renderizar nuestro componente raíz <App>. Para que nuestros hooks como useSelector funcionen correctamente, necesitamos usar un componente llamado <Provider> que pasa el store de Redux detrás de escena para que puedan acceder a él.
Ya creamos nuestro store en app/store.ts, así que podemos importarlo aquí. Luego, envolvemos todo nuestro <App> con el componente <Provider> y le pasamos el store: <Provider store={store}>.
Ahora, cualquier componente React que llame a useSelector o useDispatch se comunicará con el store de Redux que le dimos al <Provider>.
Lo que has aprendido
Aunque la aplicación de contador es bastante pequeña, mostró todas las piezas clave de una app React + Redux funcionando juntas. Esto es lo que cubrimos:
- Podemos crear un store de Redux usando la API
configureStorede Redux ToolkitconfigureStoreacepta una funciónreducercomo argumento nombradoconfigureStoreconfigura automáticamente el store con ajustes predeterminados óptimos
- La lógica de Redux normalmente se organiza en archivos llamados "slices"
- Un "slice" contiene la lógica del reducer y las acciones relacionadas con una característica/sección específica del estado de Redux
- La API
createSlicede Redux Toolkit genera creadores de acciones y tipos de acción para cada función reductora que proporciones
- Los reducers de Redux deben seguir reglas específicas
- Solo deben calcular un nuevo valor de estado basado en los argumentos
stateyaction - Deben hacer actualizaciones inmutables copiando el estado existente
- No pueden contener lógica asíncrona ni otros "efectos secundarios"
- La API
createSlicede Redux Toolkit usa Immer para permitir actualizaciones inmutables "mutando" el estado
- Solo deben calcular un nuevo valor de estado basado en los argumentos
- La lectura de valores del estado se hace con funciones llamadas "selectors"
- Los selectors aceptan
(state: RootState)como argumento y devuelven un valor del estado o derivan uno nuevo - Los selectors pueden escribirse en archivos de slices o directamente en el hook
useSelector
- Los selectors aceptan
- La lógica asíncrona normalmente se escribe en funciones especiales llamadas "thunks"
- Los thunks reciben
dispatchygetStatecomo argumentos - Redux Toolkit habilita el middleware
redux-thunkpor defecto
- Los thunks reciben
- React-Redux permite que componentes React interactúen con un store de Redux
- Envolver la app con
<Provider store={store}>permite que todos los componentes usen el store - El hook
useSelectorpermite a componentes React leer valores del store de Redux - El hook
useDispatchpermite a los componentes despachar acciones - Para uso con TS, creamos hooks pre-tipados:
useAppSelectoryuseAppDispatch - El estado global debe ir en el store de Redux, el estado local debe permanecer en componentes React
- Envolver la app con
¿Qué sigue?
Ahora que has visto todas las piezas de una aplicación Redux en funcionamiento, ¡es hora de crear la tuya propia! En el resto de este tutorial, construirás una aplicación de ejemplo más grande que utiliza Redux. En el proceso, cubriremos todas las ideas clave que necesitas conocer para usar Redux correctamente.
Continúa con la Parte 3: Flujo de datos básico de Redux para empezar a construir la aplicación de ejemplo.