Aller au contenu

b

Quelques reducers

Continuons notre travail avec la version simplifiée Redux de notre application de notes.

Pour faciliter notre développement, modifions notre reducer afin que le store soit initialisé avec un état contenant quelques notes:

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 avec un état complexe

Implémentons un filtrage pour les notes affichées à l'utilisateur. L'interface utilisateur pour les filtres sera mise en oeuvre avec des boutons radio:

navigateur avec boutons radio important/pas important et liste

Commençons par une mise en oeuvre très simple et directe:

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>
  )
}

Puisque l'attribut name de tous les boutons radio est le même, ils forment un groupe de boutons où une seule option peut être sélectionnée.

Les boutons ont un gestionnaire de changement qui imprime actuellement seulement la chaîne associée au bouton cliqué dans la console.

Nous décidons d'implémenter la fonctionnalité de filtre en stockant la valeur du filtre dans le store Redux en plus des notes elles-mêmes. L'état du store devrait ressembler à ceci après avoir effectué ces changements:

{
  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'
}

Dans l'implémentation actuelle de notre application, seul le tableau de notes est stocké dans l'état. Dans la nouvelle implémentation, l'objet d'état a deux propriétés, notes qui contient le tableau de notes et filter qui contient une chaîne indiquant quelles notes doivent être affichées à l'utilisateur.

Reducers combinés

Nous pourrions modifier notre reducer actuel pour gérer la nouvelle forme de l'état. Cependant, une meilleure solution dans cette situation est de définir un nouveau reducer séparé pour l'état du filtre:

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

Les actions pour changer l'état du filtre ressemblent à ceci:

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

Créons également une nouvelle fonction de créateur d'action. Nous écrirons le code pour le créateur d'action dans un nouveau module src/reducers/filterReducer.js:

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

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

export default filterReducer

Nous pouvons créer le reducer actuel pour notre application en combinant les deux reducers existants avec la fonction combineReducers.

Définissons le reducer combiné dans le fichier main.jsx:

import React from 'react'
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>
)

Puisque notre application se casse complètement à ce point, nous rendons un élément div vide au lieu du composant App.

L'état du store est imprimé dans la console:

console devtools montrant les données du tableau de notes

Comme nous pouvons le voir dans la sortie, le store a exactement la forme que nous voulions!

Examinons de plus près comment le reducer combiné est créé:

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

L'état du store défini par le reducer ci-dessus est un objet avec deux propriétés: notes et filter. La valeur de la propriété notes est définie par le noteReducer, qui n'a pas à gérer les autres propriétés de l'état. De même, la propriété filter est gérée par le filterReducer.

Avant de faire plus de changements dans le code, examinons comment différentes actions changent l'état du store défini par le reducer combiné. Ajoutons ce qui suit au fichier 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'))

En simulant la création d'une note et en changeant l'état du filtre de cette manière, l'état du store est enregistré dans la console après chaque changement effectué dans le store:

sortie console devtools montrant le filtre de notes et la nouvelle note

À ce stade, il est bon de prendre conscience d'un petit mais important détail. Si nous ajoutons une instruction de log console au début des deux reducers:

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

D'après la sortie console, on pourrait avoir l'impression que chaque action est dupliquée:

sortie console devtools montrant des actions dupliquées dans les reducers de note et de filtre

Y a-t-il un bug dans notre code ? Non. Le reducer combiné fonctionne de telle manière que chaque action est gérée dans chaque partie du reducer combiné. Typiquement, un seul reducer est intéressé par une action donnée, mais il y a des situations où plusieurs reducers changent leurs parties respectives de l'état basées sur la même action.

Finaliser les filtres

Terminons l'application de sorte qu'elle utilise le reducer combiné. Nous commençons par changer le rendu de l'application et en connectant le store à l'application dans le fichier main.jsx:

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

Ensuite, corrigeons un bug causé par le code qui s'attend à ce que le store de l'application soit un tableau de notes:

TypeError dans le navigateur: notes.map n'est pas une fonction

C'est une correction facile. Étant donné que les notes se trouvent dans le champ notes du store, nous devons juste apporter un petit changement à la fonction de sélection:

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>
  )
}

Auparavant, la fonction sélecteur retournait l'intégralité de l'état du store:

const notes = useSelector(state => state)

Et maintenant, elle retourne seulement son champ notes

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

Extrayons le filtre de visibilité dans son propre composant 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

Avec le nouveau composant App peut être simplifié comme suit:

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

L'implémentation est plutôt simple. Cliquer sur les différents boutons radio change l'état de la propriété filter du store.

Changeons le composant Notes pour incorporer le filtre:

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>
  )

Nous ne faisons des modifications qu'à la fonction sélectrice, qui était auparavant

useSelector(state => state.notes)

Let's simplify the selector by destructuring the fields from the state it receives as a parameter:

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

Il y a un léger défaut cosmétique dans notre application. Même si le filtre est réglé sur ALL par défaut, le bouton radio associé n'est pas sélectionné. Naturellement, ce problème peut être résolu, mais puisqu'il s'agit d'un bug désagréable mais finalement inoffensif, nous allons reporter la correction à plus tard.

La version actuelle de l'application peut être trouvée sur GitHub, branche part6-2.

Redux Toolkit

Comme nous l'avons vu jusqu'à présent, la configuration de Redux et la mise en oeuvre de la gestion de l'état nécessitent pas mal d'efforts. Cela se manifeste, par exemple, dans le code lié aux reducers et aux créateurs d'actions, qui comprend un code modèle (boilerplate) quelque peu répétitif. Redux Toolkit est une bibliothèque qui résout ces problèmes communs liés à Redux. La bibliothèque simplifie grandement, par exemple, la configuration du store Redux et offre une grande variété d'outils pour faciliter la gestion de l'état.

Commençons à utiliser Redux Toolkit dans notre application en refactorisant le code existant. Tout d'abord, nous devrons installer la bibliothèque:

npm install @reduxjs/toolkit

Ensuite, ouvrez le fichier main.jsx qui crée actuellement le store Redux. Au lieu de la fonction createStore de Redux, créons le store en utilisant la fonction configureStore de Redux Toolkit:

import React from 'react'
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>
)

Nous avons déjà éliminé quelques lignes de code maintenant que nous n'avons pas besoin de la fonction combineReducers pour créer le reducer pour le store. Nous verrons bientôt que la fonction configureStore offre de nombreux avantages supplémentaires, tels que l'intégration sans effort d'outils de développement et de nombreuses bibliothèques couramment utilisées sans avoir besoin de configuration supplémentaire.

Passons à la refonte des reducers, ce qui met en avant les avantages de Redux Toolkit. Avec Redux Toolkit, nous pouvons facilement créer des reducers et des créateurs d'actions associés en utilisant la fonction createSlice . Nous pouvons utiliser la fonction createSlice pour refondre le reducer et les créateurs d'actions dans le fichier reducers/noteReducer.js de la manière suivante:

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       )         }  },})

La fonction createSlice utilise le paramètre name pour définir le préfixe utilisé dans les valeurs de type des actions. Par exemple, l'action createNote définie plus tard aura une valeur de type notes/createNote. Il est de bonne pratique de donner à ce paramètre une valeur qui est unique parmi les reducers. De cette façon, il n'y aura pas de collisions inattendues entre les valeurs de type d'action de l'application. Le paramètre initialState définit l'état initial du reducer. Le paramètre reducers prend le reducer lui-même comme un objet, dont les fonctions gèrent les changements d'état provoqués par certaines actions. Notez que l'action.payload dans la fonction contient l'argument fourni en appelant le créateur d'action:

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

Cet appel de dispatch réagit à la diffusion de l'objet suivant:

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

Si vous avez suivi attentivement, vous avez peut-être remarqué que, à l'intérieur de l'action createNote, il semble se produire quelque chose qui enfreint le principe d'immutabilité des reducers mentionné précédemment:

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

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

Nous modifions le tableau de l'argument state en appelant la méthode push au lieu de retourner une nouvelle instance du tableau. De quoi s'agit-il?

Redux Toolkit utilise la bibliothèque Immer avec les reducers créés par la fonction createSlice, ce qui rend possible la mutation de l'argument state à l'intérieur du reducer. Immer utilise l'état muté pour produire un nouvel état immuable et ainsi les changements d'état restent immuables. Notez que l'state peut être changé sans être "muté", comme nous l'avons fait avec l'action toggleImportanceOf. Dans ce cas, la fonction retourne le nouvel état. Néanmoins, muter l'état sera souvent pratique, en particulier lorsqu'un état complexe doit être mis à jour.

La fonction createSlice retourne un objet contenant le reducer ainsi que les créateurs d'actions définis par le paramètre reducers. Le reducer peut être accédé par la propriété noteSlice.reducer, tandis que les créateurs d'actions par la propriété noteSlice.actions. Nous pouvons produire les exports du fichier de la manière suivante:

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

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

Les importations dans d'autres fichiers fonctionneront exactement comme avant:

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

Nous devons modifier les noms des types d'action dans les tests en raison des conventions de Redux Toolkit:

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 et console.log

Comme nous l'avons appris, console.log est un outil extrêmement puissant; il nous sauve souvent des ennuis.

Essayons d'imprimer l'état du Store Redux dans la console au milieu du reducer créé avec la fonction 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 
      )     
    }
  },
})

Ce qui suit est imprimé dans la console

devtools console montrant Handler, Target comme null mais IsRevoked comme vrai

La sortie est intéressante mais pas très utile. Cela concerne la bibliothèque Immer mentionnée précédemment utilisée par Redux Toolkit, qui est maintenant utilisée en interne pour sauvegarder l'état du Store.

Le statut peut être converti en un format lisible par l'homme, par exemple en le convertissant en chaîne de caractères puis de nouveau en objet JavaScript comme suit:

console.log(JSON.parse(JSON.stringify(state)))

La sortie de la console est maintenant lisible par l'humain

dev tools montrant un tableau de 2 notes

Redux DevTools

Redux DevTools est une extension Chrome qui offre des outils de développement utiles pour Redux. Elle peut être utilisée, par exemple, pour inspecter l'état du store Redux et dispatcher des actions via la console du navigateur. Lorsque le store est créé en utilisant la fonction configureStore de Redux Toolkit, aucune configuration supplémentaire n'est nécessaire pour que Redux DevTools fonctionne.

Une fois l'extension installée, cliquer sur l'onglet Redux dans la console du navigateur devrait ouvrir les outils de développement:

navigateur avec l'addon redux dans devtools

Vous pouvez inspecter comment le dispatching d'une action spécifique change l'état en cliquant sur l'action:

devtools inspectant l'arbre des notes dans redux

Il est également possible de dispatcher des actions vers le store en utilisant les outils de développement:

devtools redux dispatchant createNote avec payload

Vous pouvez trouver le code de notre application actuelle dans son intégralité dans la branche part6-3 de ce dépôt GitHub.