b

React ja GraphQL

Toteutetaan seuraavaksi React-sovellus, joka käyttää toteuttamaamme GraphQL-palvelinta. Palvelimen tämänhetkinen koodi on kokonaisuudessaan githubissa, branchissa part8-3.

GraphQL:ää on periaatteessa mahdollista käyttää HTTP POST -pyyntöjen avulla. Seuraavassa esimerkki Postmanilla tehdystä kyselystä.

fullstack content

Kommunikointi tapahtuu siis osoitteeseen http://localhost:4000/graphql kohdistuvina POST-pyyntöinä, ja itse kysely lähetetään pyynnön mukana merkkijonona avaimen query arvona.

Voisimmekin hoitaa React-sovelluksen ja GraphQL:n kommunikoinnin Axiosilla. Tämä ei kuitenkaan ole useimmiten järkevää ja on parempi idea käyttää korkeamman tason kirjastoa, joka pystyy abstrahoimaan kommunikoinnin turhia detaljeja. Tällä hetkellä järkeviä vaihtoehtoja on kaksi: Facebookin Relay ja Apollo Client. Näistä Apollo on ylivoimaisesti suositumpi ja myös meidän valintamme.

Apollo client

Käytetään kurssilla Apollo Clientin versiota 3.0-beta, tällä hetkellä (20.2.2020) uusin virallisesti julkaistu versio on 2.6. eli kun luet dokumentaatiota, muista vaihtaa näytettävän dokumentaation versio vastaamaan 3.0 betaa:

fullstack content

Luodaan uusi React-sovellus ja asennetaan siihen Apollo clientin vaatimat riippuvuudet.

npm install @apollo/client graphql

Aloitetaan seuraavalla ohjelmarungolla.

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

Koodi aloittaa luomalla client-olion, jonka avulla se lähettää kyselyn palvelimelle:

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

Palvelimen palauttama vastaus tulostuu konsoliin:

fullstack content

Sovellus pystyy siis kommunikoimaan GraphQL-palvelimen kanssa olion client välityksellä. Client saadaan sovelluksen kaikkien komponenttien saataville käärimällä komponentti App komponentin ApolloProvider lapseksi:

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

Kyselyjen tekeminen

Olemme valmiina toteuttamaan sovelluksen päänäkymän, joka listaa kaikkien henkilöiden puhelinnumerot.

Apollo Client tarjoaa muutaman vaihtoehtoisen tavan kyselyjen tekemiselle. Tämän hetken vallitseva käytäntö on hook-funktion useQuery käyttäminen.

Kyselyn tekevän komponentin App koodi näyttää seuraavalta:

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

Hook-funktion useQuery kutsuminen suorittaa parametrina annetun kyselyn. Hookin kutsuminen palauttaa olion, jolla on useita kenttiä. Kenttä loading on arvoltaan tosi, jos kyselyyn ei ole saatu vielä vastausta. Tässä tilanteessa renderöitävä koodi on

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

Kun tulos on valmis, otetaan tuloksen kentästä data kyselyn allPersons vastaus ja renderöidään luettelossa olevat nimet ruudulle.

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

Eriytetään henkilöiden näyttäminen omaan komponenttiin

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

Komponentti App siis hoitaa edelleen kyselyn ja välittää tuloksen uuden komponentin renderöitäväksi:

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

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

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

Nimetyt kyselyt ja muuttujat

Toteutetaan sovellukseen ominaisuus, jonka avulla on mahdollisuus nähdä yksittäisen henkilön osoitetiedot. Palvelimen tarjoama kysely findPerson sopii hyvin tarkoitukseen.

Edellisessä luvussa tekemissämme kyselyissä parametri oli kovakoodattuna kyselyyn:

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

Kun teemme kyselyjä ohjelmallisesti, on kyselyn parametrit pystyttävä antamaan dynaamisesti.

Tähän tarkoitukseen sopivat GraphQL:n muuttujat. Muuttujia käyttääksemme on kysely myös nimettävä.

Sopiva muoto kyselylle on seuraava:

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

Kyselyn nimenä on findPersonByName, ja se saa yhden merkkijonomuotoisen parametrin $nameToSearch.

Myös GraphQL Playground mahdollistaa muuttujia sisältävän kyselyjen tekemisen. Tällöin muuttujille on annettava arvot kohdassa Query variables:

fullstack content

Asken käyttämämme useQuery toimii hyvin tilanteissa, joissa kysely on tarkoitus suorittaa heti komponentin renderöinnin yhteydessä. Nyt kuitenkin haluamme tehdä kyselyn vasta siinä vaiheessa kun käyttäjä haluaa nähdä jonkin henkilön tiedot, eli kysely tehdään vasta sitä tarvittaessa.

Tähän tilanteeseen sopii hook-funktio useLazyQuery. Komponentti Persons muuttuu seuraavasti:

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

Koodi on kasvanut paljon, ja kaikki lisäykset eivät ole täysin ilmeisiä.

Jos henkilön yhteydessä olevaa nappia painetaan, suoritetaan klikkauksenkäsittelijä showPerson, joka tekee GraphQL-kyselyn henkilön tiedoista:

const [getPerson, result] = useLazyQuery(FIND_PERSON) 

// ...

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

Kyselyn muuttujalle nameToSearch määritellään arvo kutsuttaessa.

Kyselyn vastaus tulee muuttujaan result, ja sen arvo sijoitetaan komponentin tilan muuttujaan person. Sijoitus tehdään useEffect-hookissa:

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

Hookin toisena parametrina on result. Tämä saa aikaan sen, että hookin ensimmäisenä parametrina oleva funktio suoritetaan aina kun kyselyn palauttama olio muuttuu. Lisäksi tarkistamme, että resultin kenttä data ei ole undefined ennen kuin asetamme haetun henkilön tiedot komponentin tilaan. Jos päivitystä ei hoidettaisi kontrolloidusti hookissa, seuraisi ongelmia sen jälkeen kun yksittäisen henkilön näkymästä palataan kaikkien henkilöiden näkymään.

Jos tilan muuttujalla person on arvo, näytetään kaikkien henkilöiden sijaan yhden henkilön tarkemmat tiedot:

fullstack content

Yksittäisen henkilön näkymästä palataan kaikkien henkilöiden näkymään sijoittamalla tilan muuttujan person arvoksi null.

Ratkaisu ei ole ehkä siistein mahdollinen mutta saa kelvata meille.

Sovelluksen tämänhetkinen koodi on kokonaisuudessaan githubissa, branchissa part8-1.

Välimuisti ja devtools

Kun haemme monta kertaa esim. Arto Hellaksen tiedot, huomaamme selaimen developer-konsolin välilehteä Network seuraamalla mielenkiintoisen asian: kysely backendiin tapahtuu ainoastaan tietojen ensimmäisellä katsomiskerralla. Tämän jälkeen, siitäkin huolimatta, että koodi tekee saman kyselyn uudelleen, ei kyselyä lähetetä backendille.

Apollo client tallettaa kyselyjen tulokset cacheen eli välimuistiin ja optimoi suoritusta siten, että jos kyselyn vastaus on jo välimuistissa, ei kyselyä lähetetä ollenkaan palvelimelle.

Chromeen on mahdollista asentaa lisäosa Apollo Client devtools, jonka avulla voidaan tarkastella mm. välimuistin tilaa

fullstack content

Tieto on organisoitu välimuistiin kyselykohtaisesti. Koska Person-tyypin olioilla on identifioiva kenttä id, jonka tyypiksi on määritelty ID, osaa Apollo yhdistää kahden eri kyselyn palauttaman saman olion. Tämän ansiosta Arto Hellaksen osoitetietojen hakeminen kyselyllä findPerson on päivittänyt välimuistia Arton osoitetietojen osalta myös kyselyn allPersons alta.

Mutaatioiden tekeminen

Toteutetaan sovellukseen mahdollisuus uusien henkilöiden lisäämiseen.

Edellisessä luvussa kovakoodasimme mutaatioiden parametrit. Tarvitsemme nyt muuttujia käyttävän version henkilön lisäävästä mutaatiosta:

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

Mutaatioiden tekemiseen sopivan toiminnallisuuden tarjoaa hook-funktio useMutation.

Tehdään sovellukseen uusi komponentti uuden henkilön lisämiseen:

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 = async (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

Lomakkeen koodi on suoraviivainen, mielenkiintoiset rivit on korostettu. Mutaation suorittava funktio saadaan luotua useMutation-hookin avulla. Hook palauttaa kyselyfunktion taulukon ensimmäisenä alkiona:

const [ createPerson ] = useMutation(CREATE_PERSON)

Kyselyä tehtäessä määritellään kyselyn muuttujille arvot:

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

Lisäys kyllä toimii, mutta sovelluksen näkymä ei päivity. Syynä tälle on se, että Apollo Client ei osaa automaattisesti päivittää sovelluksen välimuistia, se sisältää edelleen ennen lisäystä olevan tilanteen. Saamme kyllä uuden käyttäjän näkyviin uudelleenlataamalla selaimen, sillä Apollon välimuisti nollautuu uudelleenlatauksen yhteydessä. Tilanteeseen on kuitenkin pakko löytää joku järkevämpi ratkaisu.

Välimuistin päivitys

Ongelma voidaan ratkaista muutamallakin eri tavalla. Eräs tapa on määritellä kaikki henkilöt hakeva kysely pollaamaan palvelinta, eli suorittamaan kysely palvelimelle toistuvasti tietyin väliajoin.

Muutos on pieni, määritellään pollausväliksi kaksi sekuntia:

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

Yksinkertaisuuden lisäksi ratkaisun hyvä puoli on se, että aina kun joku käyttäjä lisää palvelimelle uuden henkilön, se ilmestyy pollauksen ansiosta heti kaikkien sovelluksen käyttäjien selaimeen.

Ikävänä puolena pollauksessa on tietenkin sen aiheuttama turha verkkoliikenne.

Toinen helppo tapa välimuistin synkronoimiseen on määritellä useMutation-hookin option refetchQueries avulla, että kaikki henkilöt hakeva kysely tulee suorittaa mutaation yhteydessä uudelleen:

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

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

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

Edut ja haitat tällä ratkaisulla ovat melkeinpä päinvastaiset pollaukseen. Verkkoliikennettä ei synny kuin tarpeen vaatiessa, eli kyselyjä ei tehdä varalta. Jos joku muu käyttäjä päivittää palvelimen tilaa, muutokset eivät kuitenkaan siirry nyt kaikille käyttäjille.

Muitakin tapoja välimuistin tilan päivittämiseksi on, niistä lisää myöhemmin tässä osassa.

Sovellukseen on tällä hetkellä määritelty kyselyjä komponenttien koodin sekaan. Eriytetään kyselyjen määrittely omaan tiedostoonsa 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) {
    // ...
  }
`

Jokainen komponentti importtaa tarvitsemansa kyselyt:

import { ALL_PERSONS } from './queries'

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

Sovelluksen tämänhetkinen koodi on kokonaisuudessaan githubissa, branchissa part8-2.

Mutaatioiden virheiden käsittely

Jos yritämme luoda epävalidia henkilöä, seurauksena on poikkeus ja koko sovellus hajoaa

fullstack content

Poikkeus on syytä käsitellä. useMutation-hookin option onError avulla on mahdollista rekisteröidä mutaatioille virheenkäsittelijäfunktio.

Rekisteröidään mutaatiolle virheidenkäsittelijä, joka asettaa virheestä kertovan viestin propsina saaman funktion setError avulla:

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

  const [ createPerson ] = useMutation(CREATE_PERSON, {
    refetchQueries: [  {query: ALL_PERSONS } ],
    onError: (error) => {      setError(error.graphQLErrors[0].message)    }  })

  // ...
}

Renderöidään mahdollinen virheilmoitus näytölle

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

Poikkeuksesta tiedotetaan nyt käyttäjälle yksinkertaisella notifikaatiolla.

fullstack content

Sovelluksen tämänhetkinen koodi on kokonaisuudessaan githubissa, branchissa part8-3.

Puhelinnumeron päivitys

Tehdään sovellukseen mahdollisuus vaihtaa henkilöiden puhelinnumeroita. Ratkaisu on lähes samanlainen kuin uuden henkilön lisäykseen käytetty.

Mutaatio edellyttää jälleen muuttujien käyttöä.

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

Muutoksen suorittava komponentti PhoneForm on suoraviivainen, se kysyy lomakkeen avulla henkilön nimeä ja uutta puhelinnumeroa, ja kutsuu useMutation-hookilla luotua mutaation suorittavaa funktiota changeNumber. Mielenkiintoiset osat koodia korostettuna:

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

import { EDIT_NUMBER, ALL_PERSONS } from '../queries'

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

  const [ changeNumber ] = useMutation(EDIT_NUMBER)
  const submit = async (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

Ulkoasu on karu mutta toimiva:

fullstack content

Kun numero muutetaan, päivittyy se hieman yllättäen automaattisesti komponentin Persons renderöimään nimien ja numeroiden listaan. Tämä johtuu siitä, että koska henkilöillä on identifioiva, tyyppiä ID oleva kenttä, päivittyy henkilö välimuistissa uusilla tiedoilla päivitysoperaation yhteydessä.

Sovelluksen tämänhetkinen koodi on kokonaisuudessaan githubissa, branchissa part8-4.

Sovelluksessa on vielä pieni ongelma. Jos yritämme vaihtaa olemattomaan nimeen liittyvän puhelinnumeron, ei mitään näytä tapahtuvan. Syynä tälle on se, että jos nimeä vastaavaa henkilöä ei löydy, vastataan kyselyyn null:

fullstack content

Koska kyseessä ei ole GraphQL:n kannalta virhetilanne, ei onError-virheenkäsittelijän rekisteröimisestä tässä tilanteessa hyötyä.

Voimme generoida virheilmoituksen useMutation-hookin toisena parametrina palauttaman mutaation tuloksen kertovan olion result avulla.

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

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

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

Jos henkilöä ei löytynyt, eli kyselyn tulos result.data.editNumber on null, asettaa komponentti propseina saamansa callback-funktion avulla sopivan virheilmoituksen. Virheilmoituksen asettamista kontrolloidaan jälleen useEffect-hookin avulla, eli virheviesti halutaan asettaa ainoastaan jos mutaation tulos result.data muuttuu.

useEffect aiheuttaa ESLint-virheilmoituksen:

fullstack content

Varoitus on aiheeton, ja pääsemme helpoimmalla ignoroimalla ESLint-säännön riviltä:

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

Voisimme yrittää päästä varoituksesta eroon lisäämällä funktion setError useEffectin toisena parametrina olevaan taulukkoon:

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

Tämä ratkaisu ei kuitenkaan toimi, ellei setError-funktiota ole määritelty useCallback-funktioon käärittynä. Jos näin ei tehdä, seurauksena on ikuinen luuppi, sillä aina kun komponentti App renderöidään uudelleen notifikaation poistamisen jälkeen, syntyy uusi versio funktiosta setError ja se taas aiheuttaa efektifunktion uudelleensuorituksen ja taas uuden notifikaation...

Sovelluksen tämänhetkinen koodi on githubissa, branchissa part8-5.

Apollo Client ja sovelluksen tila

Esimerkissämme sovelluksen tilan käsittely on siirtynyt suurimmaksi osaksi Apollo Clientin vastuulle. Tämä onkin melko tyypillinen ratkaisu GraphQL-sovelluksissa. Esimerkkimme käyttää Reactin komponenttien tilaa ainoastaan lomakkeen tilan hallintaan sekä virhetilanteesta kertovan notifikaation näyttämiseen. GraphQL:ää käytettäessä voikin olla, että ei ole enää kovin perusteltuja syitä siirtää sovelluksen tilaa ollenkaan Reduxiin.

Apollo mahdollistaa tarvittaessa myös sovelluksen paikallisen tilan tallettamisen Apollon välimuistiin.