Ir al contenido principal

Conceptos Básicos de Redux, Parte 8: Redux Moderno con Redux Toolkit

Traducción Beta No Oficial

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

Qué aprenderás
  • Cómo simplificar tu lógica de Redux usando Redux Toolkit
  • Próximos pasos para aprender y usar Redux

¡Felicidades, has llegado a la última sección de este tutorial! Nos queda un tema más por cubrir antes de terminar.

Si quieres un recordatorio de lo que hemos cubierto hasta ahora, echa un vistazo a este resumen:

información

Recap: What You've Learned

  • Part 1: Overview:
    • what Redux is, when/why to use it, and the basic pieces of a Redux app
  • Part 2: Concepts and Data Flow:
    • How Redux uses a "one-way data flow" pattern
  • Part 3: State, Actions, and Reducers:
    • Redux state is made of plain JS data
    • Actions are objects that describe "what happened" events in an app
    • Reducers take current state and an action, and calculate a new state
    • Reducers must follow rules like "immutable updates" and "no side effects"
  • Part 4: Store:
    • The createStore API creates a Redux store with a root reducer function
    • Stores can be customized using "enhancers" and "middleware"
    • The Redux DevTools extension lets you see how your state changes over time
  • Part 5: UI and React:
    • Redux is separate from any UI, but frequently used with React
    • React-Redux provides APIs to let React components talk to Redux stores
    • useSelector reads values from Redux state and subscribes to updates
    • useDispatch lets components dispatch actions
    • <Provider> wraps your app and lets components access the store
  • Part 6: Async Logic and Data Fetching:
    • Redux middleware allow writing logic that has side effects
    • Middleware add an extra step to the Redux data flow, enabling async logic
    • Redux "thunk" functions are the standard way to write basic async logic
  • Part 7: Standard Redux Patterns:
    • Action creators encapsulate preparing action objects and thunks
    • Memoized selectors optimize calculating transformed data
    • Request status should be tracked with loading state enum values
    • Normalized state makes it easier to look up items by IDs

Como has visto, muchos aspectos de Redux implican escribir código que puede ser verboso, como actualizaciones inmutables, tipos de acción y creadores de acciones, y normalización del estado. Existen buenas razones por las que estos patrones existen, pero escribir ese código "manualmente" puede ser difícil. Además, el proceso de configurar un store de Redux requiere varios pasos, y hemos tenido que idear nuestra propia lógica para cosas como despachar acciones de "carga" en thunks o procesar datos normalizados. Finalmente, muchas veces los usuarios no están seguros de cuál es "la forma correcta" de escribir la lógica de Redux.

Por eso el equipo de Redux creó Redux Toolkit: nuestro conjunto de herramientas oficial, con opiniones definidas y "baterías incluidas" para el desarrollo eficiente de Redux.

Redux Toolkit contiene paquetes y funciones que consideramos esenciales para construir una aplicación Redux. Redux Toolkit incorpora nuestras mejores prácticas sugeridas, simplifica la mayoría de las tareas de Redux, previene errores comunes y facilita la escritura de aplicaciones Redux.

Debido a esto, Redux Toolkit es la forma estándar de escribir la lógica de aplicaciones Redux. La lógica de Redux "escrita a mano" que has escrito hasta ahora en este tutorial es código funcional real, pero no deberías escribir la lógica de Redux manualmente - hemos cubierto esos enfoques en este tutorial para que entiendas cómo funciona Redux. Sin embargo, para aplicaciones reales, deberías usar Redux Toolkit para escribir tu lógica de Redux.

Cuando utilizas Redux Toolkit, todos los conceptos que hemos cubierto hasta ahora (acciones, reductores, configuración del store, creadores de acciones, thunks, etc.) siguen existiendo, pero Redux Toolkit ofrece formas más sencillas de escribir ese código.

consejo

Redux Toolkit solo cubre la lógica de Redux; seguimos usando React-Redux para que nuestros componentes de React se comuniquen con el store de Redux, incluyendo useSelector y useDispatch.

Veamos entonces cómo podemos usar Redux Toolkit para simplificar el código que ya hemos escrito en nuestra aplicación de ejemplo de tareas. Principalmente reescribiremos nuestros archivos de "slice", pero deberíamos poder mantener todo el código de la interfaz de usuario igual.

Antes de continuar, añade el paquete de Redux Toolkit a tu aplicación:

npm install @reduxjs/toolkit

Configuración del Store

Hemos pasado por varias iteraciones en la lógica de configuración de nuestro store de Redux. Actualmente tiene este aspecto:

src/rootReducer.js
import { combineReducers } from 'redux'

import todosReducer from './features/todos/todosSlice'
import filtersReducer from './features/filters/filtersSlice'

const rootReducer = combineReducers({
// Define a top-level state field named `todos`, handled by `todosReducer`
todos: todosReducer,
filters: filtersReducer
})

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

const composedEnhancer = composeWithDevTools(applyMiddleware(thunk))

const store = createStore(rootReducer, composedEnhancer)
export default store

Observa que el proceso de configuración requiere varios pasos. Debemos:

  • Combinar los reductores de slice para formar el reducer raíz

  • Importar el reducer raíz en el archivo del store

  • Importar el middleware thunk, las APIs applyMiddleware y composeWithDevTools

  • Crear un potenciador del store con el middleware y las devtools

  • Crear el store con el reducer raíz

Sería ideal si pudiéramos reducir el número de pasos aquí.

Usando configureStore

Redux Toolkit tiene una API configureStore que simplifica la configuración del store. configureStore envuelve la API createStore del núcleo de Redux y maneja automáticamente la mayor parte de la configuración. De hecho, podemos reducirlo a un solo paso:

src/store.js
import { configureStore } from '@reduxjs/toolkit'

import todosReducer from './features/todos/todosSlice'
import filtersReducer from './features/filters/filtersSlice'

const store = configureStore({
reducer: {
// Define a top-level state field named `todos`, handled by `todosReducer`
todos: todosReducer,
filters: filtersReducer
}
})

export default store

Esta única llamada a configureStore hizo todo el trabajo por nosotros:

  • Combinó todosReducer y filtersReducer en la función del reducer raíz, que manejará un estado raíz con la forma {todos, filters}

  • Creó un store de Redux usando ese reducer raíz

  • Añadió automáticamente el middleware thunk

  • Añadió automáticamente middleware adicional para detectar errores comunes como mutar accidentalmente el estado

  • Configuró automáticamente la conexión con la extensión Redux DevTools

Podemos confirmar que funciona abriendo nuestra aplicación de ejemplo y usándola. ¡Todo nuestro código existente funciona correctamente! Como estamos despachando acciones, despachando thunks, leyendo el estado en la interfaz y viendo el historial de acciones en las DevTools, todas esas partes deben estar funcionando. Solo hemos cambiado el código de configuración del store.

Veamos qué pasa ahora si mutamos accidentalmente parte del estado. ¿Y si cambiamos el reducer de "carga de todos" para que modifique directamente el campo de estado, en lugar de hacer una copia inmutable?

src/features/todos/todosSlice
export default function todosReducer(state = initialState, action) {
switch (action.type) {
// omit other cases
case 'todos/todosLoading': {
// ❌ WARNING: example only - don't do this in a normal reducer!
state.status = 'loading'
return state
}
default:
return state
}
}

¡Vaya! Toda nuestra aplicación se ha estrellado. ¿Qué ha pasado?

Error del middleware de comprobación de inmutabilidad

Este mensaje de error es algo positivo: ¡hemos detectado un error en nuestra aplicación! configureStore añadió específicamente un middleware extra que lanza automáticamente un error cuando detecta una mutación accidental de nuestro estado (solo en modo desarrollo). Esto ayuda a detectar errores que podríamos cometer al escribir nuestro código.

Limpieza de paquetes

Redux Toolkit ya incluye varios paquetes que estamos usando, como redux, redux-thunk y reselect, y reexporta esas APIs. Por tanto, podemos limpiar un poco nuestro proyecto.

Primero, podemos cambiar nuestra importación de createSelector para que sea desde '@reduxjs/toolkit' en lugar de 'reselect'. Luego, podemos eliminar los paquetes separados que tenemos listados en nuestro package.json:

npm uninstall redux redux-thunk reselect

Para ser claros, seguimos usando estos paquetes y necesitamos instalarlos. Sin embargo, como Redux Toolkit depende de ellos, se instalarán automáticamente al instalar @reduxjs/toolkit, por lo que no necesitamos listarlos explícitamente en nuestro archivo package.json.

Escribiendo Slices

A medida que añadimos nuevas funcionalidades a nuestra app, los archivos de slices se han vuelto más grandes y complejos. En particular, el todosReducer se ha vuelto más difícil de leer debido a todos los spreads anidados para actualizaciones inmutables, y hemos escrito múltiples funciones creadoras de acciones.

Redux Toolkit incluye una API createSlice que nos ayuda a simplificar nuestra lógica de reducers y acciones. createSlice hace varias cosas importantes por nosotros:

  • Podemos escribir los case reducers como funciones dentro de un objeto, en lugar de usar switch/case

  • Los reducers pueden escribir lógica de actualización inmutable más corta

  • Todas las creadoras de acciones se generarán automáticamente basándose en las funciones reducer proporcionadas

Usando createSlice

createSlice recibe un objeto con tres campos principales:

  • name: cadena que se usará como prefijo para los tipos de acción generados

  • initialState: estado inicial del reducer

  • reducers: objeto donde las claves son cadenas y los valores son funciones "case reducer" que manejarán acciones específicas

Primero veamos un pequeño ejemplo independiente.

createSlice example
import { createSlice } from '@reduxjs/toolkit'

const initialState = {
entities: [],
status: null
}

const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
todoAdded(state, action) {
// ✅ This "mutating" code is okay inside of createSlice!
state.entities.push(action.payload)
},
todoToggled(state, action) {
const todo = state.entities.find(todo => todo.id === action.payload)
todo.completed = !todo.completed
},
todosLoading(state, action) {
return {
...state,
status: 'loading'
}
}
}
})

export const { todoAdded, todoToggled, todosLoading } = todosSlice.actions

export default todosSlice.reducer

Hay varios aspectos importantes en este ejemplo:

  • Escribimos funciones case reducer dentro del objeto reducers con nombres legibles

  • createSlice generará automáticamente creadoras de acciones correspondientes a cada función case reducer

  • createSlice devuelve automáticamente el estado existente en el caso por defecto

  • createSlice nos permite "mutar" el estado de forma segura

  • Pero también podemos hacer copias inmutables como antes si lo preferimos

Las creadoras de acciones generadas estarán disponibles como slice.actions.todoAdded, y normalmente las desestructuramos y exportamos individualmente como hicimos antes. El reducer completo está disponible como slice.reducer, y normalmente hacemos export default slice.reducer, igual que antes.

¿Cómo son estos objetos de acción generados automáticamente? Probemos llamar a uno y loguearlo:

console.log(todoToggled(42))
// {type: 'todos/todoToggled', payload: 42}

createSlice generó el tipo de acción combinando el campo name del slice con el nombre todoToggled de la función reducer. Por defecto, la creadora de acciones acepta un argumento que coloca en action.payload.

Dentro de la función reducer generada, createSlice verifica si action.type de una acción despachada coincide con alguno de los nombres generados. Si es así, ejecuta esa función case reducer. Es exactamente el mismo patrón que escribíamos manualmente con switch/case, pero createSlice lo hace automáticamente.

También vale la pena profundizar en el aspecto de "mutación".

Actualizaciones Inmutables con Immer

Anteriormente, hablamos sobre "mutación" (modificar valores existentes de objetos/arrays) e "inmutabilidad" (tratar valores como algo que no puede cambiarse).

advertencia

En Redux, ¡los reducers nunca pueden mutar los valores de estado originales!

// ❌ Illegal - by default, this will mutate the state!
state.value = 123

Entonces, si no podemos cambiar los originales, ¿cómo devolver un estado actualizado?

consejo

Los reducers 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
}

Como has visto en este tutorial, podemos escribir actualizaciones inmutables manualmente usando operadores de propagación de arrays/objetos de JavaScript y otras funciones que devuelven copias de los valores originales. Sin embargo, escribir lógica de actualización inmutable a mano es difícil, y mutar accidentalmente el estado en los 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:

advertencia

¡Solo puedes escribir lógica de "mutación" en createSlice y createReducer de Redux Toolkit porque usan Immer internamente! Si escribes lógica de mutación en reductores sin Immer, ¡mutarás el estado y causarás errores!

Immer aún nos permite escribir actualizaciones inmutables manualmente y devolver el nuevo valor si queremos. Incluso podemos combinar ambos enfoques. Por ejemplo, eliminar un elemento de un array suele ser más fácil con array.filter(), así que podrías llamar a esa función y luego asignar el resultado a state para "mutarlo":

// can mix "mutating" and "immutable" code inside of Immer:
state.todos = state.todos.filter(todo => todo.id !== action.payload)

Conversión del reductor de tareas

Empecemos convirtiendo nuestro archivo de slice de tareas para usar createSlice en su lugar. Primero seleccionaremos un par de casos específicos de nuestra sentencia switch para mostrar cómo funciona el proceso.

src/features/todos/todosSlice.js
import { createSlice } from '@reduxjs/toolkit'

const initialState = {
status: 'idle',
entities: {}
}

const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
todoAdded(state, action) {
const todo = action.payload
state.entities[todo.id] = todo
},
todoToggled(state, action) {
const todoId = action.payload
const todo = state.entities[todoId]
todo.completed = !todo.completed
}
}
})

export const { todoAdded, todoToggled } = todosSlice.actions

export default todosSlice.reducer

El reductor de tareas en nuestra aplicación de ejemplo aún usa un estado normalizado anidado en un objeto padre, así que el código aquí difiere un poco del ejemplo mínimo de createSlice que vimos antes. ¿Recuerdas cómo teníamos que escribir muchos operadores de propagación anidados para cambiar una tarea anteriormente? Ahora ese mismo código es mucho más corto y fácil de leer.

Añadamos un par de casos más a este reductor.

src/features/todos/todosSlice.js
const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
todoAdded(state, action) {
const todo = action.payload
state.entities[todo.id] = todo
},
todoToggled(state, action) {
const todoId = action.payload
const todo = state.entities[todoId]
todo.completed = !todo.completed
},
todoColorSelected: {
reducer(state, action) {
const { color, todoId } = action.payload
state.entities[todoId].color = color
},
prepare(todoId, color) {
return {
payload: { todoId, color }
}
}
},
todoDeleted(state, action) {
delete state.entities[action.payload]
}
}
})

export const { todoAdded, todoToggled, todoColorSelected, todoDeleted } =
todosSlice.actions

export default todosSlice.reducer

Los creadores de acciones para todoAdded y todoToggled solo necesitan un parámetro, como un objeto de tarea completo o un ID. Pero, ¿qué pasa si necesitamos pasar múltiples parámetros o realizar parte de esa lógica de "preparación" que mencionamos, como generar un ID único?

createSlice nos permite manejar estas situaciones añadiendo un "prepare callback" al reductor. Podemos pasar un objeto con funciones llamadas reducer y prepare. Cuando llamemos al creador de acciones generado, la función prepare recibirá los parámetros pasados. Debe crear y devolver un objeto con un campo payload (y opcionalmente meta y error), siguiendo la convención Flux Standard Action.

Aquí hemos usado un prepare callback para que nuestro creador de acciones todoColorSelected acepte argumentos separados todoId y color, combinándolos como un objeto en action.payload.

Mientras tanto, en el reductor todoDeleted, podemos usar el operador delete de JS para eliminar elementos de nuestro estado normalizado.

Podemos usar estos mismos patrones para reescribir el resto de nuestros reductores en todosSlice.js y filtersSlice.js.

Así es como se ve nuestro código con todos los slices convertidos:

Escritura de thunks

Hemos visto cómo escribir thunks que despachan acciones de "cargando", "éxito" y "fallo". Teníamos que escribir creadores de acciones, tipos de acción y reductores para manejar estos casos.

Como este patrón es muy común, Redux Toolkit tiene una API createAsyncThunk que genera estos thunks por nosotros. También genera los tipos de acción y creadores de acciones para los diferentes estados de petición, y despacha esas acciones automáticamente según la Promesa resultante.

consejo

Redux Toolkit incluye una nueva API de obtención de datos RTK Query. RTK Query es una solución específica para obtener y almacenar datos en caché en apps Redux, y puede eliminar la necesidad de escribir ningún thunk o reducer para gestionar la obtención de datos. ¡Te animamos a probarlo y ver si simplifica tu código de obtención de datos!

Pronto actualizaremos los tutoriales de Redux para incluir secciones sobre RTK Query. Hasta entonces, consulta la sección de RTK Query en la documentación de Redux Toolkit.

Uso de createAsyncThunk

Reemplacemos nuestro thunk fetchTodos generando un thunk con createAsyncThunk.

createAsyncThunk acepta dos argumentos:

  • Una cadena que se usará como prefijo para los tipos de acción generados

  • Una función callback "creadora de payload" que debe devolver una Promesa. Suele escribirse con sintaxis async/await, ya que las funciones async devuelven automáticamente una promesa.

src/features/todos/todosSlice.js
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'
})
}
})

// omit exports

Pasamos 'todos/fetchTodos' como prefijo de cadena y una función "creadora de payload" que llama a nuestra API y devuelve una promesa con los datos obtenidos. Internamente, createAsyncThunk generará tres creadores de acciones y tipos de acción, además de una función thunk que despacha automáticamente esas acciones al ser llamada. En este caso, los creadores de acciones y sus tipos son:

  • fetchTodos.pending: todos/fetchTodos/pending

  • fetchTodos.fulfilled: todos/fetchTodos/fulfilled

  • fetchTodos.rejected: todos/fetchTodos/rejected

Sin embargo, estos creadores de acciones y tipos se definen fuera de la llamada a createSlice. No podemos manejarlos dentro del campo createSlice.reducers, porque esos también generan nuevos tipos de acción. Necesitamos una forma para que nuestra llamada a createSlice escuche otros tipos de acción definidos en otro lugar.

createSlice también acepta una opción extraReducers, donde podemos hacer que el mismo reducer del slice escuche otros tipos de acción. Este campo debe ser una función callback con un parámetro builder, y podemos llamar a builder.addCase(actionCreator, caseReducer) para escuchar otras acciones.

Entonces, aquí hemos llamado a builder.addCase(fetchTodos.pending, caseReducer). Cuando se despacha esa acción, ejecutaremos el reducer que establece state.status = 'loading', igual que antes cuando escribimos esa lógica en una sentencia switch. Podemos hacer lo mismo para fetchTodos.fulfilled y manejar los datos recibidos de la API.

Como un ejemplo más, convirtamos saveNewTodo. Este thunk toma el text del nuevo objeto todo como parámetro y lo guarda en el servidor. ¿Cómo manejamos eso?

src/features/todos/todosSlice.js
// omit imports

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

export const saveNewTodo = createAsyncThunk('todos/saveNewTodo', async text => {
const initialTodo = { text }
const response = await client.post('/fakeApi/todos', { todo: initialTodo })
return response.todo
})

const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
// omit case reducers
},
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'
})
.addCase(saveNewTodo.fulfilled, (state, action) => {
const todo = action.payload
state.entities[todo.id] = todo
})
}
})

// omit exports and selectors

El proceso para saveNewTodo es el mismo que vimos para fetchTodos. Llamamos a createAsyncThunk, y pasamos el prefijo de acción y un creador de payload. Dentro del creador de payload, hacemos una llamada API asíncrona y devolvemos un valor de resultado.

En este caso, cuando llamamos a dispatch(saveNewTodo(text)), el valor text se pasará al creador de payload como su primer argumento.

Aunque no cubriremos createAsyncThunk con más detalle aquí, dejamos algunas notas rápidas de referencia:

  • Solo puedes pasar un argumento al thunk cuando lo despachas. Si necesitas pasar múltiples valores, hazlo en un único objeto.

  • El creador de payload recibirá un objeto como segundo argumento, que contiene {getState, dispatch}, y otros valores útiles.

  • El thunk despacha la acción pending antes de ejecutar tu creador de payload, luego despacha fulfilled o rejected dependiendo de si la Promesa que devuelves tiene éxito o falla.

Normalización del Estado

Anteriormente vimos cómo "normalizar" el estado, almacenando elementos en un objeto indexado por sus IDs. Esto nos permite buscar cualquier elemento por su ID sin recorrer arrays completos. Sin embargo, escribir manualmente la lógica para actualizar estados normalizados era largo y tedioso. Escribir código de actualización "mutante" con Immer lo simplifica, pero aún puede haber mucha repetición: podríamos cargar muchos tipos de elementos en nuestra app y repetiríamos la misma lógica de reducers cada vez.

Redux Toolkit incluye una API createEntityAdapter con reducers preconstruidos para operaciones típicas de datos con estados normalizados. Esto incluye añadir, actualizar y eliminar elementos de un slice. createEntityAdapter también genera selectores memorizados para leer valores del store.

Usando createEntityAdapter

Reemplacemos nuestra lógica de reducers normalizada con createEntityAdapter.

Al llamar a createEntityAdapter obtenemos un objeto "adaptador" con varios reducers predefinidos, incluyendo:

  • addOne / addMany: añadir nuevos elementos al estado

  • upsertOne / upsertMany: añadir nuevos elementos o actualizar existentes

  • updateOne / updateMany: actualizar elementos existentes con valores parciales

  • removeOne / removeMany: eliminar elementos por IDs

  • setAll: reemplazar todos los elementos existentes

Podemos usar estas funciones como case reducers o como "ayudantes mutantes" dentro de createSlice.

El adaptador también incluye:

  • getInitialState: devuelve un objeto como { ids: [], entities: {} } para almacenar estados normalizados con arrays de IDs

  • getSelectors: genera un conjunto estándar de funciones selectoras

Veamos cómo usarlos en nuestro slice de tareas:

src/features/todos/todosSlice.js
import {
createSlice,
createAsyncThunk,
createEntityAdapter
} from '@reduxjs/toolkit'
// omit some imports

const todosAdapter = createEntityAdapter()

const initialState = todosAdapter.getInitialState({
status: 'idle'
})

// omit thunks

const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
// omit some reducers
// Use an adapter reducer function to remove a todo by ID
todoDeleted: todosAdapter.removeOne,
completedTodosCleared(state, action) {
const completedIds = Object.values(state.entities)
.filter(todo => todo.completed)
.map(todo => todo.id)
// Use an adapter function as a "mutating" update helper
todosAdapter.removeMany(state, completedIds)
}
},
extraReducers: builder => {
builder
.addCase(fetchTodos.pending, (state, action) => {
state.status = 'loading'
})
.addCase(fetchTodos.fulfilled, (state, action) => {
todosAdapter.setAll(state, action.payload)
state.status = 'idle'
})
// Use another adapter function as a reducer to add a todo
.addCase(saveNewTodo.fulfilled, todosAdapter.addOne)
}
})

// omit selectors

Las funciones del adaptador toman diferentes valores en action.payload según la operación. Las funciones "add" y "upsert" toman un elemento o array de elementos, las "remove" toman IDs individuales o arrays, etc.

getInitialState permite añadir campos de estado adicionales. Aquí hemos añadido status, dando un estado final del slice como {ids, entities, status}, similar al anterior.

También podemos reemplazar algunos selectores. getSelectors genera selectores como selectAll (devuelve array completo de elementos) y selectById (devuelve un elemento). Como getSelectors no sabe dónde están nuestros datos en el estado global, debemos pasarle un selector que devuelva este slice. Usemos estos selectores en lugar de los nuestros. Como es el último cambio importante, mostramos el archivo completo del slice usando Redux Toolkit:

src/features/todos/todosSlice.js
import {
createSlice,
createSelector,
createAsyncThunk,
createEntityAdapter
} from '@reduxjs/toolkit'
import { client } from '../../api/client'
import { StatusFilters } from '../filters/filtersSlice'

const todosAdapter = createEntityAdapter()

const initialState = todosAdapter.getInitialState({
status: 'idle'
})

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

export const saveNewTodo = createAsyncThunk('todos/saveNewTodo', async text => {
const initialTodo = { text }
const response = await client.post('/fakeApi/todos', { todo: initialTodo })
return response.todo
})

const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
todoToggled(state, action) {
const todoId = action.payload
const todo = state.entities[todoId]
todo.completed = !todo.completed
},
todoColorSelected: {
reducer(state, action) {
const { color, todoId } = action.payload
state.entities[todoId].color = color
},
prepare(todoId, color) {
return {
payload: { todoId, color }
}
}
},
todoDeleted: todosAdapter.removeOne,
allTodosCompleted(state, action) {
Object.values(state.entities).forEach(todo => {
todo.completed = true
})
},
completedTodosCleared(state, action) {
const completedIds = Object.values(state.entities)
.filter(todo => todo.completed)
.map(todo => todo.id)
todosAdapter.removeMany(state, completedIds)
}
},
extraReducers: builder => {
builder
.addCase(fetchTodos.pending, (state, action) => {
state.status = 'loading'
})
.addCase(fetchTodos.fulfilled, (state, action) => {
todosAdapter.setAll(state, action.payload)
state.status = 'idle'
})
.addCase(saveNewTodo.fulfilled, todosAdapter.addOne)
}
})

export const {
allTodosCompleted,
completedTodosCleared,
todoAdded,
todoColorSelected,
todoDeleted,
todoToggled
} = todosSlice.actions

export default todosSlice.reducer

export const { selectAll: selectTodos, selectById: selectTodoById } =
todosAdapter.getSelectors(state => state.todos)

export const selectTodoIds = createSelector(
// First, pass one or more "input selector" functions:
selectTodos,
// Then, an "output selector" that receives all the input results as arguments
// and returns a final result value
todos => todos.map(todo => todo.id)
)

export const selectFilteredTodos = createSelector(
// First input selector: all todos
selectTodos,
// Second input selector: all filter values
state => state.filters,
// Output selector: receives both values
(todos, filters) => {
const { status, colors } = filters
const showAllCompletions = status === StatusFilters.All
if (showAllCompletions && colors.length === 0) {
return todos
}

const completedStatus = status === StatusFilters.Completed
// Return either active or completed todos based on filter
return todos.filter(todo => {
const statusMatches =
showAllCompletions || todo.completed === completedStatus
const colorMatches = colors.length === 0 || colors.includes(todo.color)
return statusMatches && colorMatches
})
}
)

export const selectFilteredTodoIds = createSelector(
// Pass our other memoized selector as an input
selectFilteredTodos,
// And derive data in the output selector
filteredTodos => filteredTodos.map(todo => todo.id)
)

Llamamos a todosAdapter.getSelectors pasando state => state.todos como selector. El adaptador genera un selector selectAll que toma el estado completo de Redux, recorre state.todos.entities y state.todos.ids, y devuelve el array completo de tareas. Como selectAll no especifica qué selecciona, usamos destructuring para renombrarlo a selectTodos. Similarmente, renombramos selectById a selectTodoById.

Observa que nuestros otros selectores siguen usando selectTodos como entrada. Esto se debe a que sigue devolviendo un array de objetos todo todo el tiempo, independientemente de si guardábamos el array completo en state.todos, como un array anidado, o como un objeto normalizado que convertimos a array. A pesar de todos estos cambios en cómo almacenamos los datos, el uso de selectores permitió mantener el resto del código igual, y los selectores memoizados mejoraron el rendimiento de la UI evitando rerenders innecesarios.

Lo que has aprendido

¡Enhorabuena! ¡Has completado el tutorial de "Fundamentos de Redux"!

Ahora deberías entender sólidamente qué es Redux, cómo funciona y cómo usarlo correctamente:

  • Gestión del estado global de aplicaciones

  • Mantener el estado de nuestra app como datos JS planos

  • Escribir objetos de acción que describan "qué ocurrió" en la app

  • Usar funciones reductoras que analicen el estado actual y una acción, y creen un nuevo estado inmutabilmente

  • Leer el estado de Redux en componentes React con useSelector

  • Despachar acciones desde componentes React con useDispatch

Además, has visto cómo Redux Toolkit simplifica la escritura de lógica Redux, y por qué Redux Toolkit es el enfoque estándar para aplicaciones Redux reales. Al ver primero cómo escribir código Redux "manualmente", queda claro qué hacen APIs como createSlice de Redux Toolkit, evitando que tengas que escribir ese código tú mismo.

información

Para más información sobre Redux Toolkit, incluyendo guías de uso y referencias API, consulta:

Echemos un vistazo final a la aplicación todo completa, incluyendo todo el código convertido para usar Redux Toolkit:

Y haremos un resumen final de los puntos clave aprendidos en esta sección:

Resumen
  • Redux Toolkit (RTK) es la forma estándar de escribir lógica Redux
    • RTK incluye APIs que simplifican la mayoría del código Redux
    • RTK envuelve el núcleo de Redux e incluye otros paquetes útiles
  • configureStore configura un store Redux con buenos valores por defecto
    • Combina automáticamente reductores de slices para crear el reductor raíz
    • Configura automáticamente la extensión Redux DevTools y middleware de depuración
  • createSlice simplifica la escritura de acciones y reductores Redux
    • Genera automáticamente creadores de acciones basados en nombres de slices/reductores
    • Los reductores pueden "mutar" el estado dentro de createSlice usando Immer
  • createAsyncThunk genera thunks para llamadas asíncronas
    • Genera automáticamente un thunk + creadores de acciones pending/fulfilled/rejected
    • Despachar el thunk ejecuta tu creador de payload y despacha las acciones
    • Las acciones thunk pueden manejarse en createSlice.extraReducers
  • createEntityAdapter proporciona reductores + selectores para estado normalizado
    • Incluye funciones reductoras para tareas comunes como añadir/actualizar/eliminar items
    • Genera selectores memoizados para selectAll y selectById

Siguientes Pasos para Aprender y Usar Redux

Ahora que has completado este tutorial, tenemos varias sugerencias sobre qué deberías intentar después para aprender más sobre Redux.

Este tutorial de "Fundamentos" se centró en aspectos de bajo nivel de Redux: escribir tipos de acción y actualizaciones inmutables manualmente, cómo funcionan un store Redux y el middleware, y por qué usamos patrones como creadores de acción y estado normalizado. Además, nuestra app de ejemplo todo es bastante pequeña y no pretende ser un ejemplo realista de construcción de una aplicación completa.

Sin embargo, nuestro tutorial "Conceptos Esenciales de Redux" te enseña específicamente cómo construir una aplicación del mundo real. Se enfoca en "cómo usar Redux correctamente" utilizando Redux Toolkit y explica patrones más realistas que verás en aplicaciones más grandes. Cubre muchos temas similares a este tutorial de "Fundamentos", como por qué los reducers necesitan actualizaciones inmutables, pero con un enfoque en construir una aplicación funcional real. Recomendamos encarecidamente que leas el tutorial "Conceptos Esenciales de Redux" como próximo paso.

Al mismo tiempo, los conceptos que hemos cubierto en este tutorial deberían ser suficientes para que empieces a construir tus propias aplicaciones con React y Redux. Ahora es un excelente momento para trabajar en un proyecto propio y consolidar estos conceptos viendo cómo funcionan en la práctica. Si no estás seguro de qué tipo de proyecto construir, consulta esta lista de ideas para proyectos para inspirarte.

La sección Usando Redux contiene información sobre varios conceptos importantes, como cómo estructurar tus reducers, y nuestra Guía de Estilo incluye información crucial sobre patrones recomendados y mejores prácticas.

Si quieres saber más sobre por qué existe Redux, qué problemas intenta resolver y cómo está diseñado para usarse, consulta las publicaciones del mantenedor de Redux Mark Erikson sobre El Tao de Redux, Parte 1: Implementación e intención y El Tao de Redux, Parte 2: Práctica y filosofía.

Si necesitas ayuda con dudas sobre Redux, únete al canal #redux en el servidor de Discord de Reactiflux.

¡Gracias por leer este tutorial y esperamos que disfrutes construyendo aplicaciones con Redux!