Siirry sisältöön

d

React Query, useReducer ja context

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.note.value = ''
    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.content}
          <strong> {note.important ? 'important' : ''}</strong>
        </li>
      ))}
    </div>
  )
}

export default App

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

Palvelimella olevan datan hallinta React Query ‑kirjaston avulla

Hyödynnämme nyt React Query ‑kirjastoa palvelimelta haettavan datan säilyttämiseen ja hallinnointiin. Kirjaston uusimmasta versiosta käytetään myös nimitystä TanStack Query mutta pitäydymme vanhassa tutussa nimessä.

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.note.value = ''
    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.isLoading) {    return <div>loading data...</div>  }   const notes = result.data
  return (
    // ...
  )
}

Datan hakeminen palvelimelta tapahtuu edellisen luvun tapaan Fetch APIn fetch-metodilla. Metodikutsu 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:

fullstack content

Eli ensimmäistä kertaa komponenttia renderöitäessä kysely on vielä tilassa loading, 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 React 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 React 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.note.value = ''
    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 React 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ä React 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.note.value = ''
    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, metodi 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 React 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 takaisunkutsufunktion 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. React 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ä React 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 React 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ä.

Sovelluksen lopullinen koodi on GitHubissa branchissa part6-3.

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

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

React Query on siis kirjasto, joka ylläpitää frontendissä palvelimen tilaa, eli toimii ikäänkuin välimuistina sille, mitä palvelimelle on talletettu. React 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.

useReducer

Vaikka sovellus siis käyttäisi React Queryä, tarvitaan siis yleensä jonkinlainen ratkaisu selaimen muun tilan (esimerkiksi lomakkeiden) hallintaan. Melko usein useState:n avulla muodostettu tila on riittävä ratkaisu. Reduxin käyttö on toki mahdollista mutta on olemassa myös muita vaihtoehtoja.

Tarkastellaan yksinkertaista laskurisovellusta. Sovellus näyttää laskurin arvon, ja tarjoaa kolme nappia laskurin tilan päivittämiseen:

fullstack content

Toteutetaan laskurin tilan hallinta Reactin sisäänrakennetun useReducer-hookin tarjoamalla Reduxin kaltaisella tilanhallintamekanismilla.

Sovelluksen lähtötilanteen koodi on GitHubissa branchissa part6-1. Tiedosto App.jsx näyttää seuraavalta:

import { useReducer } from 'react'

const counterReducer = (state, action) => {
  switch (action.type) {
    case 'INC':
      return state + 1
    case 'DEC':
      return state - 1
    case 'ZERO':
      return 0
    default:
      return state
  }
}

const App = () => {
  const [counter, counterDispatch] = useReducer(counterReducer, 0)

  return (
    <div>
      <div>{counter}</div>
      <div>
        <button onClick={() => counterDispatch({ type: 'INC' })}>+</button>
        <button onClick={() => counterDispatch({ type: 'DEC' })}>-</button>
        <button onClick={() => counterDispatch({ type: 'ZERO' })}>0</button>
      </div>
    </div>
  )
}

export default App

useReducer siis tarjoaa mekanismin, jonka avulla sovellukselle voidaan luoda tila. Parametrina tilaa luotaessa annetaan tilan muutosten hallinnan hoitava reduserifunktio, sekä tilan alkuarvo:

const [counter, counterDispatch] = useReducer(counterReducer, 0)

Tilan muutokset hoitava reduserifunktio on täysin samanlainen Reduxin reducerien kanssa, eli funktio saa parametrikseen nykyisen tilan, sekä tilanmuutoksen tekemän actionin. Funktio palauttaa actionin tyypin ja mahdollisen sisällön perusteella päivitetyn uuden tilan:

const counterReducer = (state, action) => {
  switch (action.type) {
    case 'INC':
      return state + 1
    case 'DEC':
      return state - 1
    case 'ZERO':
      return 0
    default:
      return state
  }
}

Esimerkissämme actioneilla ei ole muuta kuin tyyppi. Jos actionin tyyppi on INC, kasvattaa se tilan arvoa yhdellä jne. Reduxin reducerien tapaan actionin mukana voi myös olla mielivaltaista dataa, joka yleensä laitetaan actionin kenttään payload.

Funktio useReducer palauttaa taulukon, jonka kautta päästään käsiksi tilan nykyiseen arvoon (taulukon ensimmäinen alkio), sekä dispatch-funktioon (taulukon toinen alkio), jonka avulla tilaa voidaan muuttaa:

const App = () => {
  const [counter, counterDispatch] = useReducer(counterReducer, 0)
  return (
    <div>
      <div>{counter}</div>      <div>
        <button onClick={() => counterDispatch({ type: 'INC' })}>+</button>        <button onClick={() => counterDispatch({ type: 'DEC' })}>-</button>
        <button onClick={() => counterDispatch({ type: 'ZERO' })}>0</button>
      </div>
    </div>
  )
}

Tilan muutos tapahtuu siis täsmälleen kuten Reduxia käytettäessä, dispatch-funktiolle annetaan parametriksi sopiva tilaa muuttava action:

counterDispatch({ type: "INC" })

Tilan välittäminen propseina

Kun sovellus jaetaan useaan komponenttiin, laskurin arvo sekä sen hallintaan käytettävä dispatch-funktio on välitettävä jotenkin myös muille komponenteille. Eräs ratkaisu on välittää nämä tuttuun tapaan propseina.

Määritellään sovellukselle erillinen Display-komponentti, jonka vastuulla on laskurin arvon näyttäminen. Tiedoston src/components/Display.jsx sisällöksi tulee:

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

export default Display

Määritellään lisäksi Button-komponentti, joka vastaa sovelluksen painikkeista:

const Button = ({ dispatch, type, label }) => {
  return (
    <button onClick={() => dispatch({ type })}>
      {label}
    </button>
  )
}

export default Button

Tiedosto App.jsx muuttuu seuraavasti:

import { useReducer } from 'react'

import Button from './components/Button'import Display from './components/Display'
const counterReducer = (state, action) => {
  switch (action.type) {
    case 'INC':
      return state + 1
    case 'DEC':
      return state - 1
    case 'ZERO':
      return 0
    default:
      return state
  }
}

const App = () => {
  const [counter, counterDispatch] = useReducer(counterReducer, 0)

  return (
    <div>
      <Display counter={counter} />      <div>
        <Button dispatch={counterDispatch} type="INC" label="+" />        <Button dispatch={counterDispatch} type="DEC" label="-" />        <Button dispatch={counterDispatch} type="ZERO" label="0" />      </div>
    </div>
  )
}

Sovellus on nyt jaettu useampaan komponenttiin. Tilanhallinta on määritelty tiedostossa App.jsx, josta tilanhallintaan tarvittavat arvot ja funktiot välitetään lapsikomponenteille propseina.

Ratkaisu toimii, mutta ei ole optimaalinen. Jos komponenttirakenne monimutkaistuu, tulee esim dispatcheria välittää propsien avulla monen komponentin kautta sitä tarvitseville komponenteille siitäkin huolimatta, että komponenttipuussa välissä olevat komponentit eivät dispatcheria tarvitsisikaan. Tästä ilmiöstä käytetään nimitystä prop drilling.

Kontekstin käyttö tilan välittämiseen

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 { useReducer } from 'react'

import Button from './components/Button'
import Display from './components/Display'
import CounterContext from './CounterContext'
// ...

const App = () => {
  const [counter, counterDispatch] = useReducer(counterReducer, 0)

  return (
    <CounterContext.Provider value={{ counter, counterDispatch }}>      <Display />      <div>
        <Button type="INC" label="+" />        <Button type="DEC" label="-" />        <Button type="ZERO" label="0" />      </div>
    </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 counterDispatch. Kenttä counter sisältää laskimen arvon ja counterDispatch arvon muuttamiseen käytettävän dispatch-funktion.

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 argumentiksi CounterContext-olion.

Vastaavasti Button-komponentti muuttuu muotoon:

import { useContext } from 'react'import CounterContext from './CounterContext'
const Button = ({ type, label }) => {  const { counterDispatch } = useContext(CounterContext)
  return (
    <button onClick={() => counterDispatch({ type })}>      {label}
    </button>
  )
}

Komponentit saavat siis näin tietoonsa kontekstin tarjoajan siihen asettaman sisällön. Tässä tapauksessa kontekstina on olio, jolla on laskurin arvoa kuvaava kenttä counter sekä laskurin tilaa muuttavaa dispatch-funktiota kuvaava kenttä counterDispatch.

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

const { counter } = useContext(CounterContext)

Sovelluksen tämänhetkinen koodi on GitHubissa repositorion https://github.com/fullstack-hy2020/hook-counter branchissa part6-2.

Laskurikontekstin määrittely omassa tiedostossa

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

import { createContext, useReducer } from 'react'

const counterReducer = (state, action) => {
  switch (action.type) {
    case 'INC':
      return state + 1
    case 'DEC':
      return state - 1
    case 'ZERO':
      return 0
    default:
      return state
  }
}

const CounterContext = createContext()

export const CounterContextProvider = (props) => {
  const [counter, counterDispatch] = useReducer(counterReducer, 0)

  return (
    <CounterContext.Provider value={{ counter, counterDispatch }}>
      {props.children}
    </CounterContext.Provider>
  )
}

export default CounterContext

Tiedosto exporttaa 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 tilanhallintaan käytettävä dispatcheri.

Otetaan kontekstin tarjoaja käyttöön 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(
  <StrictMode>
    <CounterContextProvider>      <App />
    </CounterContextProvider>  </StrictMode>
)

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

Komponentti App yksinkertaistuu seuraavaan muotoon:

import Button from './components/Button'
import Display from './components/Display'

const App = () => {
  return (
    <div>
      <Display />
      <div>
        <Button type="INC" label="+" />
        <Button type="DEC" label="-" />
        <Button type="ZERO" label="0" />
      </div>
    </div>
  )
}

export default App

Kontekstia käytetään edelleen samalla tavalla, eikä muihin kompoentteihin tarvita muutoksia. Esimerkiksi komponentti Button on siis määritelty seuraavasti:

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

const Button = ({ type, label }) => {
  const { counterDispatch } = useContext(CounterContext)

  return (
    <button onClick={() => counterDispatch({ type })}>
      {label}
    </button>
  )
}

export default Button

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

Sovelluksen lopullinen koodi on GitHubissa repositorion https://github.com/fullstack-hy2020/hook-counter branchissa part6-3.

Tilanhallintaratkaisun valinta

Luvuissa 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 tilanteinta (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äytää?

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 React 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.

Kaikkien kattavimman ja järeimmän tilanhallintaratkaisun tarjoaa Redux, joka on eräs tapa toteuttaa ns. Flux-arkkitehtuuri. Redux on hieman vanhempi kuin tässä aliosassa esitetyt ratkaisut. Reduxin jähmeys onkin ollut motivaationa monille uusille tilanhallintaratkaisuille kuten tässä osassa esittelemällemme Reactin useReducer:ille. Osa Reduxin jäykkyyteen kohdistuvasta kritiikistä tosin on jo vanhentunut Redux Toolkit:in ansiosta.

Vuosien saatossa on myös kehitelty muita Reduxia vastaavia tilantahallintakirjastoja, kuten esim. uudempi tulokas Recoil ja hieman iäkkäämpi MobX. Npm trendsien perusteella Redux kuitenkin dominoi edelleen selvästi, ja näyttää itse asiassa vaan kasvattavan etumatkaansa:

fullstack content

Myöskään Reduxia ei ole pakko käyttää sovelluksessa kokonaisvaltaisesti. Saattaa olla mielekästä hoitaa esim. sovellusten lomakkeiden datan tallentaminen Reduxin ulkopuolella, erityisesti niissä tilanteissa, missä lomakkeen tila ei vaikuta muuhun sovellukseen. Myös Reduxin ja React 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.