Tämä on 3. periodilla pidetyn, jo päättyneen kurssin sisältö.

Jos haluat suorittaa kurssin nyt, mene osoitteeseen https://fullstackopen-2019.github.io/

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="Arto" 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: 'Arto Hellas',
  age: 35,
}

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 {props.age} years old</p>
      <p>So you were probably born {bornYear()}</p>
    </div>
  )
}

Eli koska

props = {
  name: 'Arto Hellas',
  age: 35,
}

saa const { name, age } = props aikaan sen, että muuttuja name saa arvon 'Arto Hellas' ja muuttuja age arvon 35.

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 laskuriin counter arvo. Juurikomponentti renderöi arvon ruudulle. Entä laskurin arvon muuttuessa? Jos lisäämme ohjelmaan esim. komennon

counter.value += 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()

Copypastea vähentämään on komponentin renderöinti kääritty funktioon refresh.

Nyt komponentti renderöityy kolme kertaa, saaden ensin arvon 1, sitten 2 ja lopulta 3. 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, jolla 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,

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

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

  console.log('renderöidään', counter)
  return (
    <div>{counter}</div>
  )
}

on konsolista helppo seurata metodin render kutsuja:

fullstack content

Kun React ei toimi...

Käyttäessäsi tilan tuovaa hookia useState, saatat törmätä seuraavaan virheilmoitukseen:

fullstack content

Syynä tälle on se, että et ole asentanut riittävän uutta Reactia kuten osan 1 alussa neuvottiin.

rm -rf node_modules/ && npm i

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('klicked')  }
  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('klicked')}>        plus
      </button>
    </div>
  )
}

Muuttamalla tapahtumankäsittelijä seuraavaan muotoon

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

saamme halutun toiminnallisuuden, eli 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öiden määrittely suoraan JSX-templatejen sisällä ei useimmiten ole kovin viisasta. Eriytetään vielä 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>
  )
}

Tapahtumankäsittelijä on funktio

Metodit increaseByOne ja setToZero toimivat melkein samalla tavalla, ne asettavat uuden arvon laskurille. Tehdään koodiin yksittäinen funktio, joka sopii molempiin käyttötarkoituksiin:

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

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

Huomaamme kuitenkin että muutos hajottaa sovelluksemme täysin:

fullstack content

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

<button onClick={setToValue(0)}>

tapahtumankäsittelijäksi tulee määriteltyä funktiokutsu. Sekin on monissa tilanteissa ok, mutta ei nyt, nimittäin kun React renderöi metodin, se suorittaa kutsun setToValue(0). Kutsu aiheuttaa komponentin tilan päivittävän funktion setCounter kutsumisen. Tämä taas aiheuttaa komponentin uudelleenrenderöitymisen. Ja sama toistuu uudelleen...

Tilanteeseen on kaksi ratkaisua. Ratkaisuista yksinkertaisempi on muuttaa tapahtumankäsitteyä seuraavasti

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

  const setToValue = (value) => setCounter(value)

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

eli tapahtumankäsittelijäki on määritelty funktio, joka kutsuu funktiota setToValue sopivalla parametrilla:

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

Funktio joka palauttaa funktion

Toinen vaihtoehto on käyttää yleistä Javascriptin ja yleisemminkin funktionaalisen ohjelmoinnin kikkaa, eli määritellä funktio joka palauttaa funktion:

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

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

Jos et ole aiemmin törmännyt tekniikkaan, siihen totutteluun voi mennä tovi.

Olemme siis määritelleet tapahtumankäsittelijäfunktion setToValue seuraavasti:

const setToValue = (value) => {
  return () => {
    setCounter(value)
  }
}

Kun komponentissa määritellään tapahtumankäsittelijä kutsumalla setCounter(0) on lopputuloksena funktio

() => {
  setCounter(0)
}

eli juuri oikeanlainen tilan nollaamisen aiheuttava funktio!

Plus-napin tapahtumankäsittelijä määritellään kutsumalla setCounter(counter + 1). Kun komponentti renderöidään ensimmäisen kerran, counter on saanut alkuarvon 0, eli plus-napin tapahtumankäsittelijäksi tulee funktiokutsun setCounter(1) tulos, eli funktio

() => {
  setCounter(1)
}

Vastaavasti, kun laskurin tila on esim 41, tulee plus-napin tapahtumakuuntelijaksi

() => {
  setCounter(42)
}

Tarkastellaan vielä hieman funktiota setToValue:

const setToValue = (value) => {
  return () => {
    setCounter(value)
  }
}

Koska metodi itse sisältää ainoastaan yhden komennon, eli returnin, joka palauttaa funktion, voidaan hyödyntää nuolifunktion tiiviimpää muotoa:

const setToValue = (value) => () => {
  setCounter(value)
}

Usein tälläisissä tilanteissa kaikki kirjoitetaan samalle riville, jolloin tuloksena on "kaksi nuolta sisältävä funktio":

const setToValue = (value) => () => setCounter(value)

Kaksinuolisen funktion voi ajatella funktiona, jota lopullisen tuloksen saadakseen täytyy kutsua kaksi kertaa.

Ensimmäisellä kutsulla "konfiguroidaan" varsinainen funktio, sijoittamalla osalle parametreista arvo. Eli kutsu setToValue(5) sitoo muuttujaan value arvon 5 ja funktiosta "jää jäljelle" seuraava funktio:

() => setCounter(5)

Tässä näytetty tapa soveltaa funktioita palauttavia funktioita on oleellisesti sama asia mistä funktionaalisessa ohjelmoinnissa käytetään termiä currying. Termi currying ei ole lähtöisin funktionaalisen ohjelmoinnin piiristä vaan sillä on juuret syvällä matematiikassa.

Jo muutamaan kertaan mainittu termi funktionaalinen ohjelmointi ei ole välttämättä kaikille tässä vaiheessa tuttu. Asiaa avataan hiukan kurssin kuluessa, sillä React tukee ja osin edellyttää funktionaalisen tyylin käyttöä.

HUOM: muutos, missä korvasimme metodit increaseByOne ja setToZero metodilla setToValue ei välttämättä ole järkevä, sillä erikoistuneemmat metodit ovat paremmin nimettyjä. Teimme muutoksen oikeastaan ainoastaan demonstroidaksemme currying-tekniikan soveltamista.

HUOM2: et välttämättä tarvitse tämän osan, etkä kenties kurssin muissakaan tehtävissä funktioita palauttavia funktioita, joten älä sekoita päätäsi asialla turhaan.

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 näytöstä vastaava komponentti Display.

Reactissa parhaana käytänteenä on sijoittaa tila mahdollisimman ylös komponenttihierarkiassa, mielellään sovelluksen juurikomponenttiin App.

Jätetään sovelluksen tila, eli laskimen arvo komponenttiin App ja välitetään tila propsien avulla komponentille Display:

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

Voimme hyödyntää aiemmin mainittua destrukturointia myös metodien parametreissa. Eli koska olemme kiinnostuneita propsin kentästä counter, on edellinen mahdollista yksinkertaistaa seuraavaan muotoon:

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

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

const Display = ({ counter }) => <div>{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 setToValue = (value) => setCounter(value)

  return (
    <div>
      <Display counter={counter}/>      <button onClick={() => setToValue(counter + 1)}>
        plus
      </button>
      <button onClick={() => setToValue(0)}>
        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) => (
  <button onClick={props.handleClick}>
    {props.text}
  </button>
)

ja hyödynnetään taas destrukturointia ottamaan props:in tarpeelliset kentät suoraan:

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

Komponentti App muuttuu nyt muotoon:

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


  return (
    <div>
      <Display counter={counter}/>
      <Button        handleClick={() => setToValue(counter + 1)}        text='plus'      />      <Button        handleClick={() => setToValue(0)}        text='zero'      />    </div>
  )
}

Koska meillä on nyt uudelleenkäytettävä nappi, 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.