Aller au contenu

c

Communiquer avec le backend dans l'application Redux

Étendons l'application afin que les notes soient stockées dans le backend. Nous utiliserons json-server, déjà connu de la partie 2.

L'état initial de la base de données est stocké dans le fichier db.json, qui est placé à la racine du projet:

{
  "notes": [
    {
      "content": "the app state is in redux store",
      "important": true,
      "id": 1
    },
    {
      "content": "state changes are made with actions",
      "important": false,
      "id": 2
    }
  ]
}

Nous installerons json-server pour le projet:

npm install json-server --save-dev

et ajoutez la ligne suivante à la partie scripts du fichier package.json

"scripts": {
  "server": "json-server -p3001 --watch db.json",
  // ...
}

Maintenant, lançons json-server avec la commande npm run server.

Récupération des données depuis le backend

Ensuite, nous allons créer une méthode dans le fichier services/notes.js, qui utilise axios pour récupérer les données depuis le backend

import axios from 'axios'

const baseUrl = 'http://localhost:3001/notes'

const getAll = async () => {
  const response = await axios.get(baseUrl)
  return response.data
}

export default { getAll }

Nous allons ajouter axios au projet

npm install axios

Nous allons modifier l'initialisation de l'état dans noteReducer, de sorte qu'il n'y ait par défaut aucune note:

const noteSlice = createSlice({
  name: 'notes',
  initialState: [],  // ...
})

Ajoutons également une nouvelle action appendNote pour ajouter un objet note:

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 
      )     
    },
    appendNote(state, action) {      state.push(action.payload)    }  },
})

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

Une manière rapide d'initialiser l'état des notes en fonction des données reçues du serveur consiste à récupérer les notes dans le fichier main.jsx et à dispatcher une action en utilisant le créateur d'action appendNote pour chaque objet note individuel:

// ...
import noteService from './services/notes'import noteReducer, { appendNote } from './reducers/noteReducer'
const store = configureStore({
  reducer: {
    notes: noteReducer,
    filter: filterReducer,
  }
})

noteService.getAll().then(notes =>  notes.forEach(note => {    store.dispatch(appendNote(note))  }))
// ...

Dispatcher plusieurs actions peut sembler peu pratique. Ajoutons un créateur d'action setNotes qui peut être utilisé pour remplacer directement le tableau des notes. Nous obtiendrons le créateur d'action de la fonction createSlice en implémentant l'action setNotes:

// ...

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 
      )     
    },
    appendNote(state, action) {
      state.push(action.payload)
    },
    setNotes(state, action) {      return action.payload    }  },
})

export const { createNote, toggleImportanceOf, appendNote, setNotes } = noteSlice.actions
export default noteSlice.reducer

Maintenant, le code dans le fichier main.jsx est beaucoup plus propre:

// ...
import noteService from './services/notes'
import noteReducer, { setNotes } from './reducers/noteReducer'
const store = configureStore({
  reducer: {
    notes: noteReducer,
    filter: filterReducer,
  }
})

noteService.getAll().then(notes =>
  store.dispatch(setNotes(notes)))

NB: Pourquoi n'avons-nous pas utilisé await à la place des promesses et des gestionnaires d'événements (enregistrés sur les méthodes then)?

Await ne fonctionne qu'à l'intérieur des fonctions async, et le code dans main.jsx n'est pas à l'intérieur d'une fonction, donc en raison de la nature simple de l'opération, nous nous abstiendrons d'utiliser async cette fois-ci.

Cependant, nous décidons de déplacer l'initialisation des notes dans le composant App, et, comme d'habitude, lors de la récupération de données depuis un serveur, nous utiliserons le hook d'effet.

import { useEffect } from 'react'import NewNote from './components/NewNote'
import Notes from './components/Notes'
import VisibilityFilter from './components/VisibilityFilter'
import noteService from './services/notes'import { setNotes } from './reducers/noteReducer'import { useDispatch } from 'react-redux'
const App = () => {
  const dispatch = useDispatch()  useEffect(() => {    noteService      .getAll().then(notes => dispatch(setNotes(notes)))  }, [])
  return (
    <div>
      <NewNote />
      <VisibilityFilter />
      <Notes />
    </div>
  )
}

export default App

Envoyer des données vers le backend

nous pouvons procéder de la même manière que pour la création d'une nouvelle note. Étendons le code communiquant avec le serveur de la manière suivante:

const baseUrl = 'http://localhost:3001/notes'

const getAll = async () => {
  const response = await axios.get(baseUrl)
  return response.data
}

const createNew = async (content) => {  const object = { content, important: false }  const response = await axios.post(baseUrl, object)  return response.data}
export default {
  getAll,
  createNew,}

La méthode addNote du composant NewNote change légèrement:

import { useDispatch } from 'react-redux'
import { createNote } from '../reducers/noteReducer'
import noteService from '../services/notes'
const NewNote = (props) => {
  const dispatch = useDispatch()
  
  const addNote = async (event) => {    event.preventDefault()
    const content = event.target.note.value
    event.target.note.value = ''
    const newNote = await noteService.createNew(content)    dispatch(createNote(newNote))  }

  return (
    <form onSubmit={addNote}>
      <input name="note" />
      <button type="submit">add</button>
    </form>
  )
}

export default NewNote

Puisque le backend génère les identifiants pour les notes, nous allons modifier le créateur d'action createNote dans le fichier noteReducer.js en conséquence:

const noteSlice = createSlice({
  name: 'notes',
  initialState: [],
  reducers: {
    createNote(state, action) {
      state.push(action.payload)    },
    // ..
  },
})

Modifier l'importance des notes pourrait être implémenté en utilisant le même principe, en effectuant un appel de méthode asynchrone au serveur puis en dispatchant une action appropriée.

L'état actuel du code pour l'application peut être trouvé sur GitHub dans la branche part6-3.

Actions asynchrones et Redux thunk

Notre approche est assez bonne, mais il n'est pas idéal que la communication avec le serveur se fasse à l'intérieur des fonctions des composants. Il serait préférable que cette communication soit abstraite des composants, de sorte qu'ils n'aient rien d'autre à faire que d'appeler le créateur d'action approprié. Par exemple, App initialiserait l'état de l'application comme suit:

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

  useEffect(() => {
    dispatch(initializeNotes())  
  }, []) 

  // ...
}

et NewNote créerait une nouvelle note comme suit:

const NewNote = () => {
  const dispatch = useDispatch()
  
  const addNote = async (event) => {
    event.preventDefault()
    const content = event.target.note.value
    event.target.note.value = ''
    dispatch(createNote(content))
  }

  // ...
}

Dans cette implémentation, les deux composants enverraient une action sans avoir besoin de connaître la communication avec le serveur qui se passe en coulisse. Ce genre d'actions asynchrones peut être implémenté en utilisant la bibliothèque Redux Thunk. L'utilisation de la bibliothèque ne nécessite aucune configuration supplémentaire ni même d'installation lorsque le magasin Redux est créé en utilisant la fonction configureStore de Redux Toolkit.

Avec Redux Thunk, il est possible d'implémenter des créateurs d'action qui retournent une fonction au lieu d'un objet. La fonction reçoit les méthodes dispatch et getState du magasin Redux comme paramètres. Cela permet, par exemple, des implémentations de créateurs d'action asynchrones, qui attendent d'abord la complétion d'une certaine opération asynchrone et après cela envoient une action, qui change l'état du magasin.

Nous pouvons définir un créateur d'action initializeNotes qui initialise les notes basées sur les données reçues du serveur:

// ...
import noteService from '../services/notes'
const noteSlice = createSlice(/* ... */)

export const { createNote, toggleImportanceOf, setNotes, appendNote } = noteSlice.actions

export const initializeNotes = () => {  return async dispatch => {    const notes = await noteService.getAll()    dispatch(setNotes(notes))  }}
export default noteSlice.reducer

Dans la fonction interne, c'est-à-dire l'action asynchrone, l'opération commence d'abord par récupérer toutes les notes du serveur, puis envoie l'action setNotes, qui les ajoute au magasin.

Le composant App peut maintenant être défini comme suit:

// ...
import { initializeNotes } from './reducers/noteReducer'
const App = () => {
  const dispatch = useDispatch()

  useEffect(() => {    dispatch(initializeNotes())   }, []) 
  return (
    <div>
      <NewNote />
      <VisibilityFilter />
      <Notes />
    </div>
  )
}

La solution est élégante. La logique d'initialisation des notes a été complètement séparée du composant React.

Ensuite, remplaçons le créateur d'action createNote créé par la fonction createSlice par un créateur d'action asynchrone:

// ...
import noteService from '../services/notes'

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 
      }

      return state.map(note =>
        note.id !== id ? note : changedNote 
      )     
    },
    appendNote(state, action) {
      state.push(action.payload)
    },
    setNotes(state, action) {
      return action.payload
    }
    // createNote definition removed from here!
  },
})

export const { toggleImportanceOf, appendNote, setNotes } = noteSlice.actions
export const initializeNotes = () => {
  return async dispatch => {
    const notes = await noteService.getAll()
    dispatch(setNotes(notes))
  }
}

export const createNote = content => {  return async dispatch => {    const newNote = await noteService.createNew(content)    dispatch(appendNote(newNote))  }}
export default noteSlice.reducer

Le principe est le même ici: d'abord, une opération asynchrone est exécutée, après quoi l'action qui change l'état du store est dispatchée.

Le composant NewNote change comme suit:

// ...
import { createNote } from '../reducers/noteReducer'
const NewNote = () => {
  const dispatch = useDispatch()
  
  const addNote = async (event) => {
    event.preventDefault()
    const content = event.target.note.value
    event.target.note.value = ''
    dispatch(createNote(content))  }

  return (
    <form onSubmit={addNote}>
      <input name="note" />
      <button type="submit">add</button>
    </form>
  )
}

Pour finir, nettoyons un peu le fichier main.jsx en déplaçant le code relatif à la création du store Redux dans son propre fichier, store.js:

import { configureStore } from '@reduxjs/toolkit'

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

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

export default store

Après les modifications, le contenu du fichier main.jsx est le suivant:

import React from 'react'
import ReactDOM from 'react-dom/client'
import { Provider } from 'react-redux' 
import store from './store'import App from './App'

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

L'état actuel du code pour l'application peut être trouvé sur GitHub dans la branche part6-5.

Redux Toolkit offre une multitude d'outils pour simplifier la gestion de l'état asynchrone. Des outils adaptés à ce cas d'usage incluent par exemple la fonction createAsyncThunk et l'API RTK Query.