Siirry sisältöön

c

React Query, Context API

Tarkastellaan osan lopussa vielä muutamaa erilaista tapaa sovelluksen tilan hallintaan.

Jatketaan muistiinpano-sovelluksen parissa. Otetaan fokukseen palvelimen kanssa tapahtuva kommunikointi. Aloitetaan sovellus puhtaalta pöydältä. Ensimmäinen versio on seuraava:

const App = () => {
  const addNote = async (event) => {
    event.preventDefault()
    const content = event.target.note.value
    event.target.reset()
    console.log(content)
  }

  const toggleImportance = (note) => {
    console.log('toggle importance of', note.id)
  }

  const notes = []

  return (
    <div>
      <h2>Notes app</h2>
      <form onSubmit={addNote}>
        <input name="note" />
        <button type="submit">add</button>
      </form>
      {notes.map((note) => (
        <li key={note.id} onClick={() => toggleImportance(note)}>
          {note.important ? <strong>{note.content}</strong> : note.content}
          <button onClick={() => toggleImportance(note.id)}>
            {note.important ? 'make not important' : 'make important'}
          </button>            
        </li>
      ))}
    </div>
  )
}

export default App

Alkuvaiheen koodi on GitHubissa repositorion https://github.com/fullstack-hy2020/query-notes branchissa part6-0.

Palvelimella olevan datan hallinta TanStack Query ‑kirjaston avulla

Hyödynnämme nyt TanStack Query ‑kirjastoa palvelimelta haettavan datan säilyttämiseen ja hallinnointiin.

Asennetaan kirjasto komennolla

npm install @tanstack/react-query

Tiedostoon main.jsx tarvitaan muutama lisäys, jotta kirjaston funktiot saadaan välitettyä koko sovelluksen käyttöön:

import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import App from './App.jsx'

const queryClient = new QueryClient()
createRoot(document.getElementById('root')).render(
  <QueryClientProvider client={queryClient}>    <App />
  </QueryClientProvider>)

Käytetään aiemmista osista tuttuun tapaan JSON Serveriä simuloimaan backendin toimintaa. JSON Server on valmiiksi konfiguroituna esimerkkiprojektiin, ja projektin juuressa on tiedosto db.json, joka sisältää oletuksena kaksi muistiinpanoa. Voimme siis käynnistää serverin suoraan komennolla:

npm run server

Voimme nyt hakea muistiinpanot komponentissa App. Koodi laajenee seuraavasti:

import { useQuery } from '@tanstack/react-query'
const App = () => {
  const addNote = async (event) => {
    event.preventDefault()
    const content = event.target.note.value
    event.target.reset()
    console.log(content)
  }

  const toggleImportance = (note) => {
    console.log('toggle importance of', note.id)
  }

  const result = useQuery({    queryKey: ['notes'],    queryFn: async () => {      const response = await fetch('http://localhost:3001/notes')      if (!response.ok) {        throw new Error('Failed to fetch notes')      }      return await response.json()    }  })   console.log(JSON.parse(JSON.stringify(result)))   if (result.isPending) {    return <div>loading data...</div>  }   const notes = result.data
  return (
    // ...
  )
}

Datan hakeminen palvelimelta tapahtuu edellisen luvun tapaan Fetch APIn fetch-funktiolla. Funktiokutsu on kuitenkin nyt kääritty useQuery-funktiolla muodostetuksi kyselyksi. useQuery-funktiokutsun parametrina on olio, jolla on kentät queryKey ja queryFn. Kentän queryKey arvona on taulukko, joka sisältää merkkijonon notes. Se toimii avaimena määriteltyyn kyselyyn, eli muistiinpanojen listaan.

Funktion useQuery paluuarvo on olio, joka kertoo kyselyn tilan. Konsoliin tehty tulostus havainnollistaa tilannetta:

browser devtools showing success status

Eli ensimmäistä kertaa komponenttia renderöitäessä kysely on vielä tilassa pending, eli siihen liittyvä HTTP-pyyntö on kesken. Tässä vaiheessa renderöidään ainoastaan:

<div>loading data...</div>

HTTP-pyyntö kuitenkin valmistuu niin nopeasti, että tekstiä eivät edes tarkkasilmäisimmät ehdi näkemään. Kun pyyntö valmistuu, renderöidään komponentti uudelleen. Kysely on toisella renderöinnillä tilassa success, ja kyselyolion kenttä data sisältää pyynnön palauttaman datan, eli muistiinpanojen listan, joka renderöidään ruudulle.

Sovellus siis hakee datan palvelimelta ja renderöi sen ruudulle käyttämättä ollenkaan luvuissa 2-5 käytettyjä Reactin hookeja useState ja useEffect. Palvelimella oleva data on nyt kokonaisuudessaan TanStack Query ‑kirjaston hallinnoinnin alaisuudessa, ja sovellus ei tarvitse ollenkaan Reactin useState-hookilla määriteltyä tilaa!

Siirretään varsinaisen HTTP-pyynnön tekevä funktio omaan tiedostoonsa src/requests.js:

const baseUrl = 'http://localhost:3001/notes'

export const getNotes = async () => {
  const response = await fetch(baseUrl)
  if (!response.ok) {
    throw new Error('Failed to fetch notes')
  }
  return await response.json()
}

Komponentti App yksinkertaistuu nyt seuraavasti:

import { useQuery } from '@tanstack/react-query'
import { getNotes } from './requests'
const App = () => {
  // ...

  const result = useQuery({
    queryKey: ['notes'],
    queryFn: getNotes  })

  // ...
}

Sovelluksen tämän hetken koodi on GitHubissa branchissa part6-1.

Datan vieminen palvelimelle TanStack Queryn avulla

Data haetaan jo onnistuneesti palvelimelta. Huolehditaan seuraavaksi siitä, että lisätty ja muutettu data tallennetaan palvelimelle. Aloitetaan uusien muistiinpanojen lisäämisestä.

Tehdään tiedostoon requests.js funktio createNote uusien muistiinpanojen talletusta varten:

const baseUrl = 'http://localhost:3001/notes'

export const getNotes = async () => {
  const response = await fetch(baseUrl)
  if (!response.ok) {
    throw new Error('Failed to fetch notes')
  }
  return await response.json()
}

export const createNote = async (newNote) => {  const options = {    method: 'POST',    headers: { 'Content-Type': 'application/json' },    body: JSON.stringify(newNote)  }   const response = await fetch(baseUrl, options)   if (!response.ok) {    throw new Error('Failed to create note')  }   return await response.json()}

Komponentti App muuttuu seuraavasti

import { useQuery, useMutation } from '@tanstack/react-query'import { getNotes, createNote } from './requests'
const App = () => {
  const newNoteMutation = useMutation({    mutationFn: createNote,  })
  const addNote = async (event) => {
    event.preventDefault()
    const content = event.target.note.value
    event.target.reset()
    newNoteMutation.mutate({ content, important: true })  }

  //

}

Uuden muistiinpanon luomista varten määritellään siis mutaatio funktion useMutation avulla:

const newNoteMutation = useMutation({
  mutationFn: createNote,
})

Parametrina on tiedostoon requests.js lisäämämme funktio, joka lähettää Fetch APIn avulla uuden muistiinpanon palvelimelle.

Tapahtumakäsittelijä addNote suorittaa mutaation kutsumalla mutaatio-olion funktiota mutate ja antamalla uuden muistiinpanon parametrina:

newNoteMutation.mutate({ content, important: true })

Ratkaisumme on hyvä. Paitsi se ei toimi. Uusi muistiinpano kyllä tallettuu palvelimelle, mutta se ei päivity näytölle.

Jotta saamme renderöityä myös uuden muistiinpanon, meidän on kerrottava TanStack Querylle, että kyselyn, jonka avaimena on merkkijono notes, vanha tulos tulee mitätöidä eli invalidoida.

Invalidointi on onneksi helppoa, se voidaan tehdä kytkemällä mutaatioon sopiva onSuccess-takaisinkutsufunktio:

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'import { getNotes, createNote } from './requests'

const App = () => {
  const queryClient = useQueryClient()
  const newNoteMutation = useMutation({
    mutationFn: createNote,
    onSuccess: () => {      queryClient.invalidateQueries({ queryKey: ['notes'] })    },  })

  // ...
}

Kun mutaatio on nyt suoritettu onnistuneesti, suoritetaan funktiokutsu

queryClient.invalidateQueries({ queryKey: ['notes'] })

Tämä taas saa aikaan sen, että TanStack Query päivittää automaattisesti kyselyn, jonka avain on notes eli hakee muistiinpanot palvelimelta. Tämän seurauksena sovellus renderöi ajantasaisen palvelimella olevan tilan, eli myös lisätty muistiinpano renderöityy.

Toteutetaan vielä muistiinpanojen tärkeyden muutos. Lisätään tiedostoon requests.js muistiinpanojen päivityksen hoitava funktio:

export const updateNote = async (updatedNote) => {
  const options = {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(updatedNote)
  }

  const response = await fetch(`${baseUrl}/${updatedNote.id}`, options)

  if (!response.ok) {
    throw new Error('Failed to update note')
  }

  return await response.json()
}

Myös muistiinpanon päivittäminen tapahtuu mutaation avulla. Komponentti App laajenee seuraavasti:

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { getNotes, createNote, updateNote } from './requests'
const App = () => {
  const queryClient = useQueryClient()

  const newNoteMutation = useMutation({
    mutationFn: createNote,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['notes'] })
    }
  })

  const updateNoteMutation = useMutation({    mutationFn: updateNote,    onSuccess: () => {      queryClient.invalidateQueries({ queryKey: ['notes'] })    }  })
  const addNote = async (event) => {
    event.preventDefault()
    const content = event.target.note.value
    event.target.reset()
    newNoteMutation.mutate({ content, important: true })
  }

  const toggleImportance = (note) => {
    updateNoteMutation.mutate({...note, important: !note.important })  }

  // ...
}

Eli jälleen luotiin mutaatio, joka invalidoi kyselyn notes, jotta päivitetty muistiinpano saadaan renderöitymään oikein. Mutaation käyttö on helppoa, funktio mutate saa parametrikseen muistiinpanon, jonka tärkeys on vaihdettu vanhan arvon negaatioon.

Sovelluksen tämän hetken koodi on GitHubissa branchissa part6-2.

Suorituskyvyn optimointi

Sovellus toimii hyvin, ja koodikin on suhteellisen yksinkertaista. Erityisesti yllättää muistiinpanojen listan muutoksen toteuttamisen helppous. Esim. kun muutamme muistiinpanon tärkeyttä, riittää kyselyn notes invalidointi siihen, että sovelluksen data päivittyy:

const updateNoteMutation = useMutation({
  mutationFn: updateNote,
  onSuccess: () => {
    queryClient.invalidateQueries({ queryKey: ['notes'] })  }
})

Tästä on toki seurauksena se, että sovellus tekee muistiinpanon muutoksen aiheuttavan PUT-pyynnön jälkeen uuden GET-pyynnön, jonka avulla se hakee palvelimelta kyselyn datan:

fullstack content

Jos sovelluksen hakema datamäärä ei ole suuri, ei asialla ole juurikaan merkitystä. Selainpuolen toiminnallisuuden kannaltahan ylimääräisen HTTP GET ‑pyynnön tekeminen ei juurikaan haittaa, mutta joissain tilanteissa se saattaa rasittaa palvelinta.

Tarvittaessa on myös mahdollista optimoida suorituskykyä päivittämällä itse TanStack Queryn ylläpitämää kyselyn tilaa.

Muutos uuden muistiinpanon lisäävän mutaation osalta on seuraavassa:

const App = () => {
  const queryClient = useQueryClient()

  const newNoteMutation = useMutation({
    mutationFn: createNote,
    onSuccess: (newNote) => {      const notes = queryClient.getQueryData(['notes'])      queryClient.setQueryData(['notes'], notes.concat(newNote))    }
  })

  // ...
}

Eli onSuccess-takaisinkutsussa ensin luetaan queryClient-olion avulla olemassaoleva kyselyn notes tila ja päivitetään sitä lisäämällä mukaan uusi muistiinpano, joka saadaan takaisinkutsufunktion parametrina. Parametrin arvo on funktion createNote palauttama arvo, jonka määriteltiin tiedostossa requests.js seuraavasti:

export const createNote = async (newNote) => {
  const options = {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(newNote)
  }

  const response = await fetch(baseUrl, options)

  if (!response.ok) {
    throw new Error('Failed to create note')
  }

  return await response.json()}

Samankaltainen muutos olisi suhteellisen helppoa tehdä myös muistiinpanon tärkeyden muuttavaan mutaatioon, jätämme sen kuitenkin vapaaehtoiseksi harjoitustehtäväksi.

Kiinnitetään lopuksi huomio erikoiseen yksityiskohtaan. TanStack Query hakee kaikki muistiinpanot uudestaan, jos siirrymme selaimessa toiselle välilehdelle ja sen jälkeen palaamme sovelluksen välilehdelle. Tämän voi havaita Developer Consolen network-välilehdeltä:

fullstack content

Mistä on kyse? Hieman dokumentaatiota tutkimalla huomataan, että TanStack Queryn kyselyjen oletusarvoinen toiminnallisuus on se, että kyselyt (joiden tila on stale) päivitetään kun window focus vaihtuu. Voimme halutessamme kytkeä toiminnallisuuden pois luomalla kyselyn seuraavasti:

const App = () => {
  // ...
  const result = useQuery({
    queryKey: ['notes'],
    queryFn: getNotes,
    refetchOnWindowFocus: false  })

  // ...
}

Konsoliin tehtävillä tulostuksilla voit tarkkailla sitä miten usein TanStack Query aiheuttaa sovelluksen uudelleenrenderöinnin. Nyrkkisääntönä on se, että uudelleenrenderöinti tapahtuu vähintään aina kun sille on tarvetta, eli kun kyselyn tila muuttuu. Voit lukea lisää asiasta esim. täältä.

useNotes custom hook

Ratkaisumme on aika hyvä, hieman häiritsevää on kuitenkin se, että paljon Tanstack Queryn yksityiskohtiin liittyviä määrittelyjä on tehty React-komponentissa. Eristetään nämä vielä omaan custom hook -funktioonsa:

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { getNotes, createNote, updateNote } from '../requests'

export const useNotes = () => {
  const queryClient = useQueryClient()

  const result = useQuery({
    queryKey: ['notes'],
    queryFn: getNotes,
    refetchOnWindowFocus: false
  })

  const newNoteMutation = useMutation({
    mutationFn: createNote,
    onSuccess: (newNote) => {
      const notes = queryClient.getQueryData(['notes'])
      queryClient.setQueryData(['notes'], notes.concat(newNote))
    }
  })

  const updateNoteMutation = useMutation({
    mutationFn: updateNote,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['notes'] })
    }
  })

  return {
    notes: result.data,
    isPending: result.isPending,
    addNote: (content) => newNoteMutation.mutate({ content, important: true }),
    toggleImportance: (note) => updateNoteMutation.mutate({ 
      ...note, important: !note.important 
    }),
  }
}

Hook-funktio siis kapseloi sisälleen kaiken TanStack Queryyn liittyvän: kyselyn muistiinpanojen hakemiseen sekä molemmat mutaatiot muistiinpanojen luomiseen ja päivittämiseen. Hookin käyttäjälle nämä yksityiskohdat ovat piilossa, sillä funktio palauttaa yksinkertaisen olion, jossa on

  • notes: lista muistiinpanoista
  • isPending: tieto siitä, onko data vielä latautumassa
  • addNote: funktio uuden muistiinpanon lisäämiseen pelkällä sisältömerkkijonolla
  • toggleImportance: funktio muistiinpanon tärkeyden vaihtamiseen

Komponentti App yksinkertaistuu huomattavasti:

import { useNotes } from './hooks/useNotes'

const App = () => {
  const { notes, isPending, addNote: addNoteToServer, toggleImportance } = useNotes()

  const addNote = async (event) => {
    event.preventDefault()
    const content = event.target.note.value
    event.target.reset()
    addNoteToServer(content)
  }

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

  return (
    <div>
      <h2>Notes app</h2>
      <form onSubmit={addNote}>
        <input name="note" />
        <button type="submit">add</button>
      </form>
      {notes.map((note) => (
        <li key={note.id}>
          {note.important ? <strong>{note.content}</strong> : note.content}
          <button onClick={() => toggleImportance(note)}>
            {note.important ? 'make not important' : 'make important'}
          </button>
        </li>
      ))}
    </div>
  )
}

Sovelluksen lopullinen koodi on GitHubissa branchissa part6-3.

TanStack Query on monipuolinen kirjasto joka jo nyt nähdyn perusteella yksinkertaistaa sovellusta. Tekeekö TanStack Query monimutkaisemmat tilanhallintaratkaisut kuten esim. Zustandin tarpeettomaksi? Ei. TanStack Query voi joissain tapauksissa korvata osin sovelluksen tilan, mutta kuten dokumentaatio toteaa

  • TanStack Query is a server-state library, responsible for managing asynchronous operations between your server and client
  • Zustand, etc. are client-state libraries that can be used to store asynchronous data, albeit inefficiently when compared to a tool like TanStack Query

TanStack Query on siis kirjasto, joka ylläpitää frontendissä palvelimen tilaa, eli toimii ikäänkuin välimuistina sille, mitä palvelimelle on talletettu. TanStack Query yksinkertaistaa palvelimella olevan datan käsittelyä, ja voi joissain tapauksissa eliminoida tarpeen sille, että palvelimella oleva data haettaisiin frontendin tilaan. Useimmat React-sovellukset tarvitsevat palvelimella olevan datan tilapäisen tallettamisen lisäksi myös jonkun ratkaisun sille, miten frontendin muu tila (esim. lomakkeiden tai notifikaatioiden tila) käsitellään.

Context API

Palataan vielä vanhaan kunnon laskurisovellukseen. Sovellus on määritelty seuraavasti

import { useState } from 'react'
import Display from './components/Display'
import Controls from './components/Controls'

const App = () => {
  const [counter, setCounter] = useState(0)

  return (
    <div>
      <Display counter={counter} />
      <Controls counter={counter} setCounter={setCounter} />
    </div>
  )
}

Komponentti App siis määrittelee sovelluksen tilan, jonka se välittää laskurin arvon näyttävälle komponentille Display

const Display = ({ counter }) => {

  return (
    <div>{counter}</div>
  )
}

sekä napit renderöivälle komponentille Controls:

const Controls = ({ counter, setCounter }) => {
  const increment = () => setCounter(counter + 1)
  const decrement = () => setCounter(counter - 1)
  const zero = () => setCounter(0)

  return (
    <div>
      <button onClick={increment}>plus</button>
      <button onClick={decrement}>minus</button>
      <button onClick={zero}>zero</button>
    </div>
  )
}

Sovellus kasvaa:

fullstack content

Komponentin App rooli muuttuu, se säilyttää edelleen sovelluksen tilan, mutta ei enää itse renderöi suoraan laskurin tilaa käyttäviä komponentteja:

const App = () => {
  const [counter, setCounter] = useState(0)

  return (
    <div>
      <Navbar />
      <Panel counter={counter} setCounter={setCounter} />
      <Footer />
    </div>
  )
}

Uuden komponentin Panel vastuulle tulee laskurin näytöstä ja napeista huolehtivien komponenttien renderöinti:

import Display from './Display'
import Controls from './Controls'

const Panel = ({ counter, setCounter }) => {
  return (
    <div>
      <Display counter={counter} />
      <Controls counter={counter} setCounter={setCounter} />
    </div>
  )
}

Sovelluksen komponenttihierarkia on siis seuraava:

App (state)
 ├── Panel 
 │    ├── Display
 │    └── Controls
 └── Footer

Sovelluksen tila on siis edelleen komponentissa App. Jotta laskurin tilaan päästään käsiksi komponenteissa Display ja Controls, välitetään tila ja sen muutosfunktio propseina komponentin Panel kautta, vaikka komponentti ei itse tilaa tarvitse. Vastaavanlaisia tilanteita syntyy helposti kun käytetään hookilla useState muodostettua tilaa. Ilmiöstä käytetään nimitystä prop drilling.

Reactin sisäänrakennettu Context API tuo tilanteeseen ratkaisun. Reactin konteksti on eräänlainen sovelluksen globaali tila, johon on mahdollista antaa suora pääsy mille tahansa komponentille.

Luodaan sovellukseen nyt konteksti, joka tallettaa laskurin tilanhallinnan.

Konteksti luodaan Reactin hookilla createContext. Luodaan konteksti tiedostoon src/CounterContext.jsx:

import { createContext } from 'react'

const CounterContext = createContext()

export default CounterContext

Komponentti App voi nyt tarjota kontekstin sen alikomponenteille seuraavasti:

// ...
import CounterContext from './components/CounterContext'

const App = () => {
  const [counter, setCounter] = useState(0)

  return (
    <CounterContext.Provider value={{counter, setCounter}}>      <Panel />      <Footer />
    </CounterContext.Provider>  )
}

Kontekstin tarjoaminen siis tapahtuu käärimällä lapsikomponentit komponentin CounterContext.Provider sisälle ja asettamalla kontekstille sopiva arvo.

Kontekstin arvoksi annetaan nyt olio, jolla on attribuutit counter ja setCounter, eli laskurin tila ja sitä muuttava funktio.

Huomioinarvoista on nyt se, että komponentille Panel ei enää välitetä laskurin tilaan liittyviä propseja, eli komponentti pelkistyy muotoon

const Panel = () => {
  return (
    <div>
      <Display />
      <Controls />
    </div>
  )
}

Muut komponentit saavat nyt kontekstin käyttöön hookin useContext avulla. Display-komponentti muuttuu seuraavasti:

import { useContext } from 'react'import CounterContext from './CounterContext'
const Display = () => {  const { counter } = useContext(CounterContext)
  return <div>{counter}</div>
}

Display-komponentti ei siis tarvitse enää propseja, vaan se saa laskurin arvon käyttöönsä kutsumalla useContext-hookia, jolle se antaa parametriksi CounterContext-olion.

Vastaavasti Controls-komponentti muuttuu muotoon:

import { useContext } from 'react'import CounterContext from './CounterContext'
const Controls = () => {
  const { counter, setCounter } = useContext(CounterContext)
  const increment = () => setCounter(counter + 1)
  const decrement = () => setCounter(counter - 1)
  const zero = () => setCounter(0)

  return (
    <div>
      <button onClick={increment}>plus</button>
      <button onClick={decrement}>minus</button>
      <button onClick={zero}>zero</button>
    </div>
  )
}

export default Controls

Komponentit saavat siis näin tietoonsa kontekstin tarjoajan siihen asettaman sisällön, eli laskurin tilan sekä sen arvoa muuttavan funktion.

Komponentit ottavat käyttöönsä tarvitsemansa attribuutit käyttäen hyödykseen JavaScriptin destrukturointisyntaksia:

const { counter } = useContext(CounterContext)

Laskurikontekstin määrittely omassa tiedostossa

Sovelluksessamme on vielä sellainen ikävä piirre, että laskurin tilanhallinnan toiminnallisuus on määritelty komponentissa App. Siirretään nyt kaikki laskuriin liittyvä tiedostoon CounterContext.jsx:

import { createContext, useState } from 'react'

const CounterContext = createContext()

export default CounterContext

export const CounterContextProvider = (props) => {  const [counter, setCounter] = useState(0)  return (    <CounterContext.Provider value={{ counter, setCounter }}>      {props.children}    </CounterContext.Provider>  )}

Tiedosto eksporttaa nyt kontekstia vastaavan olion CounterContext lisäksi komponentin CounterContextProvider joka on käytännössä kontekstin tarjoaja (context provider), jonka arvona on laskuri ja sen tilan asettava funktio.

Otetaan kontekstin tarjoaja käyttöön suoraan tiedostossa main.jsx

import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'

import App from './App'
import { CounterContextProvider } from './CounterContext'
createRoot(document.getElementById('root')).render(
  <CounterContextProvider>    <App />
  </CounterContextProvider>)

Nyt laskurin arvon ja toiminnallisuuden määrittelevä konteksti on kaikkien sovelluksen komponenttien käytettävissä.

Komponentti App yksinkertaistuu seuraavaan muotoon:

import Panel from './components/Panel'
import Footer from './components/Footer'

const App = () => {

  return (
    <div>
      <Navbar />
      <Panel />
      <Footer />
  </div>
  )
}

export default App

Kontekstia käytetään edelleen samalla tavalla, eikä muihin komponentteihin tarvita muutoksia, eli esim. Controls on edelleen muotoa

const Controls = () => {
  const { counter, setCounter } = useContext(CounterContext)
  const increment = () => setCounter(counter + 1)
  const decrement = () => setCounter(counter - 1)
  const zero = () => setCounter(0)

  return (
    <div>
      <button onClick={increment}>plus</button>
      <button onClick={decrement}>minus</button>
      <button onClick={zero}>zero</button>
    </div>
  )
}

Ratkaisu on varsin hyvä. Koko sovelluksen tila eli laskurin arvo on nyt eristetty tiedostoon CounterContext. Komponentit saavat käyttöönsä juuri tarvitsemansa osan kontekstia useContext-hookia ja JavaScriptin destrukturointi-syntaksia käyttäen.

Tehdään vielä pieni parannus, ja määritellään myös laskurin arvoa muuttavat funktiot increment, decrement ja zero kontekstissa:

import { createContext, useState } from 'react'

const CounterContext = createContext()

export default CounterContext

export const CounterContextProvider = (props) => {
  const [counter, setCounter] = useState(0)

  const increment = () => setCounter(counter + 1)  const decrement = () => setCounter(counter - 1)  const zero = () => setCounter(0)
  return (
    <CounterContext.Provider value={{ counter, increment, decrement, zero }}>      {props.children}
    </CounterContext.Provider>
  )
}

Nyt voimme käyttää nappien tapahtumankäsittelijöinä suoraan kontekstista saatuja funktiota:

import { useContext } from 'react'
import CounterContext from '../CounterContext' 

const Controls = () => {
  const { increment, decrement, zero } = useContext(CounterContext)
  return (
    <div>
      <button onClick={increment}>plus</button>
      <button onClick={decrement}>minus</button>
      <button onClick={zero}>zero</button>
    </div>
  )
}

Parantamisen varaa on vielä erään asian suhteen. Jos tarkastelemme laskurikontekstin käyttöönottoa, huomaamme, että sama toimistuu molemmissa sitä käyttävissä komponenteissa:

import { useContext } from 'react'
import CounterContext from '../CounterContext' 

const Display = () => {
  const { counter } = useContext(CounterContext)
  // ...
}
import { useContext } from 'react'
import CounterContext from '../CounterContext' 

const Controls = () => {
  const { increment, decrement, zero } = useContext(CounterContext)  // ...
}

Voimme viedä ratkaisun aseleen pidemmälle muodostamalla custom hookin, joka palauttaa suoraan oikean kontekstin. Lisätään se tiedostoon hooks/useCoutet.js:

import { useContext } from 'react'
import CounterContext from '../CounterContext'

const useCounter = () => useContext(CounterContext)

export default useCounter

Kontekstin käyttöönotto on nyt yhden askeleen helpompaa:

import { useCounter } from '../hooks/useCounter'

const Display = () => {
  const { counter } = useCounter()
  // ...
}

import { useCounter } from '../hooks/useCounter'

const Controls = () => {
  const { increment, decrement, zero } = useCounter()
  // ...
}

Olemme tyytyväisiä ratkaisuun, se eristää tilan käsittelyn kokonaisuudessaan kontekstiin. Tilaa käyttävät komponentit eivät ole millään tavalla tietoisia siitä miten tila on toteutettu, custom hookin ansioista ne eivät oikeastaan edes ole tietoisia siitä että ratkaisu perustuu Context API:n käyttöön.

Sovelluksen koodi on GitHubissa repositoriossa https://github.com/fullstack-hy2020/context-counter.

Tilanhallintaratkaisun valinta

Osissa 1-5 kaikki sovelluksen tilanhallinta hoidettiin Reactin hookin useState avulla. Backendiin tehtävät asynkroniset kutsut edellyttivät joissain tilanteissa hookin useEffect käyttöä. Mitään muuta ei periaatteessa tarvita.

Hienoisena ongelmana useState-hookilla luotuun tilaan perustuvassa ratkaisussa on se, että jos jotain osaa sovelluksen tilasta tarvitaan useissa sovelluksen komponenteissa, tulee tila ja sen muuttamiseksi tarvittavat funktiot välittää propsien avulla kaikille tilaa käsitteleville komponenteille. Joskus propseja on välitettävä usean komponentin läpi, ja voi olla, että matkan varrella olevat komponentit eivät edes ole tilasta millään tavalla kiinnostuneita. Tästä hieman ikävästä ilmiöstä käytetään nimitystä prop drilling.

Aikojen saatossa React-sovellusten tilanhallintaan on kehitelty muutamiakin vaihtoehtoisia ratkaisuja, joiden avulla ongelmallisia tilanteita (esim. prop drilling) saadaan helpotettua. Mikään ratkaisu ei kuitenkaan ole ollut "lopullinen", kaikilla on omat hyvät ja huonot puolensa, ja uusia ratkaisuja kehitellään koko ajan.

Aloittelijaa ja kokenuttakin web-kehittäjää tilanne saattaa hämmentää. Mitä ratkaisua tulisi käyttää?

Yksinkertaisessa sovelluksessa useState on varmasti hyvä lähtökohta. Jos sovellus kommunikoi palvelimen kanssa, voi kommunikoinnin hoitaa lukujen 1-5 tapaan itse sovelluksen tilaa hyödyntäen. Viime aikoina on kuitenkin yleistynyt se, että kommunikointi ja siihen liittyvä tilanhallinta siirretään ainakin osin TanStack Queryn (tai jonkun muun samantapaisen kirjaston) hallinnoitavaksi. Jos useState ja sen myötä aiheutuva prop drilling arveluttaa, voi kontekstin käyttö olla hyvä vaihtoehto. On myös tilanteita, joissa osa tilasta voi olla järkevää hoitaa useStaten ja osa kontekstien avulla.

Pitkään suosituin kattavin tilanhallintaratkaisu on ollut Redux, joka on eräs tapa toteuttaa ns. Flux-arkkitehtuuri. Redux on kuitenkin tunnettu monimutkaisuudestaan ja runsaasta boilerplate-koodistaan, mikä on ollut motivaationa uudemmille tilanhallintaratkaisuille. Kurssimateriaalissa Redux onkin korvattu Zustand-kirjastolla, joka tarjoaa vastaavan toiminnallisuuden huomattavasti yksinkertaisemmalla rajapinnalla. Zustand on noussut suosituksi vaihtoehdoksi erityisesti silloin, kun tarvitaan enemmän kuin mitä useState tarjoaa, mutta Reduxin täysi koneisto tuntuu ylimitoitetulta. Osa Reduxin jäykkyyteen kohdistuvasta kritiikistä tosin on vanhentunut Redux Toolkit:in ansiosta, ja Redux on edelleen laajasti käytössä erityisesti suuremmissa projekteissa.

Myöskään Zustandia tai Reduxia ei ole pakko käyttää sovelluksessa kokonaisvaltaisesti. Saattaa olla mielekästä hoitaa esim. sovellusten lomakkeiden datan tallentaminen niiden ulkopuolella, erityisesti niissä tilanteissa, missä lomakkeen tila ei vaikuta muuhun sovellukseen. Myös Zustandin tai Reduxin ja TanStack Queryn yhteiskäyttö samassa sovelluksessa on täysin mahdollista.

Kysymys siitä mitä tilanhallintaratkaisua tulisi käyttää ei ole ollenkaan suoraviivainen. Yhtä oikeaa vastausta on mahdotonta antaa, ja on myös todennäköistä, että valittu tilanhallintaratkaisu saattaa sovelluksen kasvaessa osoittautua siinä määrin epäoptimaaliseksi, että tilanhallinnan ratkaisuja täytyy vaihtaa vaikka sovellus olisi jo ehditty viedä tuotantokäyttöön.