Siirry sisältöön

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

Luodaan uusi React-sovellus ja asennetaan sovellukseen Apollo Clientin vaatimat riippuvuudet.

npm install @apollo/client graphql

Aloitetaan seuraavalla ohjelmarungolla.

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

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

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

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

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


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

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 ReactDOM from 'react-dom/client'
import App from './App'

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

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

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

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 { 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 Apollo Explorer mahdollistaa muuttujia sisältävän kyselyjen tekemisen. Tällöin muuttujille on annettava arvot kohdassa Variables:

fullstack content

Äsken 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.

Yksi mahdollisuus olisi käyttää tässä tilanteessa hookia useLazyQuery jonka avulla on mahdollista muodostaa kysely joka suoritetaan siinä vaiheessa kun käyttäjä haluaa nähdä yksittäisen henkilön tulokset.

Päädymme kuitenkin nyt siistimpään ratkaisuun hyödyntämällä useQuery:n optiota skip, jonka avulla voidaan määritellä kyselyjä, joita ei suoriteta jos jokin ehto on tosi.

Ratkaisu on seuraavassa:

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

const FIND_PERSON = gql`
  query findPersonByName($nameToSearch: String!) {
    findPerson(name: $nameToSearch) {
      name
      phone
      id
      address {
        street
        city
      }
    }
  }
`

const Person = ({ person, onClose }) => {
  return (
    <div>
      <h2>{person.name}</h2>
      <div>
        {person.address.street} {person.address.city}
      </div>
      <div>{person.phone}</div>
      <button onClick={onClose}>close</button>
    </div>
  )
}

const Persons = ({ persons }) => {
  const [nameToSearch, setNameToSearch] = useState(null)  const result = useQuery(FIND_PERSON, {    variables: { nameToSearch },    skip: !nameToSearch,  })
  if (nameToSearch && result.data) {    return (      <Person        person={result.data.findPerson}        onClose={() => setNameToSearch(null)}      />    )  }
  return (
    <div>
      <h2>Persons</h2>
      {persons.map((p) => (
        <div key={p.name}>
          {p.name} {p.phone}
          <button onClick={() => setNameToSearch(p.name)}>            show address          </button>        </div>
      ))}
    </div>
  )
}

export default Persons

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

Jos henkilön yhteydessä olevaa nappia show address painetaan, asetetaan henkilön nimi tilan nameToSearch arvoksi:

<button onClick={() => setNameToSearch(p.name)}>
  show address
</button>

Tämä saa aikaan sen, että komponentti renderöidään uudelleen. Renderöinnin yhteydessä suoritetaan kysely FIND_PERSON eli henkilön tarkempien tietojen haku jos muuttujalla nameToSearch on arvo:

const result = useQuery(FIND_PERSON, {
  variables: { nameToSearch },
  skip: !nameToSearch,})

Eli jos yksittäisen henkilön osoitetietoja ei haluta näkyviin on nameToSearch arvo null ja kyselyä ei suoriteta.

Jos tilalla nameToSearch on arvo, ja kyselyn suoritus on valmis, renderöidään komponentin Person avulla yksittäisen henkilön tarkemmat tiedot:

if (nameToSearch && result.data) {
  return (
    <Person
      person={result.data.findPerson}
      onClose={() => setNameToSearch(null)}
    />
  )
}

Yksittäisen henkilön näkymä on seuraavanlainen:

fullstack content

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

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

Välimuisti näyttää Arto Hellaksen osoitetiedot kyselyn findPerson jälkeen:

fullstack content

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 { 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:

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) => {      const messages = error.graphQLErrors.map(e => e.message).join('\n')      setError(messages)    }  })

  // ...
}

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 { 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 = 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ä.

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ä olisi 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 useEffect-hookin avulla, eli virheviesti halutaan asettaa ainoastaan jos mutaation tulos result.data muuttuu.

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

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.