Saltar al contenido

d

Alterando datos en el servidor

Al crear notas en nuestra aplicación, naturalmente desearíamos almacenarlas en algún servidor backend. El paquete json-server afirma ser un llamado REST o API RESTful en su documentación:

Obtenga una API REST falsa completa sin codificación en menos de 30 segundos (en serio)

El servidor json no coincide exactamente con la descripción proporcionada por la definición de libro de texto de una API REST, pero tampoco la mayoría de las otras API que afirman ser RESTful.

Veremos más de cerca a REST en la próxima parte del curso, pero es importante familiarizarnos en este punto con algunas de las convenciones utilizadas por json-server y API REST en general. En particular, analizaremos el uso convencional de rutas (routes), también conocido como URLs y tipos de solicitud HTTP, en REST.

REST

En terminología REST, nos referimos a objetos de datos individuales, como las notas en nuestra aplicación, como recursos. Cada recurso tiene una dirección única asociada: su URL. De acuerdo con una convención general utilizada por json-server, podríamos ubicar una nota individual en la URL del recurso notes/3, donde 3 es el id del recurso. La URL de notes, por otro lado, apuntaría a una colección de recursos que contiene todas las notas.

Los recursos se obtienen del servidor con solicitudes HTTP GET. Por ejemplo, una solicitud HTTP GET a la URL notes/3 devolverá la nota que tiene el id 3. Una solicitud HTTP GET a la URL notes devolverá una lista de todas las notas.

La creación de un nuevo recurso para almacenar una nota se realiza mediante una solicitud HTTP POST a la URL notes de acuerdo con la convención REST a la que se adhiere el servidor json. Los datos del nuevo recurso de notas se envían en el cuerpo de la solicitud.

json-server requiere que todos los datos se envíen en formato JSON. Lo que esto significa en la práctica es que los datos deben ser una cadena con el formato correcto y que la solicitud debe contener el encabezado de solicitud Content-Type con el valor application/json.

Envío de datos al servidor

Realicemos los siguientes cambios en el controlador de eventos responsable de crear una nueva nota:

addNote = event => {
  event.preventDefault()
  const noteObject = {
    content: newNote,
    important: Math.random() < 0.5,
  }

  axios    .post('http://localhost:3001/notes', noteObject)    .then(response => {      console.log(response)    })}

Creamos un nuevo objeto para la nota pero omitimos la propiedad id, ¡ya que es mejor dejar que el servidor genere identificadores para nuestros recursos!

El objeto se envía al servidor mediante el método axios post. El controlador de eventos registrado registra la respuesta que se envía desde el servidor a la consola.

Cuando intentamos crear una nueva nota, lo siguiente aparece en la consola:

datos en formato json en la consola

La nota recién creada se almacena en el valor de la propiedad data del objeto response.

A veces puede resultar útil inspeccionar las solicitudes HTTP en la pestaña Network de las herramientas para desarrolladores de Chrome, que se utilizó mucho al comienzo de la parte 0:

Podemos usar el inspector para verificar que los encabezados enviados en la solicitud POST sean los que esperábamos que fueran.

encabezado en la herramienta de desarrollo muestra 201 created para localhost:3001/notes

Dado que los datos que enviamos en la solicitud POST eran un objeto JavaScript, axios supo automáticamente establecer el valor application/json para el encabezado Content-Type.

La pestaña payload puede ser utilizada para verificar los datos de la solicitud:

pestaña payload de las herramientas de desarrollo muestra los campos content e important

También la pestaña response es útil, muestra los datos con los que respondió el servidor:

pestaña response de las herramientas de desarrollo muestra el mismo content y payload pero con un campo de id

La nueva nota aún no se muestra en la pantalla. Esto se debe a que no actualizamos el estado del componente App cuando la creamos. Arreglemos esto:

addNote = event => {
  event.preventDefault()
  const noteObject = {
    content: newNote,
    important: Math.random() > 0.5,
  }

  axios
    .post('http://localhost:3001/notes', noteObject)
    .then(response => {
      setNotes(notes.concat(response.data))      setNewNote('')    })
}

La nueva nota devuelta por el servidor backend se agrega a la lista de notas en el estado de nuestra aplicación en la forma habitual de usar la función setNotes y luego reseteando el formulario de creación de notas. Un detalle importante para recordar es que el método concat no cambia el estado original del componente, sino que crea una nueva copia de la lista.

Una vez que los datos devueltos por el servidor comienzan a tener un efecto en el comportamiento de nuestras aplicaciones web, nos enfrentamos de inmediato a un nuevo conjunto de desafíos que surgen, por ejemplo, de la asincronicidad de la comunicación. Esto requiere nuevas estrategias de depuración, el registro de la consola y otros medios de depuración se vuelven cada vez más importantes, y también debemos desarrollar una comprensión suficiente de los principios tanto del entorno de ejecución de JavaScript como de los componentes de React. Adivinar no será suficiente.

Es beneficioso inspeccionar el estado del servidor backend, por ejemplo, a través del navegador:

salida de datos JSON del backend

Esto hace posible verificar que todos los datos que enviamos fueron recibidos por el servidor.

En la siguiente parte del curso aprenderemos a implementar nuestra propia lógica en el backend. Luego examinaremos más de cerca herramientas como postman que nos ayudan a depurar nuestras aplicaciones de servidor. Sin embargo, inspeccionar el estado del servidor json a través del navegador es suficiente para nuestras necesidades actuales.

El código para el estado actual de nuestra aplicación se puede encontrar en la rama part2-5 en github.

Cambiar la importancia de las notas

Agreguemos un botón a cada nota que se pueda usar para alternar su importancia.

Realizamos los siguientes cambios en el componente Note:

const Note = ({ note, toggleImportance }) => {
  const label = note.important
    ? 'make not important' : 'make important'

  return (
    <li>
      {note.content} 
      <button onClick={toggleImportance}>{label}</button>
    </li>
  )
}

Agregamos un botón al componente y asignamos su controlador de eventos como la función toggleImportance pasada en los props del componente.

El componente App define una versión inicial de la función de controlador de eventos toggleImportanceOf y la pasa a cada componente Note:

const App = () => {
  const [notes, setNotes] = useState([]) 
  const [newNote, setNewNote] = useState('')
  const [showAll, setShowAll] = useState(true)

  // ...

  const toggleImportanceOf = (id) => {    console.log('importance of ' + id + ' needs to be toggled')  }
  // ...

  return (
    <div>
      <h1>Notes</h1>
      <div>
        <button onClick={() => setShowAll(!showAll)}>
          show {showAll ? 'important' : 'all' }
        </button>
      </div>      
      <ul>
        {notesToShow.map((note, i) => 
          <Note
            key={i}
            note={note} 
            toggleImportance={() => toggleImportanceOf(note.id)}          />
        )}
      </ul>
      // ...
    </div>
  )
}

Observe cómo cada nota recibe su función de controlador de eventos única, ya que el id de cada nota es único.

Por ejemplo, si note.id es 3, la función de controlador de eventos devuelta por toggleImportance(note.id) será:

() => { console.log('importance of 3 needs to be toggled') }

Un breve recordatorio aquí. La cadena impresa por el controlador de eventos se define de manera similar a Java agregando las cadenas:

console.log('importance of ' + id + ' needs to be toggled')

La sintaxis de plantillas literales agregada en ES6 se puede usar para escribir cadenas similares de una manera mucho más agradable:

console.log(`importance of ${id} needs to be toggled`)

Ahora podemos usar la sintaxis de "llave de dólares" para agregar partes a la cadena que evaluarán las expresiones de JavaScript, por ejemplo, el valor de una variable. Ten en cuenta que las comillas utilizadas en las plantillas de cadenas difieren de las comillas utilizadas en las cadenas de JavaScript normales.

Las notas individuales almacenadas en el backend del servidor json se pueden modificar de dos formas diferentes haciendo solicitudes HTTP a la URL única de la nota. Podemos reemplazar la nota completa con una solicitud HTTP PUT, o solo cambiar algunas de las propiedades de la nota con una solicitud HTTP PATCH.

La forma final de la función del controlador de eventos es la siguiente:

const toggleImportanceOf = id => {
  const url = `http://localhost:3001/notes/${id}`
  const note = notes.find(n => n.id === id)
  const changedNote = { ...note, important: !note.important }

  axios.put(url, changedNote).then(response => {
    setNotes(notes.map(note => note.id !== id ? note : response.data))
  })
}

Casi todas las líneas de código en el cuerpo de la función contienen detalles importantes. La primera línea define la URL única para cada recurso de nota en función de su identificación.

El método de array find se usa para encontrar la nota que queremos modificar, y luego asignamos a la variable note.

Después de esto creamos un nuevo objeto que es una copia exacta de la nota anterior, excepto por la propiedad important que tiene su valor cambiado (de true a false o de false a true).

El código para crear el nuevo objeto que usa la sintaxis de object spread puede parecer un poco extraño:

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

En la práctica, { ...note } crea un nuevo objeto con copias de todas las propiedades del objeto note . Cuando agregamos propiedades dentro de las llaves después del objeto extendido, por ejemplo, { ...note, important: true }, entonces el valor de la propiedad important del nuevo objeto será true. En nuestro ejemplo, la propiedad important obtiene la negación de su valor anterior en el objeto original.

Hay algunas cosas que señalar. ¿Por qué hicimos una copia del objeto de nota que queríamos modificar, cuando el siguiente código también parece funcionar:

const note = notes.find(n => n.id === id)
note.important = !note.important

axios.put(url, note).then(response => {
  // ...

Esto no es recomendable porque la variable note es una referencia a un elemento en el array notes en el estado del componente, y como recordamos, nunca debemos mutar el estado directamente en React.

También vale la pena señalar que el nuevo objeto changedNote es solo una copia superficial, lo que significa que los valores del nuevo objeto son los mismos que los valores del objeto antiguo. Si los valores del objeto antiguo fueran objetos en sí mismos, los valores copiados en el nuevo objeto harían referencia a los mismos objetos que estaban en el objeto antiguo.

Luego, la nueva nota se envía con una solicitud PUT al backend donde reemplazará el objeto anterior.

La función callback establece el estado del componente notes en una nueva matriz que contiene todos los elementos de la matriz notes anterior, excepto la nota anterior que se reemplaza por la versión actualizada devuelta por el servidor:

axios.put(url, changedNote).then(response => {
  setNotes(notes.map(note => note.id !== id ? note : response.data))
})

Esto se logra con el método map:

notes.map(note => note.id !== id ? note : response.data)

El método map crea una nueva matriz al mapear cada elemento de la matriz anterior a un elemento de la nueva matriz. En nuestro ejemplo, la nueva matriz se crea de forma condicional de modo que si note.id !== id es verdadero, simplemente copiamos el elemento de la matriz anterior en la nueva matriz. Si la condición es falsa, el objeto de nota devuelto por el servidor se agrega a la matriz.

Este truco de map puede parecer un poco extraño al principio, pero vale la pena dedicar un tiempo a comprenderlo. Usaremos este método muchas veces a lo largo del curso.

Extraer la comunicación con el backend en un módulo separado

El componente App se ha hinchado un poco después de agregar el código para comunicarse con el servidor backend. En el espíritu del principio de responsabilidad única, consideramos prudente extraer esta comunicación en su propio módulo.

Creemos un directorio src/services y agreguemos allí un archivo llamado notes.js:

import axios from 'axios'
const baseUrl = 'http://localhost:3001/notes'

const getAll = () => {
  return axios.get(baseUrl)
}

const create = newObject => {
  return axios.post(baseUrl, newObject)
}

const update = (id, newObject) => {
  return axios.put(`${baseUrl}/${id}`, newObject)
}

export default { 
  getAll: getAll, 
  create: create, 
  update: update 
}

El módulo devuelve un objeto que tiene tres funciones (getAll, create y update) como propiedades que se ocupan de las notas. Las funciones devuelven directamente las promesas devueltas por los métodos axios.

El componente App usa import para obtener acceso al módulo:

import noteService from './services/notes'
const App = () => {

Las funciones del módulo se pueden usar directamente con la variable importada noteService de la siguiente manera:

const App = () => {
  // ...

  useEffect(() => {
    noteService      .getAll()      .then(response => {        setNotes(response.data)      })  }, [])

  const toggleImportanceOf = id => {
    const note = notes.find(n => n.id === id)
    const changedNote = { ...note, important: !note.important }

    noteService      .update(id, changedNote)      .then(response => {        setNotes(notes.map(note => note.id !== id ? note : response.data))      })  }

  const addNote = (event) => {
    event.preventDefault()
    const noteObject = {
      content: newNote,
      important: Math.random() > 0.5
    }

    noteService      .create(noteObject)      .then(response => {        setNotes(notes.concat(response.data))        setNewNote('')      })  }

  // ...
}

export default App

Podríamos llevar nuestra implementación un paso más allá. Cuando el componente App usa las funciones, recibe un objeto que contiene la respuesta completa para la solicitud HTTP:

noteService
  .getAll()
  .then(response => {
    setNotes(response.data)
  })

El componente App solo usa la propiedad response.data del objeto de respuesta.

El módulo sería mucho más agradable de usar si, en lugar de la respuesta HTTP completa, solo obtuviéramos los datos de respuesta. El uso del módulo se vería así:

noteService
  .getAll()
  .then(initialNotes => {
    setNotes(initialNotes)
  })

Podemos lograr esto cambiando el código en el módulo de la siguiente manera (el código actual contiene algo de copiar y pegar, pero lo toleraremos por ahora):

import axios from 'axios'
const baseUrl = 'http://localhost:3001/notes'

const getAll = () => {
  const request = axios.get(baseUrl)
  return request.then(response => response.data)
}

const create = newObject => {
  const request = axios.post(baseUrl, newObject)
  return request.then(response => response.data)
}

const update = (id, newObject) => {
  const request = axios.put(`${baseUrl}/${id}`, newObject)
  return request.then(response => response.data)
}

export default { 
  getAll: getAll, 
  create: create, 
  update: update 
}

Ya no devolvemos la promesa devuelta por axios directamente. En su lugar, asignamos la promesa a la variable request y llamamos a su método then:

const getAll = () => {
  const request = axios.get(baseUrl)
  return request.then(response => response.data)
}

La última fila en nuestra función es simplemente una expresión más compacta del mismo código que se muestra a continuación:

const getAll = () => {
  const request = axios.get(baseUrl)
  return request.then(response => {    return response.data  })}

La función getAll modificada todavía devuelve una promesa, como el método then de una promesa también devuelve una promesa.

Después de definir el parámetro del método then para devolver directamente response.data, hemos conseguido que la función getAll funcione como queríamos. Cuando la solicitud HTTP es exitosa, la promesa devuelve los datos enviados en la respuesta del backend.

Tenemos que actualizar el componente App para que funcione con los cambios realizados en nuestro módulo. Tenemos que arreglar las funciones callback dadas como parámetros a los métodos del objeto noteService, de modo que utilicen los datos de respuesta devueltos directamente:

const App = () => {
  // ...

  useEffect(() => {
    noteService
      .getAll()
      .then(initialNotes => {        setNotes(initialNotes)      })
  }, [])

  const toggleImportanceOf = id => {
    const note = notes.find(n => n.id === id)
    const changedNote = { ...note, important: !note.important }

    noteService
      .update(id, changedNote)
      .then(returnedNote => {        setNotes(notes.map(note => note.id !== id ? note : returnedNote))      })
  }

  const addNote = (event) => {
    event.preventDefault()
    const noteObject = {
      content: newNote,
      important: Math.random() > 0.5
    }

    noteService
      .create(noteObject)
      .then(returnedNote => {        setNotes(notes.concat(returnedNote))        setNewNote('')
      })
  }

  // ...
}

Todo esto es bastante complicado e intentar explicarlo puede dificultar la comprensión. Internet está lleno de material que discute el tema, como este.

El libro "Async and performance" de la serie de libros You don't know JS explica el tema bien, pero la explicación tiene muchas páginas.

Las promesas son fundamentales para el desarrollo moderno de JavaScript y se recomienda encarecidamente invertir una cantidad de tiempo razonable en comprenderlas.

Sintaxis más limpia para definir objetos literales

El módulo que define servicios relacionados con notas actualmente exporta un objeto con las propiedades getAll, create y update que son asignado a funciones para el manejo de notas.

La definición del módulo fue:

import axios from 'axios'
const baseUrl = 'http://localhost:3001/notes'

const getAll = () => {
  const request = axios.get(baseUrl)
  return request.then(response => response.data)
}

const create = newObject => {
  const request = axios.post(baseUrl, newObject)
  return request.then(response => response.data)
}

const update = (id, newObject) => {
  const request = axios.put(`${baseUrl}/${id}`, newObject)
  return request.then(response => response.data)
}

export default { 
  getAll: getAll, 
  create: create, 
  update: update 
}

El módulo exporta el siguiente objeto, de aspecto bastante peculiar:

{ 
  getAll: getAll, 
  create: create, 
  update: update 
}

Las etiquetas a la izquierda de los dos puntos en la definición del objeto son las claves del objeto, mientras que las que están a la derecha de este son variables que se definen dentro del módulo.

Dado que los nombres de las claves y las variables asignadas son los mismos, podemos escribir la definición del objeto con una sintaxis más compacta:

{
  getAll,
  create,
  update
}

Como resultado, la definición del módulo se simplifica de la siguiente forma:

import axios from 'axios'
const baseUrl = 'http://localhost:3001/notes'

const getAll = () => {
  const request = axios.get(baseUrl)
  return request.then(response => response.data)
}

const create = newObject => {
  const request = axios.post(baseUrl, newObject)
  return request.then(response => response.data)
}

const update = (id, newObject) => {
  const request = axios.put(`${baseUrl}/${id}`, newObject)
  return request.then(response => response.data)
}

export default { getAll, create, update }

Al definir el objeto con esta notación más corta, utilizamos una nueva característica que se introdujo a JavaScript a través de ES6, lo que permite una forma un poco más compacta de definir objetos mediante variables.

Para demostrar esta característica, consideremos una situación en la que tenemos los siguientes valores asignados a las variables:

const name = 'Leevi'
const age = 0

En versiones anteriores de JavaScript, teníamos que definir un objeto como este:

const person = {
  name: name,
  age: age
}

Sin embargo, dado que tanto los campos de propiedad como los nombres de las variables en el objeto son iguales, basta con escribir lo siguiente en ES6 JavaScript:

const person = { name, age }

El resultado es idéntico para ambas expresiones. Ambos crean un objeto con una propiedad name con el valor Leevi y una propiedad age con el valor 0.

Promesas y errores

Si nuestra aplicación permitiera a los usuarios eliminar notas, podríamos terminar en una situación en la que un usuario intenta cambiar la importancia de una nota que ya ha sido eliminada del sistema.

Simulemos esta situación haciendo que la función getAll del servicio de notas devuelva una nota "codificada" que en realidad no existe en el servidor backend:

const getAll = () => {
  const request = axios.get(baseUrl)
  const nonExisting = {
    id: 10000,
    content: 'This note is not saved to server',
    important: true,
  }
  return request.then(response => response.data.concat(nonExisting))
}

Cuando intentamos cambiar la importancia de la nota codificada, vemos el siguiente mensaje de error en la consola. El error dice que el servidor backend respondió a nuestra solicitud HTTP PUT con un código de estado 404 no encontrado (not found).

error 404 not found en herramientas de desarrollo

La aplicación debería poder manejar este tipo de situaciones de error con elegancia. Los usuarios no podrán saber que se ha producido un error a menos que tengan la consola abierta. La única forma en que se puede ver el error en la aplicación es que hacer clic en el botón no afecta la importancia de la nota.

Anteriormente mencionamos que una promesa puede estar en uno de tres estados diferentes. Cuando falla una solicitud HTTP, la promesa asociada se rechaza. Nuestro código actual no maneja este rechazo de ninguna manera.

El rechazo de una promesa se maneja proporcionando el método then con una segunda función callback, que se llama en la situación en la que se rechaza la promesa.

La forma más común de agregar un controlador para las promesas rechazadas es usar el método catch.

En la práctica, el controlador de errores para las promesas rechazadas se define así:

axios
  .get('http://example.com/probably_will_fail')
  .then(response => {
    console.log('success!')
  })
  .catch(error => {
    console.log('fail')
  })

Si la solicitud falla, se llama al controlador de eventos registrado con el método catch.

El método catch se utiliza a menudo colocándolo más profundamente en la cadena de promesas.

Cuando nuestra aplicación realiza una solicitud HTTP, de hecho estamos creando una cadena de promesa:

axios
  .put(`${baseUrl}/${id}`, newObject)
  .then(response => response.data)
  .then(changedNote => {
    // ...
  })

El método catch se puede utilizar para definir una función de controlador en el final de una cadena de promesa, que se llama una vez que cualquier promesa en la cadena arroja un error y la promesa es rechazada.

axios
  .put(`${baseUrl}/${id}`, newObject)
  .then(response => response.data)
  .then(changedNote => {
    // ...
  })
  .catch(error => {
    console.log('fail')
  })

Usemos esta característica y registremos un controlador de errores en el componente App:

const toggleImportanceOf = id => {
  const note = notes.find(n => n.id === id)
  const changedNote = { ...note, important: !note.important }

  noteService
    .update(id, changedNote).then(returnedNote => {
      setNotes(notes.map(note => note.id !== id ? note : returnedNote))
    })
    .catch(error => {      alert(        `the note '${note.content}' was already deleted from server`      )      setNotes(notes.filter(n => n.id !== id))    })}

El mensaje de error es mostrado al usuario con la vieja y confiable alerta, un cuadro de diálogo emergente, y la nota eliminada se filtra del estado.

La eliminación de una nota ya eliminada del estado de la aplicación se realiza con el método de array filter, que devuelve una nueva matriz que comprende solo los elementos de la lista para los cuales la función que se pasó como parámetro devuelve verdadero:

notes.filter(n => n.id !== id)

Probablemente no sea una buena idea usar alert en aplicaciones React más serias. Pronto aprenderemos una forma más avanzada de mostrar mensajes y notificaciones a los usuarios. Sin embargo, hay situaciones en las que un método simple y probado en batalla como alert puede funcionar como punto de partida. Siempre se podría agregar un método más avanzado más adelante, dado que hay tiempo y energía para ello.

El código para el estado actual de nuestra aplicación se puede encontrar en la rama part2-6 en GitHub.

Juramento del desarrollador Full Stack

Nuevamente es hora de los ejercicios. La complejidad de nuestra aplicación está aumentando, ya que además de ocuparnos de los componentes de React en el frontend, también tenemos un backend que persiste los datos de la aplicación.

Para hacer frente a la creciente complejidad, debemos extender el juramento del desarrollador web a un juramento del desarrollador Full Stack, que nos recuerda asegurarnos de que la comunicación entre el frontend y el backend ocurra como se espera.

Entonces, aquí está el juramento actualizado:

El desarrollo Full Stack es extremadamente difícil, por eso usaré todos los medios posibles para facilitarlo.

  • Mantendré abierta la consola de desarrolladores del navegador todo el tiempo.
  • Usaré la pestaña de red de las herramientas de desarrollo del navegador para asegurarme de que el frontend y el backend estén comunicándose como espero.
  • Mantendré constantemente un ojo en el estado del servidor para asegurarme de que los datos enviados por el frontend se guarden allí como espero.
  • Progresaré con pequeños pasos.
  • Escribiré muchos mensajes de console.log para asegurarme de entender cómo se comporta el código y ayudar a identificar problemas.
  • Si mi código no funciona, no escribiré más código. En cambio, empezaré a eliminar el código hasta que funcione o simplemente volveré a un estado en el que todo seguía funcionando.
  • Cuando pida ayuda en el canal de Discord o Telegram del curso o en otro lugar, formularé mis preguntas adecuadamente, consulta aquí cómo pedir ayuda.