Saltar al contenido

b

React y GraphQL

A continuación, implementaremos una aplicación React que usa el servidor GraphQL que creamos.

El código actual del servidor se puede encontrar en Github, rama part8-3.

En teoría, podríamos usar GraphQL con solicitudes HTTP POST. A continuación se muestra un ejemplo de esto con Postman.

fullstack content

La comunicación funciona enviando solicitudes HTTP POST a http://localhost:4000/graphql. La consulta en sí es una cadena enviada como el valor de la clave query.

Podríamos encargarnos de la comunicación entre la aplicación React y GraphQl usando Axios. Sin embargo, la mayoría de las veces no es muy sensato hacerlo. Es una mejor idea utilizar una librería de orden superior capaz de abstraer los detalles innecesarios de la comunicación.

Por el momento hay dos buenas opciones: Relay por Facebook y Apollo Client. De estos dos, Apollo es absolutamente más popular, y también lo usaremos.

Cliente Apollo

Crearemos una nueva aplicación React e instalaremos las dependencias requeridas por Apollo client.

npm install @apollo/client graphql

Comenzaremos con el siguiente código para nuestra aplicación.

import React from 'react'
import ReactDOM from 'react-dom'
import App from './App'

import { ApolloClient, HttpLink, InMemoryCache, gql } from '@apollo/client'

const client = new ApolloClient({
  cache: new InMemoryCache(),
  link: new HttpLink({
    uri: 'http://localhost:4000',
  })
})

const query = gql`
query {
  allPersons  {
    name,
    phone,
    address {
      street,
      city
    }
    id
  }
}
`

client.query({ query })
  .then((response) => {
    console.log(response.data)
  })

ReactDOM.render(<App />, document.getElementById('root'))

El comienzo del código crea un nuevo objeto-client, que luego se usa para enviar una consulta al servidor:

client.query({ query })
  .then((response) => {
    console.log(response.data)
  })

La respuesta del servidor se imprime en la consola:

fullstack content

La aplicación puede comunicarse con un servidor GraphQL usando el objeto client. Se puede hacer que el cliente sea accesible para todos los componentes de la aplicación empaquetando el componente App con ApolloProvider.

import React from 'react'
import ReactDOM from 'react-dom'
import App from './App'

import { 
  ApolloClient, ApolloProvider, HttpLink, InMemoryCache} from '@apollo/client' 

const client = new ApolloClient({
  cache: new InMemoryCache(),
  link: new HttpLink({
    uri: 'http://localhost:4000',
  })
})

ReactDOM.render(
  <ApolloProvider client={client}>    <App />
  </ApolloProvider>,  document.getElementById('root')
)

Realización de consultas

Estamos listos para implementar la vista principal de la aplicación, que muestra una lista de números de teléfono.

Apollo Client ofrece algunas alternativas para realizar consultas. Actualmente, el uso de la función hook useQuery es la práctica dominante.

La consulta la realiza el componente App, cuyo código es el siguiente:

import React from 'react'
import { gql, useQuery } from '@apollo/client';

const ALL_PERSONS = gql`
query {
  allPersons  {
    name
    phone
    id
  }
}
`

const App = () => {
  const result = useQuery(ALL_PERSONS)

  if (result.loading)  {
    return <div>loading...</div>
  }

  return (
    <div>
      {result.data.allPersons.map(p => p.name).join(', ')}
    </div>
  )
}

export default App

Cuando se llama, useQuery realiza la consulta que recibe como parámetro. Devuelve un objeto con varios campos. El campo loading es verdadero si la consulta aún no ha recibido una respuesta. Luego se renderiza el siguiente código:

if ( result.loading ) {
  return <div>loading...</div>
}

Cuando se recibe la respuesta, el resultado de la consulta allPersons se puede encontrar en el campo data, y podemos mostrar la lista de nombres en la pantalla.

<div>
  {result.data.allPersons.map(p => p.name).join(', ')}
</div>

Separemos la visualización de la lista de personas en su propio componente

const Persons = ({ persons }) => {
  return (
    <div>
      <h2>Persons</h2>
      {persons.map(p =>
        <div key={p.name}>
          {p.name} {p.phone}
        </div>  
      )}
    </div>
  )
}

El componente App aún realiza la consulta y pasa el resultado al nuevo componente que se va a representar:

const App = () => {
  const result = useQuery(ALL_PERSONS)

  if (result.loading)  {
    return <div>loading...</div>
  }

  return (
    <Persons persons = {result.data.allPersons}/>
  )
}

Consultas y variables con nombre

Implementemos la funcionalidad para ver los detalles de la dirección de una persona. La consulta findPerson es adecuada para esto.

Las consultas que hicimos en el último capítulo tenían el parámetro codificado en la consulta:

query {
  findPerson(name: "Arto Hellas") {
    phone 
    city 
    street
    id
  }
}

Cuando hacemos consultas programáticamente, debemos ser capaz de darles parámetros dinámicamente.

Las variables GraphQL son muy adecuadas para esto. Para poder utilizar variables, también debemos nombrar nuestras consultas.

Un buen formato para la consulta es este:

query findPersonByName($nameToSearch: String!) {
  findPerson(name: $nameToSearch) {
    name
    phone 
    address {
      street
      city
    }
  }
}

El nombre de la consulta es findPersonByName, y se le da una cadena $nameToSearch como parámetro.

También es posible realizar consultas con parámetros con GraphQL Playground. Los parámetros se dan en Variables de consulta:

fullstack content

El hook useQuery es adecuado para situaciones en las que la consulta se realiza cuando se procesa el componente. Sin embargo, ahora queremos realizar la consulta solo cuando un usuario desea ver los detalles de una persona específica, por lo que la consulta se realiza solo según sea necesario.

Para esta situación, la función hook useLazyQuery es una buena opción. El componente Persons se convierte en:

const FIND_PERSON = gql`  query findPersonByName($nameToSearch: String!) {    findPerson(name: $nameToSearch) {      name      phone       id      address {        street        city      }    }  }`
const Persons = ({ persons }) => {
  const [getPerson, result] = useLazyQuery(FIND_PERSON)   const [person, setPerson] = useState(null)
  const showPerson = (name) => {    getPerson({ variables: { nameToSearch: name } })  }
  useEffect(() => {    if (result.data) {      setPerson(result.data.findPerson)    }  }, [result])
  if (person) {    return(      <div>        <h2>{person.name}</h2>        <div>{person.address.street} {person.address.city}</div>        <div>{person.phone}</div>        <button onClick={() => setPerson(null)}>close</button>      </div>    )  }  
  return (
    <div>
      <h2>Persons</h2>
      {persons.map(p =>
        <div key={p.name}>
          {p.name} {p.phone}
          <button onClick={() => showPerson(p.name)} >            show address          </button>         </div>  
      )}
    </div>
  )
}

export default Persons

El código ha cambiado bastante y todos los cambios no son completamente evidentes.

Cuando se hace clic en el botón "show address" de una persona, se hace clic en el controlador de eventos showPerson, y realiza una consulta GraphQL para obtener los detalles de las personas:

const [getPerson, result] = useLazyQuery(FIND_PERSON) 

// ...

const showPerson = (name) => {
  getPerson({ variables: { nameToSearch: name } })
}

La variable nameToSearch de la consulta recibe un valor cuando se ejecuta la consulta.

La respuesta de la consulta se guarda en la variable result, y su valor se guarda en el estado del componente person en el hook useEffect.

useEffect(() => {
  if (result.data) {
    setPerson(result.data.findPerson)
  }
}, [result])

El segundo parámetro del hook es result, por lo que la función dada al hook como su segundo parámetro se ejecuta cada vez que la consulta obtiene los detalles de una persona diferente. ¿No manejaríamos la actualización de una manera controlada en un hook, volver de la vista de una sola persona a una lista de todas las personas causaría problemas?

Si el estado person tiene un valor, en lugar de mostrar una lista de todas las personas, solo se muestran los detalles de una persona.

fullstack content

Cuando un usuario quiere volver a la lista de personas, el estado person se establece en null.

La solución no es la más ordenada posible, pero es lo suficientemente buena para nosotros.

El código actual de la aplicación se puede encontrar en Github branch part8-1.

Caché

Cuando hacemos varias consultas, por ejemplo, los detalles de la dirección de Arto Hellas, notamos algo interesante: la consulta al backend se realiza solo la primera vez. Después de esto, a pesar de que el código vuelve a realizar la misma consulta, la consulta no se envía al backend.

fullstack content

El cliente Apollo guarda las respuestas de las consultas en cache. Para optimizar el rendimiento si la respuesta a una consulta ya está en la caché, la consulta no se envía al servidor en absoluto.

Es posible instalar Apollo Client devtools en Chrome para ver el estado de la caché.

fullstack content

Los datos en la caché están organizados por consulta. Debido a que los objetos Person tienen un campo de identificación id que es de tipo ID, si el mismo objeto es devuelto por múltiples consultas, Apollo puede combinarlos en uno. Debido a esto, al hacer consultas findPerson para los detalles de la dirección de Arto Hellas, se actualizaron los detalles de la dirección también para la consulta allPersons.

Haciendo mutaciones

Implementemos la funcionalidad para agregar nuevas personas.

En el capítulo anterior codificamos los parámetros de las mutaciones. Ahora necesitamos una versión de la mutación addPerson que usa variables:

const CREATE_PERSON = gql`
mutation createPerson($name: String!, $street: String!, $city: String!, $phone: String) {
  addPerson(
    name: $name,
    street: $street,
    city: $city,
    phone: $phone
  ) {
    name
    phone
    id
    address {
      street
      city
    }
  }
}
`

La función hook useMutation proporciona la funcionalidad para realizar mutaciones.

Creemos un nuevo componente para agregar una nueva persona al directorio:

import React, { useState } from 'react'
import { gql, useMutation } from '@apollo/client'

const CREATE_PERSON = gql`
  // ...
`

const PersonForm = () => {
  const [name, setName] = useState('')
  const [phone, setPhone] = useState('')
  const [street, setStreet] = useState('')
  const [city, setCity] = useState('')

  const [ createPerson ] = useMutation(CREATE_PERSON)
  const submit = (event) => {
    event.preventDefault()

    createPerson({  variables: { name, phone, street, city } })
    setName('')
    setPhone('')
    setStreet('')
    setCity('')
  }

  return (
    <div>
      <h2>create new</h2>
      <form onSubmit={submit}>
        <div>
          name <input value={name}
            onChange={({ target }) => setName(target.value)}
          />
        </div>
        <div>
          phone <input value={phone}
            onChange={({ target }) => setPhone(target.value)}
          />
        </div>
        <div>
          street <input value={street}
            onChange={({ target }) => setStreet(target.value)}
          />
        </div>
        <div>
          city <input value={city}
            onChange={({ target }) => setCity(target.value)}
          />
        </div>
        <button type='submit'>add!</button>
      </form>
    </div>
  )
}

export default PersonForm

El código del formulario es sencillo y las líneas interesantes se han resaltado. Podemos definir la función de mutación usando el hook-useMutation. El hook devuelve una matriz, cuyo primer elemento contiene el resultado de la mutación.

const [ createPerson ] = useMutation(CREATE_PERSON)

Las variables de consulta reciben valores cuando se realiza la consulta:

createPerson({  variables: { name, phone, street, city } })

Se agregan nuevas personas sin problemas, pero la pantalla no se actualiza. La razón es que Apollo Client no puede actualizar automáticamente el caché de una aplicación, por lo que aún contiene el estado anterior a la mutación. Podríamos actualizar la pantalla recargando la página, ya que la caché se vacía cuando se recarga la página. Sin embargo, debe haber una mejor manera de hacer esto.

Actualizando la caché

Hay pocas soluciones diferentes para esto. Una forma es hacer la consulta para todas las personas poll en el servidor, o hacer la consulta repetidamente.

El cambio es pequeño. Configuremos la consulta para sondear cada dos segundos:

const App = () => {
  const result = useQuery(ALL_PERSONS, {
    pollInterval: 2000  })

  if (result.loading)  {
    return <div>loading...</div>
  }

  return (
    <div>
      <Persons persons = {result.data.allPersons}/>
      <PersonForm />
    </div>
  )
}

export default App

La solución es simple, y cada vez que un usuario agrega una nueva persona, aparece inmediatamente en las pantallas de todos los usuarios.

El lado malo de la solución es todo el tráfico web inútil.

Otra manera fácil de mantener la caché sincronizada es usar el hook de useMutation, el parámetro refetchQueries para definir que la consulta que busca a todas las personas se realice nuevamente cada vez que se cree una nueva persona.

const ALL_PERSONS = gql`
  query  {
    allPersons  {
      name
      phone
      id
    }
  }
`

const PersonForm = (props) => {
  // ...

  const [ createPerson ] = useMutation(CREATE_PERSON, {
    refetchQueries: [ { query: ALL_PERSONS } ]  })

Los pros y los contras de esta solución son casi opuestos a los anteriores. No hay tráfico web extra, porque las consultas no se hacen por si acaso. Sin embargo, si un usuario actualiza ahora el estado del servidor, los cambios no se muestran a otros usuarios inmediatamente.

Si deseas realizar varias consultas, puedes pasar varios objetos dentro de refetchQueries. Esto le permitirá actualizar diferentes partes de su aplicación al mismo tiempo. Aquí hay un ejemplo:

    const [ createPerson ] = useMutation(CREATE_PERSON, {
    refetchQueries: [ { query: ALL_PERSONS }, { query: OTHER_QUERY }, { query: ... } ] // pass as many queries as you need
  })

Hay otras formas de actualizar la caché. Más sobre estos más adelante en esta parte.

Por el momento, en nuestro código, las consultas y el componente están definidos en el mismo lugar. Separemos las definiciones de consulta en su propio archivo queries.js:

import { gql  } from '@apollo/client'

export const ALL_PERSONS = gql`
  query {
    // ...
  }
`
export const FIND_PERSON = gql`
  query findPersonByName($nameToSearch: String!) {
    // ...
  }
`

export const CREATE_PERSON = gql`
  mutation createPerson($name: String!, $street: String!, $city: String!, $phone: String) {
    // ...
  }
`

Luego, cada componente importa las consultas que necesita:

import { ALL_PERSONS } from './queries'

const App = () => {
  const result = useQuery(ALL_PERSONS)
  // ...
}

El código actual de la aplicación se puede encontrar en Github branch part8-2.

Manejo de errores de mutación

Intentar crear una persona con datos no válidos provoca un error y toda la aplicación se rompe

devtools showing error: name must be unique

Debemos manejar la excepción. Podemos registrar una función de manejo de errores en la mutación usando onError option del hook useMutation.

Registremos la mutación en un controlador de errores, que usa la función setError que recibe como parámetro para establecer un mensaje de error:

const PersonForm = ({ setError }) => {
  // ... 

  const [ createPerson ] = useMutation(CREATE_PERSON, {
    refetchQueries: [  {query: ALL_PERSONS } ],
    onError: (error) => {      const errors = error.graphQLErrors[0].extensions.error.errors      const messages = Object.values(errors).map(e => e.message).join('\n')      setError(messages)    }  })

  // ...
}

Tenemos que buscar bastante en el objeto de error hasta encontrar el mensaje de error correcto...

Entonces podemos mostrar el mensaje de error en la pantalla según sea necesario

const App = () => {
  const [errorMessage, setErrorMessage] = useState(null)
  const result = useQuery(ALL_PERSONS)

  if (result.loading)  {
    return <div>loading...</div>
  }

  const notify = (message) => {    setErrorMessage(message)    setTimeout(() => {      setErrorMessage(null)    }, 10000)  }
  return (
    <div>
      <Notify errorMessage={errorMessage} />      <Persons persons = {result.data.allPersons} />
      <PersonForm setError={notify} />    </div>
  )
}

const Notify = ({errorMessage}) => {  if ( !errorMessage ) {    return null  }  return (    <div style={{color: 'red'}}>    {errorMessage}    </div>  )}

Ahora se informa al usuario sobre un error con una simple notificación.

fullstack content

El código actual de la aplicación se puede encontrar en Github rama part8-3.

Actualización de un número de teléfono

Agreguemos la posibilidad de cambiar los números de teléfono de las personas a nuestra aplicación. La solución es casi idéntica a la que usamos para agregar nuevas personas.

Nuevamente, la mutación requiere parámetros.

export const EDIT_NUMBER = gql`
  mutation editNumber($name: String!, $phone: String!) {
    editNumber(name: $name, phone: $phone)  {
      name
      phone
      address {
        street
        city
      }
      id
    }
  }
`

El componente PhoneForm responsable del cambio es sencillo. El formulario tiene campos para el nombre de la persona y el nuevo número de teléfono, y llama a la función changeNumber. La función se realiza mediante el hook useMutation. Se han resaltado líneas interesantes en el código.

import React, { useState } from 'react'
import { useMutation } from '@apollo/client'

import { EDIT_NUMBER } from '../queries'

const PhoneForm = () => {
  const [name, setName] = useState('')
  const [phone, setPhone] = useState('')

  const [ changeNumber ] = useMutation(EDIT_NUMBER)
  const submit = (event) => {
    event.preventDefault()

    changeNumber({ variables: { name, phone } })
    setName('')
    setPhone('')
  }

  return (
    <div>
      <h2>change number</h2>

      <form onSubmit={submit}>
        <div>
          name <input
            value={name}
            onChange={({ target }) => setName(target.value)}
          />
        </div>
        <div>
          phone <input
            value={phone}
            onChange={({ target }) => setPhone(target.value)}
          />
        </div>
        <button type='submit'>change number</button>
      </form>
    </div>
  )
}

export default PhoneForm

Parece sombrío, pero funciona:

fullstack content

Sorprendentemente, cuando se cambia el número de una persona, el nuevo número aparece automáticamente en la lista de personas generada por el componente Persons. Esto sucede porque cada persona tiene un campo de identificación de tipo ID, por lo que los datos de la persona guardados en la caché se actualizan automáticamente cuando se modifican con la mutación.

El código actual de la aplicación se puede encontrar en Github branch part8-4.

Nuestra aplicación todavía tiene un pequeño defecto. Si intentamos cambiar el número de teléfono por un nombre que no existe, parece que no pasa nada. Esto sucede porque si no se puede encontrar una persona con el nombre de pila, la respuesta de mutación es nula:

fullstack content

Para GraphQL esto no es un error, por lo que registrar un controlador de errores onError no es útil.

Podemos usar el campo result devuelto por el hook useMutation como su segundo parámetro para generar un mensaje de error.

const PhoneForm = ({ setError }) => {
  const [name, setName] = useState('')
  const [phone, setPhone] = useState('')

  const [ changeNumber, result ] = useMutation(EDIT_NUMBER)
  const submit = (event) => {
    // ...
  }

  useEffect(() => {    if (result.data && result.data.editNumber === null) {      setError('person not found')    }  }, [result.data])
  // ...
}

Si no se puede encontrar a una persona, o el result.data.editNumber es null, el componente usa la función de devolución de llamada que recibió como props para establecer un mensaje de error adecuado. Queremos configurar el mensaje de error solo cuando el resultado de la mutación result.data cambie, por lo que usamos el hook useEffect para controlar la configuración del mensaje de error.

El uso de useEffect provoca una advertencia de ESLint:

fullstack content

La advertencia no tiene sentido y la solución más fácil es ignorar la regla ESLint en la línea:

useEffect(() => {
  if (result.data && !result.data.editNumber) {
    setError('name not found')
  }
}, [result.data])  // eslint-disable-line 

Podríamos intentar deshacernos de la advertencia agregando la función setError al segundo arreglo de parámetros de useEffect:

useEffect(() => {
  if (result.data && !result.data.editNumber) {
    setError('name not found')
  }
}, [result.data, setError])

Sin embargo, esta solución no funciona si la función &notify no está envuelta en una función useCallback. Si no es así, el resultado es un bucle sin fin. Cuando el componente App se rerenderiza después de que se haya ocultado una notificación, una nueva versión de notify se crea lo que hace que la función de useEffect que se ejecute lo que provoca una nueva notificación y así sucesivamente una así sucesivamente...

El código actual de la aplicación se puede encontrar en Github branch part8-5.

Apollo Client y el estado de las aplicaciones

En nuestro ejemplo, la administración del estado de las aplicaciones se ha convertido principalmente en responsabilidad de Apollo Client. Nuestro ejemplo usa el estado de los componentes de React solo para administrar el estado de un formulario y mostrar notificaciones de error. Cuando se usa GraphQL, puede ser que no haya más razones justificables para mover la administración del estado de las aplicaciones a Redux.

Cuando sea necesario, Apollo permite guardar el estado local de las aplicaciones en Apollo cache.