c

Komponentin tila ja tapahtumankäsittely

Palataan jälleen Reactin pariin.

Sovelluksemme jäi seuraavaan tilaan

const Hello = (props) => {
  return (
    <div>
      <p>
        Hello {props.name}, you are {props.age} years old
      </p>
    </div>
  )
}

const App = () => {
  const nimi = 'Pekka'
  const ika = 10

  return (
    <div>
      <h1>Greetings</h1>
      <Hello name="Maya" age={26 + 10} />
      <Hello name={nimi} age={ika} />
    </div>
  )
}

Komponenttien apufunktiot

Laajennetaan komponenttia Hello siten, että se antaa arvion tervehdittävän henkilön syntymävuodesta:

const Hello = (props) => {
  const bornYear = () => {    const yearNow = new Date().getFullYear()    return yearNow - props.age  }
  return (
    <div>
      <p>
        Hello {props.name}, you are {props.age} years old
      </p>
      <p>So you were probably born {bornYear()}</p>    </div>
  )
}

Syntymävuoden arvauksen tekevä logiikka on erotettu omaksi funktiokseen, jota kutsutaan komponentin renderöinnin yhteydessä.

Tervehdittävän henkilön ikää ei tarvitse välittää funktiolle parametrina, sillä funktio näkee sen sisältävälle komponentille välitettävät propsit.

Syntymävuoden selvittävä funktio on määritelty komponentin toiminnan määrittelevän funktion sisällä. Esim. Javalla ohjelmoitaessa metodien määrittely toisen metodin sisällä ei onnistu. JavaScriptissa taas funktioiden sisällä määritellyt funktiot on hyvin yleisesti käytetty tekniikka.

Destrukturointi

Tarkastellaan erästä pientä, mutta käyttökelpoista ES6:n mukanaan tuomaa uutta piirrettä JavaScriptissa, eli muuttujaan sijoittamisen yhteydessä tapahtuvaa destrukturointia.

Jouduimme äskeisessä koodissa viittaamaan propseina välitettyyn dataan hieman ikävästi muodossa props.name ja props.age. Näistä props.age pitää toistaa komponentissa kahteen kertaan.

Koska props on nyt olio

props = {
  name: 'Maya',
  age: 36,
}

voimme suoraviivaistaa komponenttia siten, että sijoitamme kenttien arvot muuttujiin name ja age, jonka jälkeen niitä on mahdollista käyttää koodissa suoraan:

const Hello = (props) => {
  const name = props.name  const age = props.age
  const bornYear = () => new Date().getFullYear() - age
  return (
    <div>
      <p>Hello {name}, you are {age} years old</p>      <p>So you were probably born {bornYear()}</p>
    </div>
  )
}

Huomaa, että olemme hyödyntäneet myös nuolifunktion kompaktimpaa kirjoitustapaa funktion bornYear määrittelyssä. Kuten aiemmin totesimme, jos nuolifunktio koostuu ainoastaan yhdestä komennosta, ei funktion runkoa tarvitse kirjoittaa aaltosulkeiden sisään ja funktio palauttaa ainoan komentonsa arvon.

Seuraavat ovat siis vaihtoehtoisia tapoja määritellä sama funktio:

const bornYear = () => new Date().getFullYear() - age

const bornYear = () => {
  return new Date().getFullYear() - age
}

Destrukturointi tekee apumuuttujien määrittelyn vielä helpommaksi. Sen avulla voimme "kerätä" olion oliomuuttujien arvot suoraan omiin yksittäisiin muuttujiin:

const Hello = (props) => {
  const { name, age } = props  const bornYear = () => new Date().getFullYear() - age

  return (
    <div>
      <p>Hello {name}, you are {age} years old</p>
      <p>So you were probably born {bornYear()}</p>
    </div>
  )
}

Eli koska

props = {
  name: 'Maya',
  age: 36,
}

saa const { name, age } = props aikaan sen, että muuttuja name saa arvon 'Maya' ja muuttuja age arvon 36.

Voimme viedä destrukturoinnin vielä askeleen verran pidemmälle:

const Hello = ({ name, age }) => {  const bornYear = () => new Date().getFullYear() - age

  return (
    <div>
      <p>
        Hello {name}, you are {age} years old
      </p>
      <p>So you were probably born {bornYear()}</p>
    </div>
  )
}

Destrukturointi tehdään nyt suoraan sijoittamalla komponentin saamat propsit muuttujiin name ja age.

Eli sen sijaan, että props-olio otettaisiin vastaan muuttujaan props ja sen kentät sijoitettaisiin tämän jälkeen muuttujiin name ja age

const Hello = (props) => {
  const { name, age } = props

sijoitamme destrukturoinnin avulla propsin kentät suoraan muuttujiin kun määrittelemme komponenttifunktion saaman parametrin:

const Hello = ({ name, age }) => {

Sivun uudelleenrenderöinti

Toistaiseksi tekemämme sovellukset ovat olleet sellaisia, että kun niiden komponentit on kerran renderöity, niiden ulkoasua ei ole enää voinut muuttaa. Entä jos haluaisimme toteuttaa laskurin, jonka arvo kasvaa ajan kuluessa tai nappeja painettaessa?

Aloitetaan seuraavasta rungosta. Tiedostoon App.js tulee:

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

export default App

Tiedoston index.js sisältö on:

import React from 'react'
import ReactDOM from 'react-dom/client'

import App from './App'

let counter = 1

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

Sovelluksen juurikomponentille siis annetaan propsiksi laskurin counter arvo. Juurikomponentti renderöi arvon ruudulle. Entä laskurin arvon muuttuessa? Jos lisäämme ohjelmaan esim. komennon

counter += 1

ei komponenttia kuitenkaan renderöidä uudelleen. Voimme saada komponentin renderöitymään uudelleen kutsumalla funktiota render uudelleen esim. seuraavasti

let counter = 1

const root = ReactDOM.createRoot(document.getElementById("root"))

const refresh = () => {
  root.render(<App counter={counter} />)
}

refresh()
counter += 1
refresh()
counter += 1
refresh()

Copy-pasten vähentämisen takia komponentin renderöinti on refaktoroitu funktioon refresh.

Nyt komponentti renderöityy kolme kertaa, saaden ensin arvon 1, sitten 2 ja lopulta 3. Luvut 1 ja 2 tosin ovat ruudulla niin vähän aikaa, että niitä ei ehdi havaita.

Hieman mielenkiintoisempaan toiminnallisuuteen pääsemme tekemällä renderöinnin ja laskurin kasvatuksen toistuvasti sekunnin välein käyttäen SetInterval-metodia:

setInterval(() => {
  refresh()
  counter += 1
}, 1000)

render-funktion toistuva kutsuminen ei kuitenkaan ole hyvä tapa päivittää komponentteja, joten tutustutaan seuraavaksi järkevämpään tapaan.

Tilallinen komponentti

Tähänastiset komponenttimme ovat olleet siinä mielessä yksinkertaisia, että niillä ei ole ollut ollenkaan omaa tilaa, joka voisi muuttua komponentin elinaikana.

Määritellään nyt sovelluksemme komponentille App tila Reactin state hookin avulla.

Palautetaan index.js muotoon

import React from 'react'
import ReactDOM from 'react-dom/client'

import App from './App'

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

ja muutetaan App.js muotoon:

import { useState } from 'react'
const App = () => {
  const [ counter, setCounter ] = useState(0)
  setTimeout(    () => setCounter(counter + 1),    1000  )
  return (
    <div>{counter}</div>
  )
}

export default App

Tiedosto importtaa nyt heti ensimmäisellä rivillä useState-funktion:

import { useState } from 'react'

Komponentin määrittelevä funktio alkaa funktiokutsulla:

const [ counter, setCounter ] = useState(0)

Kutsu saa aikaan sen, että komponentille luodaan tila, joka saa alkuarvokseen nollan. Funktio palauttaa taulukon, jossa on kaksi alkiota. Alkiot otetaan taulukon destrukturointisyntaksilla talteen muuttujiin counter ja setCounter.

Muuttuja counter pitää sisällään tilan arvon joka on siis aluksi nolla. Muuttuja setCounter taas on viite funktioon, jonka avulla tilaa voidaan muuttaa.

Sovellus määrittelee funktion setTimeout avulla, että tilan counter arvoa kasvatetaan yhdellä sekunnin päästä:

setTimeout(
  () => setCounter(counter + 1),
  1000
)

Kun tilaa muuttavaa funktiota setCounter kutsutaan, renderöi React komponentin uudelleen, eli käytännössä suorittaa uudelleen komponentin määrittelevän koodin

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

  setTimeout(
    () => setCounter(counter + 1),
    1000
  )

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

kun koodi suoritetaan toista kertaa, funktion useState kutsuminen palauttaa komponentin jo olemassaolevan tilan arvon, joka on nyt 1. Komponentin suoritus määrittelee jälleen laskuria kasvatettavaksi yhdellä sekunnin päästä ja renderöi ruudulle laskurin nykyisen arvon, joka on 1.

Sekunnin päästä siis suoritetaan funktion setTimeout parametrina ollut koodi

() => setCounter(counter + 1)

ja koska muuttujan counter arvo on 1, on koodi oleellisesti sama kuin tilan counter arvoon 2 asettava

() => setCounter(2)

Ja tämä saa jälleen aikaan sen, että komponentti renderöidään uudelleen. Tilan arvo kasvaa sekunnin päästä yhdellä ja sama jatkuu niin kauan kun sovellus on toiminnassa.

Jos komponenttia ei saa renderöitymään tai komponentti renderöityy "väärään" aikaan, debuggaamista saattaa auttaa komponentin määrittelevään funktioon lisätty konsoliin tulostus. Esim. näin

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

  setTimeout(
    () => setCounter(counter + 1),
    1000
  )

  console.log('rendering...', counter)
  return (
    <div>{counter}</div>
  )
}

voidaan konsolista seurata komponentin renderöitymistä:

fullstack content

Olihan selaimesi konsoli auki? Jos ei ollut, niin lupaa että tämä oli viimeinen kerta kun asiasta pitää muistuttaa.

Tapahtumankäsittely

Jo osassa 0 mainitsimme tapahtumankäsittelijät eli funktiot, jotka on rekisteröity kutsuttavaksi tiettyjen tapahtumien eli eventien yhteydessä. Esim. käyttäjän interaktio sivun elementtien kanssa voi aiheuttaa joukon erilaisia tapahtumia.

Muutetaan sovellusta siten, että laskurin kasvaminen tapahtuukin käyttäjän painaessa button-elementin avulla toteutettua nappia.

Button-elementit tukevat mm. hiiritapahtumia (mouse events), joista yleisin on click.

Reactissa funktion rekisteröiminen tapahtumankäsittelijäksi tapahtumalle click tapahtuu seuraavasti:

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

  const handleClick = () => {    console.log('clicked')  }
  return (
    <div>
      <div>{counter}</div>
      <button onClick={handleClick}>        plus      </button>    </div>
  )
}

Yllä asetetaan buttonin onClick-attribuutin arvoksi aaltosulkeissa oleva viite koodissa määriteltyyn funktioon handleClick.

Nyt jokainen napin plus painallus saa aikaan sen, että funktiota handleClick kutsutaan, eli klikatessa konsoliin tulostuu clicked.

Tapahtumankäsittelijäfunktio voidaan määritellä myös suoraan onClick-määrittelyn yhteydessä:

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

  return (
    <div>
      <div>{counter}</div>
      <button onClick={() => console.log('clicked')}>        plus
      </button>
    </div>
  )
}

Muuttamalla tapahtumankäsittelijä muotoon

<button onClick={() => setCounter(counter + 1)}>
  plus
</button>

saamme aikaan halutun toiminnallisuuden, eli nappia painettaessa tilan counter arvo kasvaa yhdellä ja komponentti renderöityy uudelleen.

Lisätään sovellukseen myös nappi laskurin nollaamiseen:

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

  return (
    <div>
      <div>{counter}</div>
      <button onClick={() => setCounter(counter + 1)}>
        plus
      </button>
      <button onClick={() => setCounter(0)}>         zero      </button>    </div>
  )
}

Sovelluksemme on valmis!

Tapahtumankäsittelijä on funktio

Nappien tapahtumankäsittelijät on siis määritelty suoraan onClick-attribuuttien määrittelyn yhteydessä seuraavasti:

<button onClick={() => setCounter(counter + 1)}> 
  plus
</button>

Entä jos yritämme määritellä tapahtumankäsittelijän yksinkertaisemmin:

<button onClick={setCounter(counter + 1)}> 
  plus
</button>

Tämä ei kuitenkaan toimi:

fullstack content

Mistä on kyse? Tapahtumankäsittelijäksi on tarkoitus määritellä joko funktio tai viite funktioon. Kun koodissa on

<button onClick={setCounter(counter + 1)}>

tapahtumankäsittelijäksi tulee määriteltyä funktiokutsu. Sekin on monissa tilanteissa ok, mutta ei nyt. Kun React renderöi komponentin ensimmäistä kertaa ja muuttujan counter arvo on 0, se suorittaa kutsun setCounter(0 + 1), eli muuttaa komponentin tilan arvoksi 1. Tämä taas aiheuttaa komponentin uudelleenrenderöitymisen. Ja sama toistuu uudelleen...

Palautetaan siis tapahtumankäsittelijä alkuperäiseen muotoonsa:

<button onClick={() => setCounter(counter + 1)}> 
  plus
</button>

Nyt napin tapahtumankäsittelijän määrittelevä attribuutti onClick saa arvokseen funktion () => setCounter(counter + 1), ja funktiota kutsutaan siinä vaiheessa kun sovelluksen käyttäjä painaa nappia.

Tapahtumankäsittelijöiden määrittely suoraan JSX-templatejen sisällä ei useimmiten ole kovin viisasta. Tässä tapauksessa se tosin on ok, koska tapahtumankäsittelijät ovat niin yksinkertaisia.

Eriytetään kuitenkin nappien tapahtumankäsittelijät omiksi komponentin sisäisiksi apufunktioiksi:

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

  const increaseByOne = () => setCounter(counter + 1)    const setToZero = () => setCounter(0)
  return (
    <div>
      <div>{counter}</div>
      <button onClick={increaseByOne}>        plus
      </button>
      <button onClick={setToZero}>        zero
      </button>
    </div>
  )
}

Nytkin tapahtumankäsittelijät on määritelty oikein, sillä onClick-attribuutit saavat arvokseen muuttujan, joka tallettaa viitteen funktioon:

<button onClick={increaseByOne}> 
  plus
</button>

Tilan vieminen alikomponenttiin

Reactissa suositaan pieniä komponentteja, joita on mahdollista uusiokäyttää monessa osissa sovellusta ja jopa eri sovelluksissa. Refaktoroidaan koodiamme vielä siten, että yhden komponentin sijaan koostamme laskurin näytöstä ja kahdesta painikkeesta.

Tehdään ensin laskurin tilan näyttämisestä vastaava komponentti Display.

Tilan sijoittaminen riittävän ylös komponenttihierarkiassa on hyvä käytäntö. Reactin dokumentaatio toteaa seuraavasti:

Often, several components need to reflect the same changing data. We recommend lifting the shared state up to their closest common ancestor.

Jätetään tätä neuvoa seuraten sovelluksen tila eli laskimen arvo komponenttiin App ja välitetään tila eli laskurin arvo propsien avulla komponentille Display:

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

Nyt riittää, että komponentille välitetään laskurin tila eli counter:

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

  const increaseByOne = () => setCounter(counter + 1)
  const setToZero = () => setCounter(0)

  return (
    <div>
      <Display counter={counter}/>      <button onClick={increaseByOne}>
        plus
      </button>
      <button onClick={setToZero}> 
        zero
      </button>
    </div>
  )
}

Kaikki toimii edelleen. Kun nappeja painetaan ja App renderöityy uudelleen, renderöityvät myös kaikki sen alikomponentit, siis myös Display automaattisesti uudelleen.

Tehdään seuraavaksi napeille tarkoitettu komponentti Button. Napille on välitettävä propsien avulla tapahtumankäsittelijä sekä napin teksti:

const Button = (props) => {
  return (
    <button onClick={props.handleClick}>
      {props.text}
    </button>
  )
}

Komponentti App muuttuu nyt muotoon:

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

  const increaseByOne = () => setCounter(counter + 1)
  const decreaseByOne = () => setCounter(counter - 1)  const setToZero = () => setCounter(0)

  return (
    <div>
      <Display counter={counter}/>
      <Button        handleClick={increaseByOne}        text='plus'      />      <Button        handleClick={setToZero}        text='zero'      />           <Button        handleClick={decreaseByOne}        text='minus'      />               </div>
  )
}

Koska meillä on nyt uudelleenkäytettävä komponentti Button, sovellukselle on lisätty uutena toiminnallisuutena nappi, jolla laskurin arvoa voi vähentää.

Tapahtumankäsittelijä välitetään napeille propsin handleClick välityksellä. Propsin nimellä ei ole sinänsä merkitystä, mutta valinta ei ollut sattumanvarainen, sillä esim. Reactin tutoriaali suosittelee tätä konventiota.

Tilan muutos aiheuttaa uudelleenrenderöitymisen

Kerrataan vielä sovelluksen toiminnan pääperiaatteet.

Kun sovellus käynnistyy, suoritetaan komponentin App-koodi, joka luo useState-hookin avulla sovellukselle laskurin tilan counter. Komponentti renderöi laskimen alkuarvon 0 näyttävän komponentin Display sekä kolme Button-komponenttia, joille se asettaa laskurin tilaa muuttavat tapahtumankäsittelijät.

Kun jotain napeista painetaan, suoritetaan vastaava tapahtumankäsittelijä. Tapahtumankäsittelijä muuttaa komponentin App tilaa funktion setCounter avulla. Tilaa muuttavan funktion kutsuminen aiheuttaa komponentin uudelleenrenderöitymisen.

Eli jos painetaan nappia plus, muuttaa napin tapahtumankäsittelijä tilan counter arvoksi 1 ja komponentti App renderöidään uudelleen. Komponentin uudelleenrenderöinti aiheuttaa sen "alikomponentteina" olevien Display- ja Button-komponenttien uudelleenrenderöitymisen. Display saa propsin arvoksi laskurin uuden arvon 1 ja Button-komponentit saavat propseina tilaa sopivasti muuttavat tapahtumankäsittelijät.

Jotta varmasti ymmärtäisimme, miten ohjelma toimii, lisätään koodiin muutama console.log

const App = () => {
  const [counter, setCounter] = useState(0)
  console.log('rendering with counter value', counter)
  const increaseByOne = () => {
    console.log('increasing, value before', counter)    setCounter(counter + 1)
  }

  const decreaseByOne = () => { 
    console.log('decreasing, value before', counter)    setCounter(counter - 1)
  }

  const setToZero = () => {
    console.log('resetting to zero, value before', counter)    setCounter(0)
  }

  return (
    <div>
      <Display counter={counter} />
      <Button handleClick={increaseByOne} text="plus" />
      <Button handleClick={setToZero} text="zero" />
      <Button handleClick={decreaseByOne} text="minus" />
    </div>
  )
} 

Katsotaan nyt mitä konsoliin tulostuu kun painetaan nappeja plus, plus, zero ja minus:

fullstack content

Ei kannata yrittää arvailla mitä koodisi tekee, parempi on varmistaa toiminnallisuus omin silmin esim. lisäilemällä koodiin sopivasti console.log-komentoja.

Komponenttien refaktorointi

Laskimen arvon näyttävä komponentti on siis seuraava:

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

Komponentti tarvitsee ainoastaan propsin kenttää counter, joten se voidaan yksinkertaistaa destrukturoinnin avulla seuraavaan muotoon:

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

Koska komponentin määrittelevä funktio ei sisällä muuta kuin returnin, voimme määritellä sen hyödyntäen nuolifunktioiden tiiviimpää ilmaisumuotoa:

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

Vastaava suoraviivaistus voidaan tehdä myös nappikomponentille:

const Button = (props) => {
  return (
    <button onClick={props.handleClick}>
      {props.text}
    </button>
  )
}

Destrukturoidaan props:ista tarpeelliset kentät ja käytetään nuolifunktioiden tiiviimpää muotoa:

const Button = ({ handleClick, text }) => (
  <button onClick={handleClick}>
    {text}
  </button>
)