Saltar al contenido

b

Muchos reducers

Continuemos nuestro trabajo con la versión Redux simplificada de nuestra aplicación de notas.

Para facilitar nuestro desarrollo, cambiemos nuestro reducer para que el store se inicialice con un estado que contenga un par de notas:

const initialState = [
  {
    content: 'reducer defines how redux store works',
    important: true,
    id: 1,
  },
  {
    content: 'state of store can contain any data',
    important: false,
    id: 2,
  },
]

const noteReducer = (state = initialState, action) => {
  // ...
}

// ...
export default noteReducer

Store con estado complejo

Implementemos el filtrado de las notas que se muestran al usuario. La interfaz de usuario para los filtros se implementará con botones de radio:

botones de radio con opciones important/not y listado

Comencemos con una implementación muy simple y directa:

import NewNote from './components/NewNote'
import Notes from './components/Notes'

const App = () => {
  const filterSelected = (value) => {    console.log(value)  }
  return (
    <div>
      <NewNote />
      <div>        all          <input type="radio" name="filter"          onChange={() => filterSelected('ALL')} />        important    <input type="radio" name="filter"          onChange={() => filterSelected('IMPORTANT')} />        nonimportant <input type="radio" name="filter"          onChange={() => filterSelected('NONIMPORTANT')} />      </div>      <Notes />
    </div>
  )
}

Dado que el atributo name de todos los botones de radio es el mismo, estos forman un button group (grupo de botones) en el que solo se puede seleccionar una opción.

Los botones tienen un controlador de cambios que actualmente solo imprime el string asociado con el botón en el que se hizo clic en la consola.

En la siguiente sección, vamos a implementar el filtrado almacenando las notas y el valor del filtro en el store de redux. Cuando terminemos, nos gustaría que el estado del store se viera así:

{
  notes: [
    { content: 'reducer defines how redux store works', important: true, id: 1},
    { content: 'state of store can contain any data', important: false, id: 2}
  ],
  filter: 'IMPORTANT'
}

Solo el array de notas se almacenaba en el estado de la implementación anterior de nuestra aplicación. En la nueva implementación, el objeto de estado tiene dos propiedades, notes que contienen el array de notas y filter que contiene un string que indica qué notas deben mostrarse al usuario.

Reducers combinados

Podríamos modificar nuestro reducer actual para hacer frente a la nueva forma del estado. Sin embargo, una mejor solución en esta situación es definir un nuevo reducer separado para el estado del filtro:

const filterReducer = (state = 'ALL', action) => {
  switch (action.type) {
    case 'SET_FILTER':
      return action.payload
    default:
      return state
  }
}

Las acciones para cambiar el estado del filtro se ven así:

{
  type: 'SET_FILTER',
  payload: 'IMPORTANT'
}

Creemos también una nueva función de action creator. Escribiremos su código en un nuevo módulo src/reducers/filterReducer.js:

const filterReducer = (state = 'ALL', action) => {
  // ...
}

export const filterChange = filter => {
  return {
    type: 'SET_FILTER',
    payload: filter,
  }
}

export default filterReducer

Podemos crear el reducer que nuestra aplicación realmente utilizara al combinar los dos reducers existentes con la función combineReducers.

Definamos el reducer combinado en el archivo main.jsx:

import ReactDOM from 'react-dom/client'
import { createStore, combineReducers } from 'redux'import { Provider } from 'react-redux' 
import App from './App'

import noteReducer from './reducers/noteReducer'
import filterReducer from './reducers/filterReducer'
const reducer = combineReducers({  notes: noteReducer,  filter: filterReducer})
const store = createStore(reducer)
console.log(store.getState())

/*
ReactDOM.createRoot(document.getElementById('root')).render(
  <Provider store={store}>
    <App />
  </Provider>
)*/

ReactDOM.createRoot(document.getElementById('root')).render(
  <Provider store={store}>
    <div />
  </Provider>
)

Dado que nuestra aplicación se rompe por completo en este punto, renderizamos un elemento div vacío en lugar del componente App.

El estado del store se imprime en la consola:

consola de desarrollo mostrando el array de notas

Como podemos ver en el resultado, ¡el store tiene la forma exacta que queríamos!

Echemos un vistazo más de cerca a cómo se crea el reducer combinado:

const reducer = combineReducers({
  notes: noteReducer,
  filter: filterReducer,
})

El estado del store definido por este reducer es un objeto con dos propiedades: notes y filter. El valor de la propiedad notes es definido por noteReducer, que no tiene que lidiar con las otras propiedades del estado. Asimismo, la propiedad filter es administrada por filterReducer.

Antes de realizar más cambios en el código, echemos un vistazo a cómo las diferentes acciones cambian el estado del store definido por el reducer combinado. Agreguemos lo siguiente al archivo main.jsx:

import { createNote } from './reducers/noteReducer'
import { filterChange } from './reducers/filterReducer'
//...
store.subscribe(() => console.log(store.getState()))
store.dispatch(filterChange('IMPORTANT'))
store.dispatch(createNote('combineReducers forms one reducer from many simple reducers'))

Al simular la creación de una nota y cambiar el estado del filtro de esta manera, el estado del store se muestra en la consola después de cada cambio que se realiza en el store:

consola mostrando filtro de notas y nueva nota

En este punto es bueno darse cuenta de un pequeño pero importante detalle. Si agregamos un console log al comienzo de ambos reducers:

const filterReducer = (state = 'ALL', action) => {
  console.log('ACTION: ', action)
  // ...
}

Según el resultado de la consola, uno podría tener la impresión de que cada acción se duplica:

consola mostrando acciones duplicadas en los reducers note y filter

¿Hay algún bug en nuestro código? No. El reducer combinado funciona de tal manera que cada acción es controlada en cada parte del reducer combinado, o en otras palabras, cada reducer "escucha" a todas las acciones despachadas y hace algo con ellas si así se lo hemos instruido. Normalmente, solo un reducer está interesado en una acción determinada, pero hay situaciones en las que varios reducers cambian sus respectivas partes del estado en función de la misma acción.

Terminando los filtros

Terminemos la aplicación para que utilice el reducer combinado. Comenzamos cambiando la renderización de la aplicación y conectando el store a la aplicación en el archivo main.jsx:

ReactDOM.createRoot(document.getElementById('root')).render(
  <Provider store={store}>
    <App />
  </Provider>
)

A continuación, solucionemos un error causado por el código que espera que la store de aplicaciones sea un array de notas:

error en el navegador, TypeError: notes.map no es una función

Es una solución fácil. Debido a que las notas están en el campo notes del store, solo tenemos que hacer un pequeño cambio en la función de selector:

const Notes = () => {
  const dispatch = useDispatch()
  const notes = useSelector(state => state.notes)
  return(
    <ul>
      {notes.map(note =>
        <Note
          key={note.id}
          note={note}
          handleClick={() => 
            dispatch(toggleImportanceOf(note.id))
          }
        />
      )}
    </ul>
  )
}

Anteriormente, la función de selector devolvía el estado completo del store:

const notes = useSelector(state => state)

Y ahora devuelve solo su campo notes

const notes = useSelector(state => state.notes)

Extraigamos el filtro de visibilidad en su propio componente src/components/VisibilityFilter.jsx:

import { filterChange } from '../reducers/filterReducer'
import { useDispatch } from 'react-redux'

const VisibilityFilter = (props) => {
  const dispatch = useDispatch()

  return (
    <div>
      all    
      <input 
        type="radio" 
        name="filter" 
        onChange={() => dispatch(filterChange('ALL'))}
      />
      important   
      <input
        type="radio"
        name="filter"
        onChange={() => dispatch(filterChange('IMPORTANT'))}
      />
      nonimportant 
      <input
        type="radio"
        name="filter"
        onChange={() => dispatch(filterChange('NONIMPORTANT'))}
      />
    </div>
  )
}

export default VisibilityFilter

Con el nuevo componente, App se puede simplificar de la siguiente manera:

import Notes from './components/Notes'
import NewNote from './components/NewNote'
import VisibilityFilter from './components/VisibilityFilter'

const App = () => {
  return (
    <div>
      <NewNote />
      <VisibilityFilter />
      <Notes />
    </div>
  )
}

export default App

La implementación es bastante sencilla. Al hacer clic en los diferentes radio buttons, cambia el estado de la propiedad filter del store.

Cambiemos el componente Notes para incorporar el filtro:

const Notes = () => {
  const dispatch = useDispatch()
  const notes = useSelector(state => {    if ( state.filter === 'ALL' ) {      return state.notes    }    return state.filter  === 'IMPORTANT'       ? state.notes.filter(note => note.important)      : state.notes.filter(note => !note.important)  })
  return(
    <ul>
      {notes.map(note =>
        <Note
          key={note.id}
          note={note}
          handleClick={() => 
            dispatch(toggleImportanceOf(note.id))
          }
        />
      )}
    </ul>
  )

Solo realizamos cambios en la función de selector, que solía ser

useSelector(state => state.notes)

Simplifiquemos el selector desestructurando los campos del estado que recibe como parámetro:

const notes = useSelector(({ filter, notes }) => {
  if ( filter === 'ALL' ) {
    return notes
  }
  return filter  === 'IMPORTANT' 
    ? notes.filter(note => note.important)
    : notes.filter(note => !note.important)
})

Hay un pequeño defecto cosmético en nuestra aplicación. Aunque el filtro está configurado en ALL de forma predeterminada, el radio button asociado no está seleccionado. Naturalmente, este problema se puede solucionar, pero como se trata de un error desagradable pero, en última instancia, inofensivo, dejaremos la solución para más adelante.

La versión actual de la aplicación se puede encontrar en GitHub, en la rama part6-2.

Redux Toolkit

Como hemos visto hasta ahora, la implementación de la gestión del estado y la configuración de Redux requiere bastante esfuerzo. Esto se manifiesta, por ejemplo, en el código relacionado con el reducer y el action creator, que tiene un código un tanto repetitivo. Redux Toolkit es una librería que resuelve estos problemas comunes relacionados con Redux. La librería, por ejemplo, simplifica enormemente la configuración del store de Redux y ofrece una gran variedad de herramientas para facilitar la gestión del estado.

Comencemos a usar Redux Toolkit en nuestra aplicación refactorizando el código existente. Primero, necesitaremos instalar la librería:

npm install @reduxjs/toolkit

A continuación, abre el archivo main.jsx que actualmente crea la store de Redux. En lugar de la función createStore de Redux, creemos el Store usando la función configureStore de Redux Toolkit:

import ReactDOM from 'react-dom/client'
import { Provider } from 'react-redux'
import { configureStore } from '@reduxjs/toolkit'import App from './App'

import noteReducer from './reducers/noteReducer'
import filterReducer from './reducers/filterReducer'

const store = configureStore({  reducer: {    notes: noteReducer,    filter: filterReducer  }})
console.log(store.getState())

ReactDOM.createRoot(document.getElementById('root')).render(
  <Provider store={store}>
    <App />
  </Provider>
)

Ya nos deshicimos de algunas líneas de código, ya no necesitamos la función combineReducers para crear el reducer del store. Pronto veremos que la función configureStore tiene muchos beneficios adicionales, como la integración sin esfuerzo de herramientas de desarrollo y muchas librerías de uso común sin necesidad de configuración adicional.

Pasemos a refactorizar los reducers, lo que trae consigo los beneficios de Redux Toolkit. Con Redux Toolkit, podemos crear fácilmente reducers y action creators relacionados utilizando la función createSlice. Podemos usar la función createSlice para refactorizar el reducer y los action creators en el archivo reducers/noteReducer.js de la siguiente manera:

import { createSlice } from '@reduxjs/toolkit'
const initialState = [
  {
    content: 'reducer defines how redux store works',
    important: true,
    id: 1,
  },
  {
    content: 'state of store can contain any data',
    important: false,
    id: 2,
  },
]

const generateId = () =>
  Number((Math.random() * 1000000).toFixed(0))

const noteSlice = createSlice({  name: 'notes',  initialState,  reducers: {    createNote(state, action) {      const content = action.payload      state.push({        content,        important: false,        id: generateId(),      })    },    toggleImportanceOf(state, action) {      const id = action.payload      const noteToChange = state.find(n => n.id === id)      const changedNote = {         ...noteToChange,         important: !noteToChange.important       }      return state.map(note =>        note.id !== id ? note : changedNote       )         }  },})

El parámetro name de la función createSlice define el prefijo que se utiliza en los valores de tipo de la acción. Por ejemplo, la acción createNote definida más adelante tendrá el valor de tipo notes/createNote. Es una buena práctica dar al parámetro un valor que sea único entre los reducers. De esta forma no habrá colisiones inesperadas entre los valores de tipo de acción de la aplicación. El parámetro initialState define el estado inicial del reducer. El parámetro reducers toma al propio reducer como un objeto, cuyas funciones manejan los cambios de estado causados por ciertas acciones. Ten en cuenta que action.payload en la función contiene el argumento proporcionado al llamar al creador de la acción:

dispatch(createNote('Redux Toolkit is awesome!'))

Esta llamada a dispatch equivale a enviar el siguiente objeto:

dispatch({ type: 'notes/createNote', payload: 'Redux Toolkit is awesome!' })

Si has prestado atención, es posible que hayas notado que dentro de la acción createNote, parece suceder algo que viola el principio de inmutabilidad de los reducers mencionado anteriormente:

createNote(state, action) {
  const content = action.payload

  state.push({
    content,
    important: false,
    id: generateId(),
  })
}

Estamos mutando el array del argumento state al llamar al método push en lugar de devolver una nueva instancia del array. ¿De qué se trata todo esto?

Redux Toolkit utiliza la librería Immer con reducers creados por la función createSlice, lo que hace posible mutar el argumento state dentro del reducer. Immer usa el estado mutado para producir un nuevo estado inmutable y, por lo tanto, los cambios de estado permanecen inmutables. Ten en cuenta que state se puede cambiar sin "mutarlo", como hemos hecho con la acción toggleImportanceOf. En este caso, la función devuelve el nuevo estado directamente. Sin embargo, mutar el estado a menudo será útil, especialmente cuando se necesita actualizar un estado complejo.

La función createSlice devuelve un objeto que contiene al reducer así como a los action creators definidos por el parámetro reducers. Se puede acceder al reducer mediante la propiedad noteSlice.reducer, mientras que a los action creators mediante la propiedad noteSlice.actions. Podemos producir las exportaciones del archivo de la siguiente manera:

const noteSlice = createSlice(/* ... */)

export const { createNote, toggleImportanceOf } = noteSlice.actionsexport default noteSlice.reducer

Las importaciones en otros archivos funcionarán igual que antes:

import noteReducer, { createNote, toggleImportanceOf } from './reducers/noteReducer'

Necesitamos modificar los nombres de los tipos de las acciones en las pruebas debido a las convenciones de ReduxToolkit:

import noteReducer from './noteReducer'
import deepFreeze from 'deep-freeze'

describe('noteReducer', () => {
  test('returns new state with action notes/createNote', () => {
    const state = []
    const action = {
      type: 'notes/createNote',      payload: 'the app state is in redux store',    }

    deepFreeze(state)
    const newState = noteReducer(state, action)

    expect(newState).toHaveLength(1)
    expect(newState.map(s => s.content)).toContainEqual(action.payload)
  })

  test('returns new state with action notes/toggleImportanceOf', () => {
    const state = [
      {
        content: 'the app state is in redux store',
        important: true,
        id: 1
      },
      {
        content: 'state changes are made with actions',
        important: false,
        id: 2
      }]
  
    const action = {
      type: 'notes/toggleImportanceOf',      payload: 2
    }
  
    deepFreeze(state)
    const newState = noteReducer(state, action)
  
    expect(newState).toHaveLength(2)
  
    expect(newState).toContainEqual(state[0])
  
    expect(newState).toContainEqual({
      content: 'state changes are made with actions',
      important: true,
      id: 2
    })
  })
})

Redux Toolkit y console.log

Como hemos aprendido, console.log es una herramienta extremadamente poderosa, por lo general siempre nos salva de problemas.

Intentemos imprimir el estado del store de Redux en la consola en medio del reducer creado con la función createSlice:

const noteSlice = createSlice({
  name: 'notes',
  initialState,
  reducers: {
    // ...
    toggleImportanceOf(state, action) {
      const id = action.payload

      const noteToChange = state.find(n => n.id === id)

      const changedNote = { 
        ...noteToChange, 
        important: !noteToChange.important 
      }

      console.log(state)
      return state.map(note =>
        note.id !== id ? note : changedNote 
      )     
    }
  },
})

Lo siguiente se imprime en la consola

consola mostrando Handler y Target como null pero isRevoked como true

Lo que vemos es interesante pero no muy útil. Esto tiene que ver con la librería Immer que mencionamos anteriormente y es utilizada por Redux Toolkit internamente para guardar el estado de la Tienda.

El estado se puede convertir a un formato legible por humanos utilizando la función current de la librería immer.

Actualicemos las importaciones para incluir a la función "current" de la librería immer:

import { createSlice, current } from '@reduxjs/toolkit'

Luego actualicemos el llamado a la función console.log:

console.log(current(state))

Ahora lo que imprime la consola es legible para humanos

consola mostrando array de 2 notas

Redux DevTools

Redux DevTools es una extension de Chrome, que ofrece útiles herramientas de desarrollo para Redux. Se puede usar, por ejemplo, para inspeccionar el estado del store de Redux y enviar acciones (dispatch) a través de la consola del navegador. Cuando el store se crea usando la función configureStore de Redux Toolkit, no se necesita ninguna configuración adicional para que Redux DevTools funcione.

Una vez instalada la extension, al hacer clic en la pestaña de Redux en las herramientas de desarrollo del navegador, Redux DevTools debería abrirse:

redux addon en herramientas de desarrollo

Puedes inspeccionar cómo el envío de una determinada acción cambia el estado haciendo clic en la acción:

devtools inspeccionando el árbol de state en redux

También es posible enviar acciones (dispatch) a la store utilizando las herramientas de desarrollo:

devtools enviando createNote con payload

El código actual de la aplicación se puede encontrar en GitHub, en la rama part6-3.