Ir al contenido principal

Fundamentos de Redux, Parte 7: Patrones Estándar en Redux

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
  • Patrones estándar usados en apps reales de Redux, y por qué existen:
    • Creadores de acciones para encapsular objetos de acción
    • Selectores memoizados para mejorar rendimiento
    • Seguimiento de estado de peticiones mediante enumeraciones de carga
    • Normalización de estado para gestionar colecciones de elementos
    • Trabajo con promesas y thunks
Requisitos previos
  • Comprensión de los temas de todas las secciones anteriores

En la Parte 6: Lógica Asíncrona y Obtención de Datos, vimos cómo usar middleware de Redux para escribir lógica asíncrona que interactúa con el store. En particular, usamos el middleware "thunk" de Redux para escribir funciones que contienen lógica asíncrona reusable, sin conocer de antemano con qué store de Redux interactuarán.

Hasta ahora, hemos cubierto los fundamentos de cómo funciona realmente Redux. Sin embargo, las aplicaciones reales de Redux utilizan algunos patrones adicionales sobre esos fundamentos.

Es importante destacar que ¡ninguno de estos patrones es obligatorio para usar Redux! Pero existen muy buenas razones por las que cada uno existe, y verás algunos o todos ellos en casi todas las bases de código de Redux.

En esta sección, reestructuraremos nuestro código existente de la app de tareas para usar algunos de estos patrones, y hablaremos sobre por qué son comunes en aplicaciones Redux. Luego, en la Parte 8, hablaremos sobre "Redux moderno", incluyendo cómo usar nuestro paquete oficial Redux Toolkit para simplificar toda la lógica de Redux escrita "manualmente" en nuestra app, y por qué recomendamos usar Redux Toolkit como enfoque estándar para escribir apps con Redux.

Traducción Beta No Oficial

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

precaución

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

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

Creadores de Acciones

En nuestra app, hemos estado escribiendo objetos de acción directamente en el código donde se despachan:

dispatch({ type: 'todos/todoAdded', payload: trimmedText })

Sin embargo, en la práctica, las apps bien escritas de Redux no escriben esos objetos de acción en línea al despacharlos. En su lugar, usamos funciones "creadoras de acciones".

Un creador de acciones es una función que crea y devuelve un objeto de acción. Los usamos para evitar escribir manualmente el objeto cada vez:

const todoAdded = text => {
return {
type: 'todos/todoAdded',
payload: text
}
}

Luego las usamos llamando al creador de acciones y pasando el objeto de acción resultante directamente a dispatch:

store.dispatch(todoAdded('Buy milk'))

console.log(store.getState().todos)
// [ {id: 0, text: 'Buy milk', completed: false}]

Detailed Explanation: Why use Action Creators?

In our small example todo app, writing action objects by hand every time isn't too difficult. In fact, by switching to using action creators, we've added more work - now we have to write a function and the action object.

But, what if we needed to dispatch the same action from many parts of the application? Or what if there's some additional logic that we have to do every time we dispatch an action, like creating a unique ID? We'd end up having to copy-paste the additional setup logic every time we need to dispatch that action.

Action creators have two primary purposes:

  • They prepare and format the contents of action objects
  • They encapsulate any additional work needed whenever we create those actions

That way, we have a consistent approach for creating actions, whether or not there's any extra work that needs to be done. The same goes for thunks as well.

Usando Creadores de Acciones

Actualicemos nuestro archivo de slice de tareas para usar creadores de acciones en algunos de nuestros tipos de acción.

Comenzaremos con las dos acciones principales que hemos usado hasta ahora: cargar la lista de tareas desde el servidor y añadir una nueva tarea después de guardarla en el servidor.

Actualmente, todosSlice.js despacha un objeto de acción directamente así:

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

Crearemos una función que genera y devuelve ese mismo tipo de objeto de acción, pero que acepta el array de tareas como argumento y lo coloca en action.payload. Luego podemos despachar la acción usando ese nuevo creador dentro de nuestro thunk fetchTodos:

src/features/todos/todosSlice.js
export const todosLoaded = todos => {
return {
type: 'todos/todosLoaded',
payload: todos
}
}

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

Podemos hacer lo mismo con la acción "tarea añadida":

src/features/todos/todosSlice.js
export const todoAdded = todo => {
return {
type: 'todos/todoAdded',
payload: todo
}
}

export function saveNewTodo(text) {
return async function saveNewTodoThunk(dispatch, getState) {
const initialTodo = { text }
const response = await client.post('/fakeApi/todos', { todo: initialTodo })
dispatch(todoAdded(response.todo))
}
}

De paso, hagamos lo mismo para la acción "filtro de color cambiado":

src/features/filters/filtersSlice.js
export const colorFilterChanged = (color, changeType) => {
return {
type: 'filters/colorFilterChanged',
payload: { color, changeType }
}
}

Y como esta acción se despachaba desde el componente <Footer>, necesitaremos importar el creador colorFilterChanged allí y usarlo:

src/features/footer/Footer.js
import React from 'react'
import { useSelector, useDispatch } from 'react-redux'

import { availableColors, capitalize } from '../filters/colors'
import { StatusFilters, colorFilterChanged } from '../filters/filtersSlice'

// omit child components

const Footer = () => {
const dispatch = useDispatch()

const todosRemaining = useSelector(state => {
const uncompletedTodos = state.todos.filter(todo => !todo.completed)
return uncompletedTodos.length
})

const { status, colors } = useSelector(state => state.filters)

const onMarkCompletedClicked = () => dispatch({ type: 'todos/allCompleted' })
const onClearCompletedClicked = () =>
dispatch({ type: 'todos/completedCleared' })

const onColorChange = (color, changeType) =>
dispatch(colorFilterChanged(color, changeType))

const onStatusChange = status =>
dispatch({ type: 'filters/statusFilterChanged', payload: status })

// omit rendering output
}

export default Footer

Observa que el creador colorFilterChanged acepta dos argumentos diferentes y luego los combina para formar el campo correcto de action.payload.

Esto no cambia nada sobre cómo funciona la aplicación ni cómo se comporta el flujo de datos de Redux: seguimos creando objetos de acción y despachándolos. Pero, en lugar de escribir objetos de acción directamente en nuestro código constantemente, ahora usamos creadores de acciones para preparar esos objetos antes de despacharlos.

También podemos usar creadores de acciones con funciones thunk, y de hecho ya envolvimos un thunk en un creador de acciones en la sección anterior. Específicamente, envolvimos saveNewTodo en una función "creadora de acciones thunk" para poder pasarle un parámetro text. Aunque fetchTodos no recibe parámetros, igualmente podríamos envolverlo en un creador de acciones:

src/features/todos/todosSlice.js
export function fetchTodos() {
return async function fetchTodosThunk(dispatch, getState) {
const response = await client.get('/fakeApi/todos')
dispatch(todosLoaded(response.todos))
}
}

Esto significa que debemos cambiar el lugar donde se despacha en index.js para llamar a la función creadora de acciones thunk externa y pasar la función thunk interna devuelta a dispatch:

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

store.dispatch(fetchTodos())

Hasta ahora hemos escrito thunks usando la palabra clave function para que sea claro lo que hacen. Sin embargo, también podemos escribirlos usando sintaxis de funciones flecha. El uso de retornos implícitos puede acortar el código, aunque también puede dificultar un poco la lectura si no estás familiarizado con las funciones flecha:

src/features/todos/todosSlice.js
// Same thing as the above example!
export const fetchTodos = () => async dispatch => {
const response = await client.get('/fakeApi/todos')
dispatch(todosLoaded(response.todos))
}

Del mismo modo, podríamos acortar los creadores de acciones simples si quisiéramos:

src/features/todos/todosSlice.js
export const todoAdded = todo => ({ type: 'todos/todoAdded', payload: todo })

Depende de ti decidir si usar funciones flecha de esta manera es mejor o no.

información

Para más detalles sobre por qué son útiles los creadores de acciones, consulta:

Selectores Memoized

Ya hemos visto que podemos escribir funciones "selectoras" que aceptan el objeto state de Redux como argumento y devuelven un valor:

const selectTodos = state => state.todos

¿Qué pasa si necesitamos derivar algunos datos? Por ejemplo, quizás queramos obtener un array solo con los IDs de las tareas:

const selectTodoIds = state => state.todos.map(todo => todo.id)

Sin embargo, array.map() siempre devuelve una nueva referencia de array. Sabemos que el hook useSelector de React-Redux volverá a ejecutar su función selectora después de cada acción despachada, y si el resultado del selector cambia, forzará al componente a volver a renderizarse.

En este ejemplo, ¡llamar a useSelector(selectTodoIds) siempre hará que el componente se vuelva a renderizar después de cada acción, porque está devolviendo una nueva referencia de array!

En la Parte 5, vimos que podemos pasar shallowEqual como argumento a useSelector. Pero hay otra opción: podríamos usar selectores "memoized".

La memoización es un tipo de caché: específicamente, guardar los resultados de un cálculo costoso y reutilizarlos si volvemos a ver las mismas entradas más tarde.

Las funciones selectoras memoized son selectores que guardan el valor de resultado más reciente y, si los llamas varias veces con las mismas entradas, devolverán el mismo resultado. Si los llamas con entradas diferentes a las de la última vez, recalcularán un nuevo valor de resultado, lo almacenarán en caché y devolverán el nuevo resultado.

Memoización de selectores con createSelector

La biblioteca Reselect proporciona una API createSelector que generará funciones selectoras memoized. createSelector acepta una o más funciones "selectoras de entrada" como argumentos, más un "selector de salida", y devuelve la nueva función selectora. Cada vez que llamas al selector:

  • Todas las "selectoras de entrada" se llaman con todos los argumentos

  • Si alguno de los valores devueltos por las selectoras de entrada ha cambiado, el "selector de salida" se volverá a ejecutar

  • Todos los resultados de las selectoras de entrada se convierten en argumentos para el selector de salida

  • El resultado final del selector de salida se almacena en caché para la próxima vez

Creemos una versión memoized de selectTodoIds y usémosla con nuestro <TodoList>.

Primero, necesitamos instalar Reselect:

npm install reselect

Luego, podemos importar y llamar a createSelector. Nuestra función original selectTodoIds se definió en TodoList.js, pero es más común que las funciones selectoras se escriban en el archivo de slice relevante. Así que agreguemos esto al slice de todos:

src/features/todos/todosSlice.js
import { createSelector } from 'reselect'

// omit reducer

// omit action creators

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

Luego, usémoslo en <TodoList>:

src/features/todos/TodoList.js
import React from 'react'
import { useSelector, shallowEqual } from 'react-redux'

import { selectTodoIds } from './todosSlice'
import TodoListItem from './TodoListItem'

const TodoList = () => {
const todoIds = useSelector(selectTodoIds)

const renderedListItems = todoIds.map(todoId => {
return <TodoListItem key={todoId} id={todoId} />
})

return <ul className="todo-list">{renderedListItems}</ul>
}

Esto se comporta un poco diferente a la función de comparación shallowEqual. Cada vez que el array state.todos cambia, vamos a crear un nuevo array de IDs como resultado. Esto incluye cualquier actualización inmutable en los elementos todo, como cambiar su campo completed, ya que debemos crear un nuevo array para la actualización inmutable.

consejo

Los selectores memoizados solo son útiles cuando realmente derivas valores adicionales de los datos originales. Si solo estás buscando y devolviendo un valor existente, puedes mantener el selector como una función simple.

Selectores con múltiples argumentos

Nuestra app de todos debería poder filtrar los todos visibles según su estado de completado. Escribamos un selector memoizado que devuelva esa lista filtrada.

Sabemos que necesitamos todo el array todos como un argumento para nuestro selector de salida. También necesitamos pasar el valor actual del filtro de estado de completado. Agregaremos un "selector de entrada" separado para extraer cada valor y pasar los resultados al "selector de salida".

src/features/todos/todosSlice.js
import { createSelector } from 'reselect'
import { StatusFilters } from '../filters/filtersSlice'

// omit other code

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

const completedStatus = status === StatusFilters.Completed
// Return either active or completed todos based on filter
return todos.filter(todo => todo.completed === completedStatus)
}
)
precaución

Nota que ahora hemos agregado una dependencia de importación entre dos slices: todosSlice importa un valor de filtersSlice. Esto es válido, pero ten cuidado. Si dos slices intentan importar algo mutuamente, puedes terminar con un problema de "dependencia cíclica de importación" que puede hacer que tu código falle. Si eso ocurre, intenta mover código común a su propio archivo e importa desde ahí.

Ahora podemos usar este nuevo selector de "todos filtrados" como entrada para otro selector que devuelva los IDs de esos todos:

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

Si cambiamos <TodoList> para usar selectFilteredTodoIds, deberíamos poder marcar algunos todos como completados:

Todo app - todos marcados como completados

y luego filtrar la lista para mostrar solo los todos completados:

Todo app - todos marcados como completados

Podemos expandir nuestro selectFilteredTodos para incluir también el filtrado por color:

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

Nota que al encapsular la lógica en este selector, nuestro componente no necesitó cambios, incluso cuando alteramos el comportamiento de filtrado. Ahora podemos filtrar por estado y color simultáneamente:

Todo app - filtros de estado y color

Finalmente, tenemos varios lugares donde nuestro código consulta state.todos. Vamos a hacer cambios en cómo se diseña ese estado en el resto de esta sección, así que extraeremos un único selector selectTodos y lo usaremos en todas partes. También podemos mover selectTodoById al todosSlice:

src/features/todos/todosSlice.js
export const selectTodos = state => state.todos

export const selectTodoById = (state, todoId) => {
return selectTodos(state).find(todo => todo.id === todoId)
}
información

Para más detalles sobre selectores y memoización con Reselect:

Estado de solicitudes asíncronas

Estamos usando un thunk asíncrono para obtener la lista inicial de todos desde el servidor. Como usamos una API de servidor simulada, la respuesta es inmediata. En una app real, la llamada API podría tardar en resolverse. En ese caso, es común mostrar algún tipo de indicador de carga mientras esperamos que termine la respuesta.

Esto normalmente se maneja en apps Redux mediante:

  • Tener algún valor de "estado de carga" que indique el estado actual de una solicitud

  • Despachar una acción de 'solicitud iniciada' antes de realizar la llamada API, que se gestiona cambiando el valor del estado de carga

  • Actualizar nuevamente el valor del estado de carga cuando la solicitud finalice para indicar que la llamada ha terminado

La capa de interfaz muestra entonces un indicador de carga durante la solicitud, y muestra los datos reales cuando esta finaliza.

Vamos a actualizar nuestro slice de tareas para rastrear un valor de estado de carga, y despachar una acción adicional 'todos/todosLoading' como parte del thunk fetchTodos.

Actualmente, el state de nuestro reducer de tareas es solo el array de tareas. Si queremos rastrear el estado de carga dentro del slice, necesitaremos reorganizar el estado para que sea un objeto que contenga el array de tareas y el valor de estado de carga. Esto también implica reescribir la lógica del reducer para manejar el anidamiento adicional:

src/features/todos/todosSlice.js
const initialState = {
status: 'idle',
entities: []
}

export default function todosReducer(state = initialState, action) {
switch (action.type) {
case 'todos/todoAdded': {
return {
...state,
entities: [...state.entities, action.payload]
}
}
case 'todos/todoToggled': {
return {
...state,
entities: state.entities.map(todo => {
if (todo.id !== action.payload) {
return todo
}

return {
...todo,
completed: !todo.completed
}
})
}
}
// omit other cases
default:
return state
}
}

// omit action creators

export const selectTodos = state => state.todos.entities

Hay algunas cosas importantes a tener en cuenta:

  • El array de tareas ahora está anidado como state.entities en el objeto de estado de todosReducer. El término "entidades" se refiere a "elementos únicos con ID", lo que describe nuestros objetos de tareas.

  • Esto también significa que el array está anidado en todo el objeto de estado de Redux como state.todos.entities

  • Ahora debemos realizar pasos adicionales en el reducer para copiar el nivel adicional de anidamiento y garantizar actualizaciones inmutables correctas, como state objeto -> array entities -> objeto todo

  • Como el resto de nuestro código accede al estado de tareas solo mediante selectores, solo necesitamos actualizar el selector selectTodos; el resto de la interfaz seguirá funcionando como se espera a pesar de haber reorganizado considerablemente nuestro estado.

Valores de enumeración para el estado de carga

También notarás que hemos definido el campo de estado de carga como una enumeración de cadenas:

{
status: 'idle' // or: 'loading', 'succeeded', 'failed'
}

en lugar de un booleano isLoading.

Un booleano nos limita a dos posibilidades: "cargando" o "no cargando". En realidad, una solicitud puede estar en muchos estados diferentes, como:

  • No ha comenzado

  • En progreso

  • Completada con éxito

  • Fallida

  • Completada con éxito, pero ahora en situación donde podríamos querer volver a obtener

También es posible que la lógica de la aplicación deba transicionar solo entre estados específicos según ciertas acciones, algo más difícil de implementar con booleanos.

Por esto, recomendamos almacenar el estado de carga como valor de enumeración de cadena en lugar de flags booleanos.

información

Para una explicación detallada de por qué los estados de carga deben ser enumeraciones, consulta:

Basándonos en esto, añadiremos una nueva acción "loading" que establecerá nuestro estado como 'loading', y actualizaremos la acción "loaded" para restablecer el estado a 'idle':

src/features/todos/todosSlice.js
const initialState = {
status: 'idle',
entities: []
}

export default function todosReducer(state = initialState, action) {
switch (action.type) {
// omit other cases
case 'todos/todosLoading': {
return {
...state,
status: 'loading'
}
}
case 'todos/todosLoaded': {
return {
...state,
status: 'idle',
entities: action.payload
}
}
default:
return state
}
}

// omit action creators

// Thunk function
export const fetchTodos = () => async dispatch => {
dispatch(todosLoading())
const response = await client.get('/fakeApi/todos')
dispatch(todosLoaded(response.todos))
}

Sin embargo, antes de mostrar esto en la interfaz, necesitamos modificar la API de servidor simulada para añadir un retardo artificial a las llamadas API. Abre src/api/server.js y busca esta línea comentada alrededor de la línea 63:

src/api/server.js
new Server({
routes() {
this.namespace = 'fakeApi'
// this.timing = 2000

// omit other code
}
})

Si descomentas esa línea, el servidor simulado añadirá un retardo de 2 segundos a cada llamada API, dándonos tiempo suficiente para ver el indicador de carga.

Ahora podemos leer el valor del estado de carga en nuestro componente <TodoList> y mostrar un indicador de carga basado en ese valor.

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

const TodoList = () => {
const todoIds = useSelector(selectFilteredTodoIds)
const loadingStatus = useSelector(state => state.todos.status)

if (loadingStatus === 'loading') {
return (
<div className="todo-list">
<div className="loader" />
</div>
)
}

const renderedListItems = todoIds.map(todoId => {
return <TodoListItem key={todoId} id={todoId} />
})

return <ul className="todo-list">{renderedListItems}</ul>
}

En una aplicación real, también querríamos manejar errores de API y otros casos potenciales.

Así se ve la aplicación con el estado de carga habilitado (para volver a ver el indicador, recarga la vista previa o ábrela en una nueva pestaña):

Acciones Estándar de Flux (FSA)

El almacén de Redux no se preocupa realmente por qué campos incluyas en tus objetos de acción. Solo requiere que action.type exista y sea una cadena. Esto significa que podrías añadir cualquier campo adicional que desees. Por ejemplo, podríamos usar action.todo para acciones de "todo añadido", o action.color, etc.

Sin embargo, si cada acción usa nombres de campos diferentes para sus datos, resulta difícil anticipar qué campos debe manejar cada reducer.

Por eso la comunidad de Redux creó la convención "Flux Standard Actions" o "FSA". Este enfoque sugiere cómo organizar campos dentro de objetos de acción, permitiendo que los desarrolladores identifiquen consistentemente qué campos contienen cada tipo de dato. El patrón FSA se usa ampliamente en la comunidad Redux, y de hecho ya lo has estado usando durante todo este tutorial.

La convención FSA establece que:

  • Si tu objeto de acción contiene datos reales, ese valor debe ir siempre en action.payload

  • Una acción puede incluir un campo action.meta con datos descriptivos adicionales

  • Una acción puede tener un campo action.error con información de errores

Por lo tanto, TODAS las acciones de Redux DEBEN:

  • Ser objetos JavaScript simples

  • Tener un campo type

Y si sigues el patrón FSA, una acción PUEDE:

  • Tener un campo payload

  • Tener un campo error

  • Tener un campo meta

Detailed Explanation: FSAs and Errors

The FSA specification says that:

The optional error property MAY be set to true if the action represents an error. An action whose error is true is analogous to a rejected Promise. By convention, the payload SHOULD be an error object. If error has any other value besides true, including undefined and null, the action MUST NOT be interpreted as an error.

The FSA specs also argue against having specific action types for things like "loading succeeded" and "loading failed".

However, in practice, the Redux community has ignored the idea of using action.error as a boolean flag, and instead settled on separate action types, like 'todos/todosLoadingSucceeded' and 'todos/todosLoadingFailed'. This is because it's much easier to check for those action types than it is to first handle 'todos/todosLoaded' and then check if (action.error).

You can do whichever approach works better for you, but most apps use separate action types for success and failure.

Estado Normalizado

Hasta ahora, hemos almacenado nuestros todos en un array. Esto es razonable porque recibimos los datos del servidor como array, y necesitamos recorrerlos para mostrarlos como lista en la interfaz.

Sin embargo, en aplicaciones Redux más grandes es común almacenar datos en una estructura de estado normalizado. "Normalización" significa:

  • Garantizar que solo exista una copia de cada dato

  • Almacenar elementos permitiendo búsquedas directas por ID

  • Referenciar otros elementos mediante IDs, en lugar de copiar objetos completos

Por ejemplo, en una aplicación de blogs, los objetos Post podrían referenciar User y Comment. Si hay múltiples posts del mismo usuario y cada Post incluye el objeto User completo, tendríamos duplicados del mismo User. En su lugar, un Post tendría un ID de usuario (post.user), permitiéndonos buscar objetos User por ID con state.users[post.user].

Esto implica organizar datos como objetos (no arrays) donde los IDs son claves y los elementos son valores:

const rootState = {
todos: {
status: 'idle',
entities: {
2: { id: 2, text: 'Buy milk', completed: false },
7: { id: 7, text: 'Clean room', completed: true }
}
}
}

Convirtamos nuestro slice de todos a formato normalizado. Esto requerirá cambios significativos en la lógica del reducer y actualización de selectores:

src/features/todos/todosSlice
const initialState = {
status: 'idle',
entities: {}
}

export default function todosReducer(state = initialState, action) {
switch (action.type) {
case 'todos/todoAdded': {
const todo = action.payload
return {
...state,
entities: {
...state.entities,
[todo.id]: todo
}
}
}
case 'todos/todoToggled': {
const todoId = action.payload
const todo = state.entities[todoId]
return {
...state,
entities: {
...state.entities,
[todoId]: {
...todo,
completed: !todo.completed
}
}
}
}
case 'todos/colorSelected': {
const { color, todoId } = action.payload
const todo = state.entities[todoId]
return {
...state,
entities: {
...state.entities,
[todoId]: {
...todo,
color
}
}
}
}
case 'todos/todoDeleted': {
const newEntities = { ...state.entities }
delete newEntities[action.payload]
return {
...state,
entities: newEntities
}
}
case 'todos/allCompleted': {
const newEntities = { ...state.entities }
Object.values(newEntities).forEach(todo => {
newEntities[todo.id] = {
...todo,
completed: true
}
})
return {
...state,
entities: newEntities
}
}
case 'todos/completedCleared': {
const newEntities = { ...state.entities }
Object.values(newEntities).forEach(todo => {
if (todo.completed) {
delete newEntities[todo.id]
}
})
return {
...state,
entities: newEntities
}
}
case 'todos/todosLoading': {
return {
...state,
status: 'loading'
}
}
case 'todos/todosLoaded': {
const newEntities = {}
action.payload.forEach(todo => {
newEntities[todo.id] = todo
})
return {
...state,
status: 'idle',
entities: newEntities
}
}
default:
return state
}
}

// omit action creators

const selectTodoEntities = state => state.todos.entities

export const selectTodos = createSelector(selectTodoEntities, entities =>
Object.values(entities)
)

export const selectTodoById = (state, todoId) => {
return selectTodoEntities(state)[todoId]
}

Como state.entities ahora es un objeto (no array), debemos usar operadores de propagación anidados en lugar de operaciones de arrays. Tampoco podemos recorrer objetos como arrays, por lo que en varios puntos usaremos Object.values(entities) para obtener un array de todos que podamos iterar.

La buena noticia es que los selectores encapsulan el acceso al estado, por lo que la interfaz no cambia. La mala noticia es que el código del reducer resulta más extenso y complejo.

Parte del problema aquí es que este ejemplo de aplicación de tareas no es una aplicación real a gran escala. Por lo tanto, la normalización del estado no es tan útil en esta aplicación concreta, y es más difícil apreciar sus beneficios potenciales.

Afortunadamente, en la Parte 8: Redux Moderno con Redux Toolkit veremos formas de reducir drásticamente la lógica del reducer para gestionar nuestro estado normalizado.

Por ahora, lo importante es comprender:

  • La normalización se usa comúnmente en aplicaciones Redux

  • Los principales beneficios son poder buscar elementos individuales por ID y garantizar que solo exista una copia de cada elemento en el estado

información

Para más detalles sobre por qué es útil la normalización con Redux, consulta:

Thunks y Promesas

Nos queda un último patrón por ver en esta sección. Ya hemos visto cómo manejar estados de carga en el store de Redux basándonos en acciones despachadas. ¿Y si necesitamos acceder a los resultados de un thunk en nuestros componentes?

Cada vez que llamas a store.dispatch(action), dispatch devolverá realmente la action como resultado. El middleware puede modificar este comportamiento y devolver otro valor.

Ya hemos visto que el middleware Redux Thunk nos permite pasar una función a dispatch, la llama y luego devuelve el resultado:

reduxThunkMiddleware.js
const reduxThunkMiddleware = storeAPI => next => action => {
// If the "action" is actually a function instead...
if (typeof action === 'function') {
// then call the function and pass `dispatch` and `getState` as arguments
// Also, return whatever the thunk function returns
return action(storeAPI.dispatch, storeAPI.getState)
}

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

Esto significa que podemos escribir thunks que devuelvan una promesa, y esperar esa promesa en nuestros componentes.

Ya tenemos el componente <Header> despachando un thunk para guardar nuevas tareas en el servidor. Añadamos un estado de carga dentro de <Header>, deshabilitemos el campo de texto y mostremos un spinner mientras esperamos al servidor:

src/features/header/Header.js
const Header = () => {
const [text, setText] = useState('')
const [status, setStatus] = useState('idle')
const dispatch = useDispatch()

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

const handleKeyDown = async e => {
// If the user pressed the Enter key:
const trimmedText = text.trim()
if (e.which === 13 && trimmedText) {
// Create and dispatch the thunk function itself
setStatus('loading')
// Wait for the promise returned by saveNewTodo
await dispatch(saveNewTodo(trimmedText))
// And clear out the text input
setText('')
setStatus('idle')
}
}

let isLoading = status === 'loading'
let placeholder = isLoading ? '' : 'What needs to be done?'
let loader = isLoading ? <div className="loader" /> : null

return (
<header className="header">
<input
className="new-todo"
placeholder={placeholder}
autoFocus={true}
value={text}
onChange={handleChange}
onKeyDown={handleKeyDown}
disabled={isLoading}
/>
{loader}
</header>
)
}

export default Header

Ahora, si añadimos una tarea, veremos un spinner en la cabecera:

Aplicación de tareas - spinner de carga en cabecera

Lo que has aprendido

Como has visto, hay varios patrones adicionales ampliamente usados en aplicaciones Redux. Estos patrones no son obligatorios y pueden implicar más código inicialmente, pero ofrecen beneficios como hacer la lógica reutilizable, encapsular detalles de implementación, mejorar el rendimiento y facilitar la búsqueda de datos.

información

Así luce nuestra aplicación después de aplicar completamente estos patrones:

Resumen
  • Las funciones creadoras de acciones encapsulan la preparación de objetos de acción y thunks
    • Pueden aceptar argumentos, contener lógica de configuración y devolver objetos de acción o funciones thunk
  • Los selectores memoizados mejoran el rendimiento en apps Redux
    • Reselect proporciona la API createSelector para generar selectores memoizados
    • Devuelven la misma referencia de resultado si reciben las mismas entradas
  • El estado de peticiones debe almacenarse como enumeración, no booleanos
    • Usar enumeraciones como 'idle' e 'loading' ayuda a rastrear estados consistentemente
  • "Acciones estándar Flux" son la convención común para organizar acciones
    • Usan payload para datos, meta para descripciones extra y error para errores
  • El estado normalizado facilita buscar elementos por ID
    • Los datos se almacenan en objetos en lugar de arrays, con IDs como claves
  • Los thunks pueden devolver promesas desde dispatch
    • Los componentes pueden esperar a que thunks asíncronos completen para continuar

¿Qué sigue?

Escribir todo este código "manualmente" puede ser tedioso y difícil. Por eso te recomendamos que uses nuestro paquete oficial Redux Toolkit para escribir la lógica de Redux en su lugar.

Redux Toolkit incluye APIs que te ayudan a escribir todos los patrones de uso típicos de Redux con menos código. También ayuda a prevenir errores comunes como mutar el estado accidentalmente.

En la Parte 8: Redux Moderno, veremos cómo usar Redux Toolkit para simplificar todo el código que hemos escrito hasta ahora.