d

Monimutkaisempi tila, Reactin debuggaus

Monimutkaisempi tila

Edellisessä esimerkissä sovelluksen tila oli yksinkertainen, sillä se koostui ainoastaan yhdestä kokonaisluvusta. Entä jos sovellus tarvitsee monimutkaisemman tilan?

Helpoin ja useimmiten paras tapa on luoda sovellukselle useita erillisiä tiloja tai tilan "osia" kutsumalla funktiota useState useampaan kertaan.

Seuraavassa sovellukselle luodaan kaksi alkuarvon 0 saavaa tilaa left ja right:

const App = () => {
  const [left, setLeft] = useState(0)
  const [right, setRight] = useState(0)

  return (
    <div>
      <div>
        {left}
        <button onClick={() => setLeft(left + 1)}>
          left
        </button>
        <button onClick={() => setRight(right + 1)}>
          right
        </button>
        {right}
      </div>
    </div>
  )
}

Komponentti saa käyttöönsä tilan alustuksen yhteydessä funktiot setLeft ja setRight, joiden avulla se voi päivittää tilan osia.

Komponentin tila tai yksittäinen tilan pala voi olla minkä tahansa tyyppinen. Voisimme toteuttaa saman toiminnallisuuden tallentamalla nappien left ja right painallukset yhteen olioon

{
  left: 0,
  right: 0
}

jolloin sovellus muuttuisi seuraavasti:

const App = () => {
  const [clicks, setClicks] = useState({
    left: 0, right: 0
  })

  const handleLeftClick = () => {
    const newClicks = { 
      left: clicks.left + 1, 
      right: clicks.right 
    }
    setClicks(newClicks)
  }

  const handleRightClick = () => {
    const newClicks = { 
      left: clicks.left, 
      right: clicks.right + 1 
    }
    setClicks(newClicks)
  }

  return (
    <div>
      <div>
        {clicks.left}
        <button onClick={handleLeftClick}>left</button>
        <button onClick={handleRightClick}>right</button>
        {clicks.right}
      </div>
    </div>
  )
}

Nyt komponentilla on siis ainoastaan yksi tila. Näppäinten painallusten yhteydessä on nyt huolehdittava koko tilan muutoksesta.

Tapahtumankäsittelijä vaikuttaa hieman sotkuiselta. Kun nappia left painetaan, suoritetaan seuraava funktio

const handleLeftClick = () => {
  const newClicks = { 
    left: clicks.left + 1, 
    right: clicks.right 
  }
  setClicks(newClicks)
}

ja uudeksi tilaksi asetetaan siis seuraava olio

{
  left: clicks.left + 1,
  right: clicks.right
}

jolloin kentän left arvo on sama kuin alkuperäisen tilan kentän left + 1 ja kentän right arvo on sama kuin alkuperäisen tilan kentän right.

Uuden tilan määrittelevän olion muodostaminen onnistuu hieman tyylikkäämmin hyödyntämällä kesällä 2018 kieleen tuotua object spread -syntaksia:

const handleLeftClick = () => {
  const newClicks = { 
    ...clicks, 
    left: clicks.left + 1 
  }
  setClicks(newClicks)
}

const handleRightClick = () => {
  const newClicks = { 
    ...clicks, 
    right: clicks.right + 1 
  }
  setClicks(newClicks)
}

Merkintä vaikuttaa hieman erikoiselta. Käytännössä { ...clicks } luo olion, jolla on kenttinään kopiot olion clicks kenttien arvoista. Kun aaltosulkeisiin lisätään asioita, esim. { ...clicks, right: 1 }, tulee uuden olion kenttä right saamaan arvon 1.

Esimerkissä siis

{ ...clicks, right: clicks.right + 1 }

luo oliosta clicks kopion, jossa kentän right arvoa kasvatetaan yhdellä.

Apumuuttujat ovat oikeastaan turhat, ja tapahtumankäsittelijät voidaan määritellä seuraavasti:

const handleLeftClick = () =>
  setClicks({ ...clicks, left: clicks.left + 1 })

const handleRightClick = () =>
  setClicks({ ...clicks, right: clicks.right + 1 })

Miksi emme hoitaneet tilan päivitystä seuraavasti:

const handleLeftClick = () => {
  clicks.left++
  setClicks(clicks)
}

Sovellus näyttää toimivan. Reactissa ei kuitenkaan ole sallittua muuttaa tilaa suoraan (kuten komento clicks.left nyt tekee), koska sillä voi olla arvaamattomat seuraukset. Tilan muutos tulee aina tehdä asettamalla uudeksi tilaksi vanhan perusteella tehty kopio!

Kaiken tilan pitäminen yhdessä oliossa on tämän sovelluksen kannalta huono ratkaisu; etuja siinä ei juuri ole, mutta sovellus monimutkaistuu merkittävästi. Onkin ehdottomasti parempi ratkaisu tallettaa nappien klikkaukset erillisiin tilan paloihin.

On kuitenkin tilanteita, joissa jokin osa tilaa kannattaa pitää monimutkaisemman tietorakenteen sisällä. Reactin dokumentaatiossa on hieman ohjeistusta aiheeseen liittyen.

Taulukon käsittelyä

Tehdään sovellukseen laajennus lisäämällä sovelluksen tilaan taulukko allClicks, joka muistaa kaikki näppäimenpainallukset:

const App = () => {
  const [left, setLeft] = useState(0)
  const [right, setRight] = useState(0)
  const [allClicks, setAll] = useState([])
  const handleLeftClick = () => {    setAll(allClicks.concat('L'))    setLeft(left + 1)  }
  const handleRightClick = () => {    setAll(allClicks.concat('R'))    setRight(right + 1)  }
  return (
    <div>
      <div>
        {left}
        <button onClick={handleLeftClick}>left</button>
        <button onClick={handleRightClick}>right</button>
        {right}
        <p>{allClicks.join(' ')}</p>      </div>
    </div>
  )
}

Kaikki painallukset siis talletetaan omaan tilaan allClicks, joka alustetaan tyhjäksi taulukoksi:

const [allClicks, setAll] = useState([])

Kun esim. nappia left painetaan, tilan taulukkoon allClicks lisätään kirjain L:

const handleLeftClick = () => {
  setAll(allClicks.concat('L'))
  setLeft(left + 1)
}

Tila allClicks saa nyt arvokseen taulukon, jossa on entisen taulukon alkiot ja L. Uuden alkion liittäminen on tehty metodilla concat, joka toimii siten, että se ei muuta olemassa olevaa taulukkoa vaan luo uuden taulukon, johon uusi alkio on lisätty.

Kuten jo aiemmin mainittiin, JavaScriptissa on mahdollista lisätä taulukkoon myös metodilla push ja sovellus näyttäisi tässä tilanteessa toimivan myös jos lisäys hoidettaisiin siten, että allClicks-tilaa muutetaan pushaamalla siihen alkio ja sitten päivitetään tila:

const handleLeftClick = () => {
  allClicks.push('L')
  setAll(allClicks)
  setLeft(left + 1)
}

Älä kuitenkaan tee näin. Kuten jo mainitsimme, React-komponentin tilaa, eli esimerkiksi muuttujaa allClicks, ei saa muuttaa. Vaikka tilan muuttaminen näyttääkin toimivan joissakin tilanteissa, voi seurauksena olla hankalasti havaittavia ongelmia.

Katsotaan vielä tarkemmin, miten kaikkien painallusten historia renderöidään ruudulle:

const App = () => {
  // ...

  return (
    <div>
      <div>
        {left}
        <button onClick={handleLeftClick}>left</button>
        <button onClick={handleRightClick}>right</button>
        {right}
        <p>{allClicks.join(' ')}</p>      </div>
    </div>
  )
}

Taulukolle allClicks kutsutaan metodia join, joka muodostaa taulukosta merkkijonon, joka sisältää taulukon alkiot erotettuina parametrina olevalla merkillä eli välilyönnillä.

Tilan päivitys tapahtuu asynkronisesti

Laajennetaan sovellusta siten, että se pitää kirjaa nappien painallusten yhteenlasketusta määrästä tilassa total, jonka arvoa päivitetään aina nappien painalluksen yhteydessä:

const App = () => {
  const [left, setLeft] = useState(0)
  const [right, setRight] = useState(0)
  const [allClicks, setAll] = useState([])
  const [total, setTotal] = useState(0)
  const handleLeftClick = () => {
    setAll(allClicks.concat('L'))
    setLeft(left + 1)
    setTotal(left + right)  }

  const handleRightClick = () => {
    setAll(allClicks.concat('R'))
    setRight(right + 1)
    setTotal(left + right)  }

  return (
    <div>
      {left}
      <button onClick={handleLeftClick}>left</button>
      <button onClick={handleRightClick}>right</button>
      {right}
      <p>{allClicks.join(' ')}</p>
      <p>total {total}</p>    </div>
  )
}

Ratkaisu toimii melkein:

fullstack content

Jostain syystä napien painellusten yhteenlaskettu määrä näyttää koko ajan yhtä liian vähän.

Lisätään tapahtumankäsittelijään muutama console.log:

const App = () => {
  // ...
  const handleLeftClick = () => {
    setAll(allClicks.concat('L'))
    console.log('left before', left)    setLeft(left + 1)
    console.log('left after', left)    setTotal(left + right) 
  }

  // ...
}

Konsoli paljastaa ongelman

fullstack content

Vaikka tilalle left asetettiin uusi arvo kutsumalla setLeft(left + 1) on tilalla siis tapahtumankäsittelijän sisällä edelleen vanha arvo päivityksestä huolimatta! Tämän takia seuraava takia nappien painallusten laskuyritys tuottaa aina yhtä liian pienen tuloksen:

setTotal(left + right) 

Syynä ilmiöön on se, että tilan päivitys tapahtuu Reactissa asynkronisesti, eli "jossain vaiheessa" ennen kuin komponentti renderöidään uudelleen, ei kuitenkaan välittömästi.

Saamme korjattua sovelluksen seuraavasti:

const App = () => {
  // ...
  const handleLeftClick = () => {
    setAll(allClicks.concat('L'))
    const updatedLeft = left + 1
    setLeft(updatedLeft)
    setTotal(updatedLeft + right) 
  }

  // ...
}

Eli nyt nappien määrän summa perustuu varmasti oikeaan määrään vasemman napin painalluksia.

Ehdollinen renderöinti

Muutetaan sovellusta siten, että painallushistorian renderöinnistä vastaa komponentti History:

const History = (props) => {  if (props.allClicks.length === 0) {    return (      <div>        the app is used by pressing the buttons      </div>    )  }  return (    <div>      button press history: {props.allClicks.join(' ')}    </div>  )}
const App = () => {
  // ...

  return (
    <div>
      <div>
        {left}
        <button onClick={handleLeftClick}>left</button>
        <button onClick={handleRightClick}>right</button>
        {right}
        <History allClicks={allClicks} />      </div>
    </div>
  )
}

Nyt komponentin toiminta riippuu siitä, onko näppäimiä jo painettu. Jos ei, eli taulukko allClicks on tyhjä, komponentti renderöi "käyttöohjeen" sisältävän divin.

<div>the app is used by pressing the buttons</div>

ja muussa tapauksessa näppäilyhistorian:

<div>
  button press history: {props.allClicks.join(' ')}
</div>

Komponentin History ulkoasun muodostamat React-elementit riippuvat siis sovelluksen tilasta, eli komponentissa on ehdollista renderöintiä.

Reactissa on monia muitakin tapoja ehdolliseen renderöintiin. Katsotaan niitä tarkemmin seuraavassa osassa.

Muutetaan vielä sovellusta siten, että se käyttää aiemmin määrittelemäämme komponenttia Button painikkeiden muodostamiseen:

const History = (props) => {
  if (props.allClicks.length === 0) {
    return (
      <div>
        the app is used by pressing the buttons
      </div>
    )
  }

  return (
    <div>
      button press history: {props.allClicks.join(' ')}
    </div>
  )
}

const Button = ({ handleClick, text }) => (  <button onClick={handleClick}>    {text}  </button>)
const App = () => {
  const [left, setLeft] = useState(0)
  const [right, setRight] = useState(0)
  const [allClicks, setAll] = useState([])

  const handleLeftClick = () => {
    setAll(allClicks.concat('L'))
    setLeft(left + 1)
  }

  const handleRightClick = () => {
    setAll(allClicks.concat('R'))
    setRight(right + 1)
  }

  return (
    <div>
      <div>
        {left}
        <Button handleClick={handleLeftClick} text='left' />        <Button handleClick={handleRightClick} text='right' />        {right}
        <History allClicks={allClicks} />
      </div>
    </div>
  )
}

Vanha React

Tällä kurssilla käyttämämme tapa React-komponenttien tilan määrittelyyn, eli state hook on siis "uutta" Reactia ja käytettävissä alkuvuodesta 2019 ilmestyneestä versiosta 16.8.0 lähtien. Ennen hookeja JavaScript-funktioina määriteltyihin React-komponentteihin ei ollut mahdollista saada tilaa ollenkaan, ja tilaa edellyttävät komponentit oli pakko määritellä class-komponentteina JavaScriptin luokkasyntaksia hyödyntäen.

Olemme tällä kurssilla tehneet hieman radikaalinkin ratkaisun käyttää pelkästään hookeja ja näin ollen opetella heti alusta asti ohjelmoimaan modernia Reactia. Luokkasyntaksin hallitseminen on kuitenkin sikäli tärkeää, että vaikka funktiona määriteltävät komponentit ovat modernia Reactia, maailmassa on miljardeja rivejä vanhaa Reactia, jota kenties sinäkin joudut jonain päivänä ylläpitämään. Dokumentaation ja Internetistä löytyvien esimerkkien suhteen tilanne on sama; tulet törmäämään myös class-komponentteihin.

Tutustummekin riittävällä tasolla class-komponentteihin kurssin seitsemännessä osassa.

React-sovellusten debuggaus

Ohjelmistokehittäjän työ sisältää monesti debuggaamista ja olemassa olevan koodin lukemista. Silloin tällöin syntyy toki muutama rivi uuttakin koodia, mutta suuri osa ajasta ihmetellään, miksi joku on rikki tai miksi joku asia ylipäätään toimii. Hyvät debuggauskäytännöt ja -työkalut ovatkin todella tärkeitä.

Onneksi React on debuggauksen suhteen jopa harvinaisen kehittäjäystävällinen kirjasto.

Muistutetaan vielä tärkeimmästä web-sovelluskehitykseen liittyvästä asiasta:

Web-sovelluskehityksen sääntö numero yksi

Pidä selaimen developer-konsoli koko ajan auki.

Välilehdistä tulee olla auki nimenomaan Console, jollei ole erityistä syytä käyttää jotain muuta välilehteä.

Pidä myös koodi ja web-sivu koko ajan yhtä aikaa näkyvillä.

Jos ja kun koodi ei käänny eli selaimessa alkaa näkyä punaista

fullstack content

älä kirjoita lisää koodia, vaan selvitä ongelma. Koodauksen historia ei tunne tilannetta, jossa kääntymätön koodi alkaa ihmeen voimalla toimimaan kirjoittamalla suuri määrää lisää koodia, emmekä usko tällaista ihmettä nähtävän tälläkään kurssilla.

Vanha kunnon printtaukseen perustuva debuggaus on monesti toimiva tapa. Eli jos esim. komponentissa

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

olisi ongelma, kannattaa komponentista alkaa printtailla konsoliin. Pystyäksemme printtaamaan tulee funktio muuttaa pitempään muotoon ja propsit kannattaa kenties vastaanottaa ilman destrukturointia:

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

näin selviää heti, onko esim. joku propsia vastaava attribuutti nimetty väärin komponenttia käytettäessä.

HUOM kun käytät komentoa console.log debuggaukseen, älä yhdistele asioita "javamaisesti" plussalla, eli sen sijaan että kirjoittaisit

console.log('props value is' + props)

erottele tulostettavat asiat pilkulla:

console.log('props value is', props)

Jos yhdistät merkkijonoon olion, tuloksena on suhteellisen hyödytön tulostusmuoto

props value is [Object object]

kun taas pilkulla tulostettavat asiat erotellessa saat developer-konsoliin olion, jonka sisältöä on mahdollista tarkastella.

Konsoliin tulostus ei ole suinkaan ainoa keino debuggaamiseen. Koodin suorituksen voi pysäyttää Chromen developer-konsolin debuggeriin kirjoittamalla omassa tekstieditorissasi olevaan lähdekoodiin mihin tahansa kohtaan koodia komennon debugger.

Koodi pysähtyy, kun suoritus etenee sellaiseen pisteeseen, jossa komento debugger suoritetaan:

fullstack content

Muuttujien tilaa voi tutkia Console-välilehdellä:

fullstack content

Kun bugi selviää, debugger-komennon voi poistaa ja ladata sivun uudelleen.

Debuggerissa on mahdollista suorittaa koodia tarvittaessa rivi riviltä Sources-välilehden oikealta laidalta.

Debuggeriin pääsee myös ilman komentoa debugger lisäämällä Sources-välilehdellä sopiviin kohtiin koodia breakpointeja. Komponentin muuttujien arvojen tarkkailu on mahdollista Scope-osassa:

fullstack content

Chromeen kannattaa ehdottomasti asentaa React Developer Tools -lisäosa, joka tuo konsoliin uuden välilehden Components. Uuden välilehden avulla voidaan tarkkailla sovelluksen React-komponentteja ja niiden tilaa ja propseja:

fullstack content

Komponentin App tila on määritelty seuraavasti:

const [left, setLeft] = useState(0)
const [right, setRight] = useState(0)
const [allClicks, setAll] = useState([])

React Developer Tools näyttää hookeilla luodut tilan osat siinä järjestyksessä kuin ne on määritelty koodissa:

fullstack content

Ylimpänä oleva State vastaa siis tilan left arvoa, seuraava tilan right arvoa ja alimpana on taulukko allClicks.

Chromella tapahtuvaan JavaScriptin debuggaukseen voi tutustua myös esim. tämän sivun videolla alkaen kohdasta 16:50.

Hookien säännöt

Jotta hookeilla muodostettu sovelluksen tila toimisi oikein, on hookeja käytettävä tiettyjä rajoituksia noudattaen.

Funktiota useState ei saa kutsua loopissa (sama koskee seuraavassa osassa esiteltävää funktiota useEffect), ehtolausekkeiden sisältä tai muista kuin komponentin määrittelevästä funktiosta. Tämä takaa sen, että hookeja kutsutaan aina samassa järjestyksessä. Jos näin ei ole, sovellus saattaa toimia miten sattuu.

Hookeja siis kuuluu kutsua ainoastaan React-komponentin määrittelevän funktion rungosta:

const App = (props) => {
  // nämä ovat ok
  const [age, setAge] = useState(0)
  const [name, setName] = useState('Juha Tauriainen')

  if ( age > 10 ) {
    // ei ehtolauseessa
    const [foobar, setFoobar] = useState(null)
  }

  for ( let i = 0; i < age; i++ ) {
    // eikä myöskään loopissa
    const [rightWay, setRightWay] = useState(false)
  }

  const notGood = () => {
    // ei muiden kuin komponentin määrittelevän funktion sisällä
    const [x, setX] = useState(-1000)
  }

  return (
    //...
  )
}

Tapahtumankäsittely revisited

Tapahtumankäsittely on osoittautunut aiempien vuosien kursseilla haastavaksi aiheeksi.

Tarkastellaan asiaa vielä uudelleen.

Oletetaan, että käytössä on yksinkertainen sovellus, jonka komponentti App on määritelty seuraavasti:

const App = (props) => {
  const [value, setValue] = useState(10)

  return (
    <div>
      {value}
      <button>reset to zero</button>
    </div>
  )
}

Haluamme, että napin avulla tilan tallettava muuttuja value saadaan nollattua.

Jotta saamme napin reagoimaan, on napille lisättävä tapahtumankäsittelijä.

Tapahtumankäsittelijän tulee aina olla funktio tai viite funktioon. Jos tapahtumankäsittelijän paikalle yritetään laittaa jotain muuta, nappi ei toimi.

Jos annamme tapahtumankäsittelijäksi esimerkiksi merkkijonon

<button onClick={'crap...'}>button</button>

React varoittaa asiasta konsolissa:

index.js:2178 Warning: Expected `onClick` listener to be a function, instead got a value of `string` type.
    in button (at index.js:20)
    in div (at index.js:18)
    in App (at index.js:27)

Myös seuraavanlainen yritys olisi tuhoon tuomittu:

<button onClick={value + 1}>button</button>

Nyt tapahtumankäsittelijäksi on yritetty laittaa value + 1, joka tarkoittaa laskuoperaation tulosta. React varoittaa tästäkin konsolissa:

index.js:2178 Warning: Expected `onClick` listener to be a function, instead got a value of `number` type.

Myöskään seuraava ei toimi

<button onClick={value = 0}>button</button>

sillä taaskaan tapahtumankäsittelijänä ei ole funktio vaan sijoitusoperaatio. Konsoliin tulee valitus. Tämä tapa on myös toisella tavalla väärin: tilan muuttaminen ei onnistu suoraan tilan arvon tallentavaa muuttujaa muuttamalla.

Entä seuraava:

<button onClick={console.log('clicked the button')}>
  button
</button>

Konsoliin tulostuu kertaalleen clicked the button, mutta nappia painellessa ei tapahdu mitään. Miksi tämä ei toimi vaikka tapahtumankäsittelijänä on nyt funktio console.log?

Ongelma on siinä, että tapahtumankäsittelijänä on funktion kutsu, eli varsinaiseksi tapahtumankäsittelijäksi tulee funktion kutsun paluuarvo, joka on tässä tapauksessa määrittelemätön arvo undefined.

Funktiokutsu console.log('clicked the button') suoritetaan siinä vaiheessa kun komponentti renderöidään, minkä takia konsoliin tulee kuitenkin yksi tulostus.

Myös seuraava yritys on virheellinen:

<button onClick={setValue(0)}>button</button>

Jälleen olemme yrittäneet laittaa tapahtumankäsittelijäksi funktiokutsun. Ei toimi. Tämä yritys aiheuttaa myös toisen ongelman: kun komponenttia renderöidään, suoritetaan tapahtumankäsittelijänä oleva funktiokutsu setValue(0) mikä taas saa aikaan komponentin uudelleenrenderöinnin. Ja uudelleenrenderöinnin yhteydessä funktiota kutsutaan uudelleen käynnistäen jälleen uusi uudelleenrenderöinti ja joudutaan päättymättömään rekursioon.

Jos haluamme suorittaa tietyn funktiokutsun tapahtuvan nappia painettaessa, seuraava toimii:

<button onClick={() => console.log('clicked the button')}>
  button
</button>

Nyt tapahtumankäsittelijä on nuolisyntaksilla määritelty funktio () => console.log('clicked the button'). Kun komponentti renderöidään, ei suoriteta mitään, ainoastaan talletetaan funktioviite tapahtumankäsittelijäksi. Itse funktion suoritus tapahtuu vasta napin painallusten yhteydessä.

Saamme myös nollauksen toimimaan samalla tekniikalla

<button onClick={() => setValue(0)}>button</button>

eli nyt tapahtumankäsittelijä on funktio () => setValue(0).

Tapahtumankäsittelijäfunktioiden määrittely suoraan napin määrittelyn yhteydessä ei ole välttämättä paras mahdollinen tapa.

Usein tapahtumankäsittelijä määritelläänkin jossain muualla. Seuraavassa määritellään funktio ja sijoitetaan se muuttujaan handleClick komponentin rungossa:

const App = (props) => {
  const [value, setValue] = useState(10)

  const handleClick = () =>
    console.log('clicked the button')

  return (
    <div>
      {value}
      <button onClick={handleClick}>button</button>
    </div>
  )
}

Muuttujassa handleClick on nyt talletettuna viite itse funktioon. Viite annetaan napin määrittelyn yhteydessä attribuuttiin onClick:

<button onClick={handleClick}>button</button>

Tapahtumankäsittelijäfunktio voi luonnollisesti koostua useista komennoista, jolloin käytetään nuolifunktion aaltosulullista muotoa:

const App = (props) => {
  const [value, setValue] = useState(10)

  const handleClick = () => {    console.log('clicked the button')    setValue(0)  }
  return (
    <div>
      {value}
      <button onClick={handleClick}>button</button>
    </div>
  )
}

Funktion palauttava funktio

Näytetään vielä eräs tapa määritellä tapahtumankäsittelijöitä: funktion palauttava funktio. Tällä kurssilla ei tätä tyyliä tulla käyttämään, joten voit huoletta hypätä seuraavan ohi jos asia tuntuu nyt hankalalta. Funktioita palauttavat funktiot ovat kuitenkin melko yleisiä funktionaalista ohjelmointityyliä käytettäessä, joten tarkastellaan tekniikkaa hieman vaikka selviämmekin kurssilla ilman sitä.

Muutetaan koodia seuraavasti:

const App = (props) => {
  const [value, setValue] = useState(10)

  const hello = () => {    const handler = () => console.log('hello world')    return handler  }
  return (
    <div>
      {value}
      <button onClick={hello()}>button</button>
    </div>
  )
}

Koodi näyttää hankalalta, mutta se toimii.

Tapahtumankäsittelijäksi on nyt asetettu funktiokutsu:

<button onClick={hello()}>button</button>

Aiemmin varoiteltiin, että tapahtumankäsittelijä ei saa olla funktiokutsu vaan sen on oltava funktio tai viite funktioon. Miksi funktiokutsu kuitenkin toimii nyt?

Kun komponenttia renderöidään suoritetaan seuraava funktio:

const hello = () => {
  const handler = () => console.log('hello world')

  return handler
}

Funktion paluuarvona on nyt toinen, muuttujaan handler määritelty funktio.

Eli kun React renderöi rivin

<button onClick={hello()}>button</button>

se sijoittaa onClick-käsittelijäksi funktiokutsun hello() paluuarvon. Eli oleellisesti ottaen rivi "muuttuu" seuraavaksi:

<button onClick={() => console.log('hello world')}>
  button
</button>

Koska funktio hello palautti funktion, tapahtumankäsittelijäkin on nyt funktio.

Mitä hyötyä tällaisesta on?

Muutetaan koodia hiukan:

const App = (props) => {
  const [value, setValue] = useState(10)

  const hello = (who) => {    const handler = () => {      console.log('hello', who)    }    return handler  }
  return (
    <div>
      {value}
      <button onClick={hello('world')}>button</button>      <button onClick={hello('react')}>button</button>      <button onClick={hello('function')}>button</button>    </div>
  )
}

Nyt meillä on kolme nappia, joiden tapahtumankäsittelijät määritellään parametrin saavan funktion hello avulla.

Ensimmäinen nappi määritellään seuraavasti:

<button onClick={hello('world')}>button</button>

Tapahtumankäsittelijä siis saadaan suorittamalla funktiokutsu hello('world'). Funktiokutsu palauttaa funktion:

() => {
  console.log('hello', 'world')
}

Toinen nappi määritellään seuraavasti:

<button onClick={hello('react')}>button</button>

Tapahtumankäsittelijän määrittelevä funktiokutsu hello('react') palauttaa

() => {
  console.log('hello', 'react')
}

eli molemmat napit saavat omat, yksilölliset tapahtumankäsittelijänsä.

Funktioita palauttavia funktioita voikin hyödyntää määrittelemään geneeristä toiminnallisuutta, jota voi tarkentaa parametrien avulla. Tapahtumankäsittelijöitä luovan funktion hello voikin ajatella olevan eräänlainen tehdas, jota voi pyytää valmistamaan sopivia tervehtimiseen sopivia tapahtumankäsittelijäfunktioita.

Käyttämämme määrittelytapa

const hello = (who) => {
  const handler = () => {
    console.log('hello', who)
  }

  return handler
}

on hieman verboosi. Eliminoidaan apumuuttuja ja määritellään palautettava funktio suoraan returnin yhteydessä:

const hello = (who) => {
  return () => {
    console.log('hello', who)
  }
}

Koska funktio hello sisältää ainoastaan yhden komennon, returnin, voimme käyttää aaltosulutonta muotoa

const hello = (who) =>
  () => {
    console.log('hello', who)
  }

ja tuoda vielä "kaikki nuolet" samalle riville:

const hello = (who) => () => {
  console.log('hello', who)
}

Voimme käyttää samaa kikkaa myös muodostamaan tapahtumankäsittelijöitä, jotka asettavat komponentin tilan halutuksi. Muutetaan koodi muotoon:

const App = (props) => {
  const [value, setValue] = useState(10)

  const setToValue = (newValue) => () => {
    console.log('value now', newValue) // tulostetaan uusi arvo konsoliin
    setValue(newValue)
  }

  return (
    <div>
      {value}
      <button onClick={setToValue(1000)}>thousand</button>
      <button onClick={setToValue(0)}>reset</button>
      <button onClick={setToValue(value + 1)}>increment</button>
    </div>
  )
}

Kun komponentti renderöidään, ja tehdään nappia thousand

<button onClick={setToValue(1000)}>thousand</button>

tulee tapahtumankäsittelijäksi funktiokutsun setToValue(1000) paluuarvo eli seuraava funktio:

() => {
  console.log('value now', 1000)
  setValue(1000)
}

Kasvatusnapin generoima rivi on seuraava:

<button onClick={setToValue(value + 1)}>increment</button>

Tapahtumankäsittelijän muodostaa funktiokutsu setToValue(value + 1), joka saa parametrikseen tilan tallettavan muuttujan value nykyisen arvon kasvatettuna yhdellä. Jos value olisi 10, tulisi tapahtumankäsittelijäksi funktio:

() => {
  console.log('value now', 11)
  setValue(11)
}

Funktioita palauttavia funktioita ei tässäkään tapauksessa olisi ollut pakko käyttää. Muutetaan tilan päivittämisestä huolehtiva funktio setToValue normaaliksi funktioksi:

const App = (props) => {
  const [value, setValue] = useState(10)

  const setToValue = (newValue) => {
    console.log('value now', newValue)
    setValue(newValue)
  }

  return (
    <div>
      {value}
      <button onClick={() => setToValue(1000)}>
        thousand
      </button>
      <button onClick={() => setToValue(0)}>
        reset
      </button>
      <button onClick={() => setToValue(value + 1)}>
        increment
      </button>
    </div>
  )
}

Voimme nyt määritellä tapahtumankäsittelijän funktioksi, joka kutsuu funktiota setToValue sopivalla parametrilla. Esim. nollaamisen tapahtumankäsittelijä voidaan kirjoittaa muotoon:

<button onClick={() => setToValue(0)}>reset</button>

On makuasia käyttääkö tapahtumankäsittelijöinä funktioita palauttavia funktioita vai nuolifunktioita. Tällä kurssilla emme kuitenkaan selvyyden vuoksi käytä funktioita palauttavia funktioita.

Tapahtumankäsittelijän vieminen alikomponenttiin

Eriytetään vielä painike omaksi komponentikseen:

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

Komponentti saa siis propsina handleClick tapahtumankäsittelijän ja propsina text merkkijonon, jonka se renderöi painikkeen tekstiksi. Komponenttia käytetään seuraavasti:

const App = (props) => {
  // ...
  return (
    <div>
      {value}
      <Button handleClick={setToValue(1000)} text="thousand" />      <Button handleClick={setToValue(0)} text="reset" />      <Button handleClick={setToValue(value + 1)} text="increment" />    </div>
  )
}

Komponentin Button käyttö on helppoa, mutta on toki pidettävä huolta siitä, että komponentille annettavat propsit on nimetty niin kuin komponentti olettaa:

fullstack content

Älä määrittele komponenttia komponentin sisällä

Eriytetään vielä sovelluksestamme luvun näyttäminen omaan komponenttiin Display.

Määritellään uusi komponentti App-komponentin sisällä:

// tämä on oikea paikka määritellä komponentti!
const Button = (props) => (
  <button onClick={props.handleClick}>
    {props.text}
  </button>
)

const App = (props) => {
  const [value, setValue] = useState(10)

  const setToValue = newValue => {
    console.log('value now', newValue)
    setValue(newValue)
  }

  // älä määrittele komponenttia täällä!
  const Display = props => <div>{props.value}</div>
  return (
    <div>
      <Display value={value} />
      <Button handleClick={() => setToValue(1000)} text="thousand" />
      <Button handleClick={() => setToValue(0)} text="reset" />
      <Button handleClick={() => setToValue(value + 1)} text="increment" />
    </div>
  )
}

Kaikki näyttää toimivan, mutta älä koskaan määrittele komponenttia toisen komponentin sisällä. Tapa on hyödytön ja johtaa usein ongelmiin. Suurimmat ongelmat johtuvat siitä, että toisen komponentin sisällä määritelty komponentti on Reactin näkökulmasta jokaisen renderöinnin yhteydessä aina uusi komponentti. Tämä tekee komponentin optimoinnista Reactille mahdotonta.

Siirretäänkin komponentin Display määrittely oikeaan paikkaan eli komponentin App määrittelevän funktion ulkopuolelle:

const Display = props => <div>{props.value}</div>

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

const App = () => {
  const [value, setValue] = useState(10)

  const setToValue = newValue => {
    console.log('value now', newValue)
    setValue(newValue)
  }

  return (
    <div>
      <Display value={value} />
      <Button handleClick={() => setToValue(1000)} text="thousand" />
      <Button handleClick={() => setToValue(0)} text="reset" />
      <Button handleClick={() => setToValue(value + 1)} text="increment" />
    </div>
  )
}

Hyödyllistä materiaalia

Internetissä on todella paljon Reactiin liittyvää materiaalia. Välillä ongelman muodostaa kuitenkin se, että käytämme kurssilla uutta Reactia, ja edelleen aika suuri osa Internetistä löytyvästä materiaalista on meidän kannaltamme vanhentunutta ja käyttää Class-syntaksia komponenttien määrittelyyn.

Linkkejä:

  • Reactin dokumentaatio kannattaa ehdottomasti käydä jossain vaiheessa läpi, ei välttämättä kaikkea nyt, osa on ajankohtaista vasta kurssin myöhemmissä osissa ja kaikki Class-komponentteihin liittyvä on kurssin kannalta epärelevanttia.
  • Reactin sivuilla oleva tutoriaali sen sijaan on aika huono.
  • Egghead.io:n kursseista Start learning React on laadukas, ja hieman uudempi The Beginner's guide to React on myös kohtuullisen hyvä; molemmat sisältävät myös asioita, jotka tulevat tällä kurssilla vasta myöhemmissä osissa. Molemmissa on toki se ongelma, että ne käyttävät Class-komponentteja.

Websovelluskehittäjän vala

Ohjelmointi on hankalaa, ja sen takia lupaan hyödyntää kaikkia ohjelmointia helpottavia keinoja:

  • pidän selaimen konsolin koko ajan auki
  • etenen pienin askelin
  • käytän koodissa runsaasti console.log-komentoja varmistamaan sen, että varmasti ymmärrän jokaisen kirjoittamani koodirivin, sekä etsiessäni koodista mahdollisia bugin aiheuttajia
  • jos koodini ei toimi, en kirjoita enää yhtään lisää koodia, vaan alan poistamaan toiminnan rikkoneita rivejä tai palaan suosiolla tilanteeseen, missä koodi vielä toimi
  • kun kysyn apua kurssin Discord- tai Telegram-kanavalla, tai muualla internetissä, muotoilen kysymyksen järkevästi, esim. täällä esiteltyyn tapaan