Saltar al contenido

c

Comunicarse con el servidor en una aplicación redux

Expandamos la aplicación, de modo que las notas se almacenen en el backend. Usaremos json-server, de la parte 2.

El estado inicial de la base de datos se almacena en el archivo db.json, que se coloca en la raíz del proyecto:

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

Instalaremos json-server en nuestro proyecto...

npm install json-server --save-dev

y agregaremos la siguiente línea a la parte de scripts del archivo package.json

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

Ahora iniciemos json-server con el comando npm run server.

Obteniendo datos del backend

A continuación, crearemos un método en el archivo services/notes.js, que usa axios para obtener datos del 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 }

Agregaremos axios al proyecto

npm install axios

Cambiaremos la inicialización del estado en noteReducer, de modo que por defecto no haya notas:

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

También agreguemos una nueva acción appendNote para añadir un objeto de una nota:

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

Una manera rápida para inicializar el estado de las notas basado en los datos recibidos del backend es extraer las notas en el archivo main.jsx y enviar (dispatch) una acción usando el action creator appendNote para cada objeto de nota:

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

Enviar (dispatching) múltiples acciones no parece muy práctico. Agreguemos un action creator setNotes que se pueda usar para reemplazar directamente al array de notas. Obtendremos al action creator de la función createSlice implementando la acción 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

Ahora, el código en el archivo main.jsx se ve mucho mejor:

// ...
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: ¿Por qué no usamos await en lugar de promesas y controladores de eventos?

Await solo funciona dentro de funciones async, y el código en main.jsx no está dentro de una función, por lo que debido a la naturaleza simple de la operación, esta vez nos abstendremos de usar async.

Sin embargo, decidimos mover la inicialización de las notas al componente App y, como es habitual al obtener datos de un servidor, usaremos el effect hook.

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

Enviando datos al backend

Podemos hacer lo mismo cuando se trata de crear una nueva nota. Expandamos el código comunicándonos con el servidor de la siguiente manera:

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

El método addNote del componente NewNote cambia ligeramente:

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

Debido a que el backend genera ids para las notas, cambiaremos el action creator createNote en el archivo noteReducer.js de la siguiente manera:

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

El cambio de importancia de las notas podría implementarse utilizando el mismo principio, haciendo una llamada asíncrona al servidor y luego enviando una acción apropiada.

El estado actual del código para la aplicación se puede encontrar en GitHub en la rama part6-3.

Acciones asíncronas y Redux Thunk

Nuestro enfoque es bastante bueno, pero no es muy bueno que la comunicación con el servidor suceda dentro de las funciones de los componentes. Sería mejor si la comunicación pudiera abstraerse de los componentes para que no tengan que hacer nada más que llamar al action creator apropiado. Como ejemplo, App inicializaría el estado de la aplicación de la siguiente manera:

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

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

  // ...
}

y NewNote crearía una nueva nota de la siguiente manera:

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

  // ...
}

En esta implementación, ambos componentes enviarían una acción sin necesidad de saber sobre la comunicación con el servidor que sucede detrás de escena. Estos tipos de acciones asíncronas se pueden implementar utilizando la librería Redux Thunk. El uso de la librería no requiere ninguna configuración adicional o incluso instalación cuando el store de Redux se ha creado utilizando la función configureStore del kit de herramientas de Redux (Redux Toolkit).

Con Redux Thunk, es posible implementar action creators que devuelven una función en lugar de un objeto. La función recibe los métodos dispatch y getState del store de Redux como parámetros. Esto permite, por ejemplo, implementaciones de action creators asíncronos, que primero esperan la finalización de una cierta operación asíncrona y luego despachan alguna acción, que cambia el estado del store.

Podemos implementar un action creator initializeNotes que inicializa las notas basadas en los datos recibidos del servidor de la siguiente manera:

// ...
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

En la función interna, es decir, la acción asíncrona, la operación primero obtiene todas las notas del servidor y luego despacha la acción setNotes, que las agrega al store.

El componente App puede inicializar las notas de la siguiente manera:

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

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

La solución es bastante elegante. La lógica de inicialización de las notas se ha separado completamente del componente React.

Ahora, reemplacemos el action creator createNote creado por la función createSlice con un action creator asíncrono:

// ...
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

El principio aquí es el mismo: primero se ejecuta una operación asíncrona y luego se despacha la acción que cambia el estado del store.

El componente NewNote cambia como se muestra a continuación:

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

Finalmente, limpiemos un poco el archivo main.jsx moviendo el código relacionado con la creación del store de Redux a su propio archivo 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

Luego de los cambios, el contenido del archivo main.jsx es el siguiente:

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

El estado actual del código de la aplicación se puede encontrar en GitHub en la rama part6-5.

Redux Toolkit ofrece una gran cantidad de herramientas para simplificar la administración de estado asíncrono. Las herramientas adecuadas para este caso de uso son, por ejemplo, la función createAsyncThunk y la API RTK Query.