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 metodille tarvitse välittää parametrina, sillä funktio näkee sen sisältävälle komponentille välitettävät propsit.

Teknisesti ajatellen 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

Ennen kuin siirrymme eteenpäin, tarkastellaan erästä pientä, mutta käyttökelpoista ES6:n mukanaan tuomaa uutta piirrettä Javascriptissä, 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 myös hyödyntäneet nuolifunktion kompaktimpaa kirjoitustapaa metodin 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 vaihtoehtoiset tavat 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 sensijaan 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 komponettifunktion 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 esim. ajan kuluessa tai nappien painallusten yhteydessä?

Aloitetaan seuraavasta rungosta:

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

let counter = 1

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

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 uudelleenrenderöitymään kutsumalla uudelleen metodia ReactDOM.render, esim. seuraavasti

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

let counter = 1

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

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

Copypasten vähentämisen takia on komponentin renderöinti kääritty 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:

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

ReactDOM.render-metodin toistuva kutsuminen ei kuitenkaan ole suositeltu tapa päivittää komponentteja. 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.

Muutetaan ohjelmaa seuraavasti

import React, { useState } from 'react'import ReactDOM from 'react-dom'

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

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

Sovellus importaa nyt heti ensimmäisellä rivillä useState-funktion:

import React, { useState } from 'react'

Komponentin määrittelevä funktio alkaa metodikutsulla

const [ counter, setCounter ] = useState(0)

Kutsu saa aikaan sen, että komponentille luodaan tila, joka saa alkuarvokseen nollan. Metodi 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

(props) => {
  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 komponentti ei renderöidy vaikka sen omasta mielestä pitäisi, tai se renderöityy "väärään aikaan", debuggaamista auttaa joskus komponentin määrittelevään funktioon lisätty konsoliin tulostus. Esim. jos lisäämme koodiin seuraavan tulostuksen

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

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

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

voidaan konsolista seurata metodin render kutsuja:

4e

Tapahtumankäsittely

Mainitsimme jo osassa 0 muutamaan kertaan tapahtumankäsittelijät, eli funktiot, jotka on rekisteröity kutsuttavaksi tiettyjen tapahtumien eli eventien yhteydessä. Esim. käyttäjän interaktio sivun elementtien kanssa aiheuttaa joukon erinäisiä 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 = (props) => {
  const [ counter, setCounter ] = useState(0)

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

Eli laitetaan 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 = (props) => {
  const [ counter, setCounter ] = useState(0)

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

Muuttamalla tapahtumankäsittelijä seuraavaan 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 = (props) => {
  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 hieman yksinkertaisemmassa muodossa:

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

Tämä muutos kuitenkin hajottaa sovelluksemme täysin:

5b

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 metodin 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 apufunktioikseen:

const App = (props) => {
  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>
  )
}

Tälläkin kertaa 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 useissa 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.

Reactissa pidetään hyvänä käytänteenä sijoittaa tila riittävän ylös komponenttihierarkiassa. 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>
  )
}

Komponentin käyttö on suoraviivaista, riittää että sille välitetään laskurin tila eli counter:

const App = (props) => {
  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 = (props) => {
  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 täysin sattumanvarainen, 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.

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ä metodi 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 nappia edustavalle komponentille

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

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

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