Siirry sisältöön

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 ovat 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. Sovelluksemme näyttäisikin toimivan myös silloin, kun lisäys hoidetaan muuttamalla allClicks-tilaa pushaamalla siihen alkio ja päivittämällä sitten 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ä nappien painallusten 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 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>

Komponentti History renderöi siis eri React-elementit riippuen 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 plussaa käyttäen merkkijonoon olion, tuloksena on suhteellisen hyödytön tulostusmuoto

props value is [Object object]

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

Konsoliin tulostus ei ole suinkaan ainoa keino debuggaamiseen. Voit pysäyttää koodin suorituksen 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 silmukassa (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 saadaan nollattua tilan tallettava muuttuja value.

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, mikä käynnistää jälleen uuden uudelleenrenderöinnin, ja näin joudutaan päättymättömään rekursioon.

Jos haluamme suorittaa tietyn funktiokutsun 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 painalluksen 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 käytettäviä 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 komponenttiinsa 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.

Webohjelmoijan vala

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

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

Kielimallien hyödyntäminen

Suuret kielimallit, kuten ChatGPT, Claude ja GitHub Copilot ovat osoittautuneet erittäin hyödyllisiksi ohjelmistokehityksessä.

Itse käytän pääasiassa Copilottia, joka integroituu saumattomasti VS Codeen pluginin ansiosta.

Copilot on hyödyllinen monenlaisissa skenaarioissa. Copilotia voi pyytää generoimaan koodia avoinna olevaan tiedostoon kuvailemalla halutun toiminnallisuuden teksinä:

fullstack content

Jos koodi vaikuttaa hyvältä, Copilot lisää sen tiedostoon:

fullstack content

Esimerkkimme tapauksessa Copilot loi ainoastaan painikkeen, tapahtumankäsittelijä handleResetClick on määrittelemättä.

Myös tapahtumankäsittelijän saa generoitua. Funktion ensimmäisen rivin kirjoittamalla Copilot tarjoaa generoimaansa toiminnallisuutta:

fullstack content

Copilotin chat-ikkunassa on mahdollista kysyä selitystä maalatun koodialueen toiminnalle:

fullstack content

Copilot on hyödyllinen myös virhetilanteissa, kopioimalla virheviesti Copilotin chatiin, tulee selitys ongelmasta ja korjausehdotus:

fullstack content

Copilotin chat mahdollistaa myös suurempien kokonaisuuksien luomisen

fullstack content

Copilotin ja muiden kielimallien antamien vihjeiden hyödyllisyyden aste vaihtelee. Kielimallien ehkä suurin ongelma on hallusinointi, ne generoivat välillä täysin vakuuttavan näköisiä vastauksia mitkä kuitenkin ovat täysin päättömiä. Ohjelmoidessa toki hallusinoitu koodi jää usein nopeasti kiinni jos koodi ei toimi. Ongelmallisempia tilanteita ovat ne, missä kielimallin generoima koodi näyttää toimivan, mutta se sisältää vaikeammin havaittavia bugeja tai esim. tietoturvahaavoittuvuuksia.

Toinen ongelma kielimallien soveltamisessa ohjelmistokehitykseen on se, että kielimallien on vaikea "hahmottaa" isompia projekteja, ja esim. generoida toiminnallisuutta, joka edellyttäisi muutoksia useisiin tiedostoihin. Kielimallit eivät myöskään nykyisellään osaa yleistää koodia, eli jos koodissa on esim. olemassaolevia funktioita tai komponentteja, joita kielimalli pystyisi pienin muutoksin hyödyntämään siltä pyydettyyn toiminnallisuuteen, ei kielimalli tähän taivu. Tästä voi olla seurauksena se, että koodikanta rapistuu sillä kielimallit generoivat koodiin paljon toisteisuutta, ks. lisää esim. täältä.

Kielimalleja käytettäessä vastuu siis jää aina ohjelmoijalle.

Kielimallien nopea kehitys asettaa ohjelmointia opiskelevan haastavaan asemaan: kannattaako ja tarvitseeko enää ylipäätään opetella ohjelmointia vanhan liiton tyyliin, kun lähes kaiken saa kielimalleilta valmiina?

Tässä kohtaa kannattaa muistaa C-kielen kehittäjän Brian Kerninghamin vanha viisaus:

fullstack content

Eli koska ongelmien selvittely on kaksi kertaa vaikeampaa kuin ohjelmointi, ei kannata ohjelmoida sellaista koodia minkä vain juuri ja juuri itse ymmärtää. Miten debuggaus mahtaakaan onnistua tilanteessa missä ohjelmointi on ulkoistettu kielimallille ja ohjelmistokehittäjä ei ymmärrä debugattavaa koodia ollenkaan?

Toistaiseksi kielimallien ja tekoälyn kehitys on vielä siinä vaiheessa, että ne eivät ole itseriittoisia, ja vaikeimmat ongelmat jäävät ihmisten selvitettäväksi. Tämän takia aloittelevienkin ohjelmistokehittäjien on kaiken varalta opeteltava ohjelmoimaan todella hyvin. Voi olla, että kielimallien kehityksestä huolimatta tarvitaankin entistä syvällisempää osaamista. Tekoäly tekee ne helpot asiat, mutta ihmistä tarvitaan kaikkein kiperimpien tekoälyn aiheuttamien sotkujen selvittelyyn. GitHub Copilot onkin varsin hyvin nimetty tuote, kyseessä on Copilot eli lentoperämies/nainen. Ohjelmoija on edelleen kapteeni ja kantaa lopullisen vastuun.

Voikin olla oman etusi mukaista, että kytket oletusarvoisesti Copilotin pois päältä kun teet tätä kurssia ja turvadut siihen ainoastaan todellisella hädän hetkellä.