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.

Huom: Oletuksena repon kloonaus antaa sinulle ainoastaan main-branchin. Kloonaa siis yllä mainittu repo käyttämällä seuraavaa komentoa, jotta saat part6-0 branchin sisällön.

git clone --branch part6-0 https://github.com/fullstack-hy2020/query-notes.git

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 ReactDOM from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import App from './App'

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

Voimme nyt hakea muistiinpanot komponentissa App. Koodi laajenee seuraavasti:

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

  const result = useQuery({    queryKey: ['notes'],    queryFn: () => axios.get('http://localhost:3001/notes').then(res => res.data)  })  console.log(JSON.parse(JSON.stringify(result)))
  if ( result.isLoading ) {    return <div>loading data...</div>  }
  const notes = result.data
  return (
    // ...
  )
}

Datan hakeminen palvelimelta tapahtuu edelleen tuttuun tapaan Axiosin get-metodilla. Axiosin metodikutsu on kuitenkin nyt kääritty useQuery-funktiolla muodostetuksi kyselyksi. Funktiokutsun ensimmäisenä parametrina on merkkijono notes joka 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 requests.js:

import axios from 'axios'

export const getNotes = () =>
  axios.get('http://localhost:3001/notes').then(res => res.data)

Komponentti App yksinkertaistuu nyt hiukan

import { useQuery } from '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:

import axios from 'axios'

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

export const getNotes = () =>
  axios.get(baseUrl).then(res => res.data)

export const createNote = newNote =>  axios.post(baseUrl, newNote).then(res => res.data)

Komponentti App muuttuu seuraavasti

import { useQuery, useMutation } from '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 mutaatio funktion useMutation avulla:

const newNoteMutation = useMutation({ mutationFn: createNote })

Parametrina on tiedostoon requests.js lisäämämme funktio, joka lähettää Axiosin 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 'react-query'import { getNotes, createNote } from './requests'

const App = () => {
  const queryClient = useQueryClient()
  const newNoteMutation = useMutation(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 = updatedNote =>
  axios.put(`${baseUrl}/${updatedNote.id}`, updatedNote).then(res => res.data)

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

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

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

  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(updateNote, {
    onSuccess: () => {
      queryClient.invalidateQueries('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(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 = newNote =>
  axios.post(baseUrl, newNote).then(res => res.data)

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

Jos seuraamme tarkasti selaimen network-välilehteä, huomaamme että React Query hakee kaikki muistiinpanot jo siinä vaiheessa kun viemme kursorin syötekenttään:

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 eli sovelluksen käyttöliittymän aktiivinen elementti 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:

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

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

Kontekstin käyttö tilan välittämiseen

Jos haluamme jakaa sovelluksen useaan komponenttiin, on laskurin arvo sekä sen hallintaan käytettävä dispatch-funktio välitettävä myös muille komponenteille. Eräs ratkaisu olisi välittää nämä tuttuun tapaan propseina:

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

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

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

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.

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 CounterContext.jsx:

import { createContext } from 'react'

const CounterContext = createContext()

export default CounterContext

Komponentti App voi nyt tarjota kontekstin sen alikomponenteille seuraavasti:

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 taulukko, joka sisältää laskimen arvon, sekä arvon muuttamiseen käytettävän dispatch-funktion.

Muut komponentit saavat nyt kontekstin käyttöön hookin useContext avulla:

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

const Display = () => {
  const [counter, dispatch] = useContext(CounterContext)  return <div>
    {counter}
  </div>
}

const Button = ({ type, label }) => {
  const [counter, dispatch] = useContext(CounterContext)  return (
    <button onClick={() => dispatch({ type })}>
      {label}
    </button>
  )
}

Komponentit saavat siis näin tietoonsa kontekstin tarjoajan siihen asettaman sisällön, joka on tällä kertaa taulukko mikä sisältää laskurin arvon, sekä laskurin tilaa muuttavan dispatch-funktion.

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 ReactDOM from 'react-dom/client'
import App from './App'
import { CounterContextProvider } from './CounterContext'
ReactDOM.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 Display from './components/Display'
import Button from './components/Button'

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, esim. komponentti Button on siis määritelty seuraavasti:

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

const Button = ({ type, label }) => {
  const [counter, dispatch] = useContext(CounterContext)
  return (
    <button onClick={() => dispatch({ type })}>
      {label}
    </button>
  )
}

export default Button

Komponentti Button tarvitsee ainoastaan laskuriin liittyvää dispatch-funktiota, mutta se saa kontekstista funktion useContext avulla haltuunsa myös laskurin arvon (eli muuttujan counter):

  const [counter, dispatch] = useContext(CounterContext)

Tämä ei ole suuri ongelma, mutta koodia on mahdollista muuttaa hieman miellyttävämpään ja ilmaisuvoimaisempaan suuntaan määrittelemällä tiedostoon CounterContext pari apufunktiota:

import { createContext, useReducer, useContext } from 'react'
const CounterContext = createContext()

// ...

export const useCounterValue = () => {
  const counterAndDispatch = useContext(CounterContext)
  return counterAndDispatch[0]
}

export const useCounterDispatch = () => {
  const counterAndDispatch = useContext(CounterContext)
  return counterAndDispatch[1]
}

// ...

Näiden apufunktioiden avulla kontekstia käyttävien komponenttien on mahdollista saada haltuunsa juuri tarvitsemansa osa kontekstia. Komponentti Display muuttuu seuraavasti:

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


export default Display

Komponentti Button muuttuu muotoon:

import { useCounterDispatch } from '../CounterContext'
const Button = ({ type, label }) => {
  const dispatch = useCounterDispatch()  return (
    <button onClick={() => dispatch({ 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 joka tarjoaa komponenteille hyvin nimetyt ja helppokäyttöiset apufunktiot tilan käsittelyyn.

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

Teknisenä yksityiskohtana todettakoon, että apufunktiot useCounterValue ja useCounterDispatch on määritelty ns. custom hookeina, sillä funktion useContext kutsuminen ei ole mahdollista muualta kuin React-komponenteista tai custom hookeista käsin. Custom hookit taas ovat JavaScript-funktioita joiden nimen pitää alkaa merkkijonolla use. Palaamme custom hookeihin hieman tarkemmin kurssin osassa 7.

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ä tilanhallintarkatkaisua 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.