Creative Commons -lisenssi

osan 2 oppimistavoitteet

  • Web-sovellusten toiminnan perusteet
    • lisää CSS:ää
    • selain suoritusympäristönä
    • selaimen ja palvelimen välisen kommunikoinnin perusteet
  • React
    • taulukossa olevan datan renderöinti
    • komponenttien määrittely moduuleissa
    • kontrolloidut lomakkeet
    • taulukossa olevan renderöitävän datan filtteröinti
    • komponentin ‘lifecycle’-metodit
    • riippuvuuksien lisääminen
    • kommunikointi palvelimen kanssa
    • tyylien lisäämisen perusteita
  • Javascript
    • template string
    • olioiden käsittelyä: property shorthand notation, object assign
    • ES6 moduulien perusteita
    • promiset
    • taulukoiden käsittelyä: map, filter, find

Muutama huomio

setState on asynkrooninen

Muutamat ovat jo törmänneet siihen, että React kutsuu funktiota setState asynkroonisesti, eli jos meillä on seuraava koodi

console.log(this.state.counter)
this.setState({counter: 55})
console.log(this.state.counter)

tulostavat molemmat rivit saman arvon, sillä Reactin tila ei saa uutta arvoa heti komennon this.setState jälkeen, vaan vasta sitten kun suorituksen alla oleva metodi on suoritettu loppuun ja setState on saanut mahdollisuuden suoritukselle.

console.log

Mikä erottaa kokeneen ja kokemattoman Javascript-ohjelmoijan? Kokeneet käyttävät 10-100 kertaa enemmän console.logia.

Paradoksaalista kyllä tämä näyttää olevan tilanne, vaikka kokematon ohjelmoija oikeastaan tarvitsisi console.logia (tai jotain muita debuggaustapoja) huomattavissa määrin kokenutta enemmän.

Eli kun joku ei toimi, älä arvaile vaan logaa tai käytä jotain muita debuggauskeinoja.

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

console.log('propsin arvo on' + props)

erottele tulostettavat asiat pilkulla:

console.log('propsin arvo on', props)

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

propsin arvo on [Object object]

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

Tapahtumankäsittely revisited

Pajan ja telegrammin havaintojen perusteella tapahtumankäsittely on osoittautunut haastavaksi.

Osassa 1 on nyt uusi luku tapahtumäakasittely revisited joka käy aihepiiriä läpi.

Visual Studio Coden snippetit

VS Codeen on helppo määritellä “snippettejä”, eli Netbeansin “sout”:in tapaisia oikoteitä yleisesti käytettyjen koodinpätkien generointiin. Ohje snippetien luomiseen täällä

VS Code -plugineina löytyy myös hyödyllisiä valmiiksi määriteltyjä snippetejä, esim. tämä

Pakolliset tehtävät, tehtävien vaikutus arvosanaan

Joissain yhteyksissä on ollut pientä epäselvyyttä mitä tiettyjen tehtävien pakollisuus tarkoittaa, ja mikä eipakollisten tehtävien rooli on. Tarkennusta asiaan tehtävien sivun alussa

linkkivinkit

Kurssisivun alaisuudessa on nyt osio, jonne kaikkien toivotaan lisäilevän hyödylliseksi kokemiaan linkkejä. Lisääminen onnistuu tekemällä pull request tänne

Kun lisäät linkin, laita linkin yhteyteen pieni kuvaus mitä linkin takaa löytyy.

Taulukkojen käyttö Javascriptissä

Tästä osasta lähtien käytämme runsaasti Javascriptin taulukkojen funktionaalisia käsittelymetodeja, kuten find, filter ja map. Periaate niissä on täysin sama kuin Java 8:sta tutuissa streameissa, joita on käytetty jo runsaan vuoden ajan laitoksen Ohjelmoinnin perusteissa ja jatkokurssilla.

Jos taulukon funktionaalinen käsittely tuntuu vielä vieraalta, kannattaa katsoa Youtubessa olevasta videosarjasarjasta Functional Programming in JavaScript ainakin kolme ensimmäistä osaa

Kokoelmien renderöiminen

Tehdään nyt Reactilla ensimmäisen osan alussa käytettyä esimerkkisovelluksen Single page app -versiota vastaavan sovelluksen ‘frontend’ eli selainpuolen sovelluslogiikka.

Aloitetaan seuraavasta:

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

const notes = [
  {
    id: 1,
    content: 'HTML on helppoa',
    date: '2017-12-10T17:30:31.098Z',
    important: true
  },
  {
    id: 2,
    content: 'Selain pystyy suorittamaan vain javascriptiä',
    date: '2017-12-10T18:39:34.091Z',
    important: false
  },
  {
    id: 3,
    content: 'HTTP-protokollan tärkeimmät metodit ovat GET ja POST',
    date: '2017-12-10T19:20:14.298Z',
    important: true
  }
]

const App = (props) => {
  const { notes } = props;

  return (
    <div>
      <h1>Muistiinpanot</h1>
      <ul>
        <li>{notes[0].content}</li>
        <li>{notes[1].content}</li>
        <li>{notes[2].content}</li>
      </ul>
    </div>
  )
}

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

Jokaiseen muistiinpanoon on merkitty tekstuaalisen sisällön ja aikaleiman lisäksi myös boolean-arvo, joka kertoo onko muistiinpano luokiteltu tärkeäksi, sekä yksikäsitteinen tunniste id.

Koodin toiminta perustuu siihen, että taulukossa on tasan kolme muistiinpanoa, yksittäiset muistiinpanot renderöidään ‘kovakoodatusti’ viittaamalla suoraan taulukossa oleviin olioihin:

<li>{note[1].content}</li>

Tämä ei tietenkään ole järkevää. Ratkaisu voidaan yleistää generoimalla taulukon perusteella joukko React-elementtejä käyttäen map-funktiota:

notes.map(note => <li>{note.content}</li>)

nyt tuloksena on taulukko, jonka sisältö on joukko li-elementtejä

[
  '<li>HTML on helppoa</li>',
  '<li>Selain pystyy suorittamaan vain javascriptiä</li>',
  '<li>HTTP-protokollan tärkeimmät metodit ovat GET ja POST</li>'
]

jotka voidaan sijoittaa ul-tagien sisälle:

const App = (props) => {
  const { notes } = props;

  return (
    <div>
      <h1>Muistiinpanot</h1>
      <ul>
        {notes.map(note => <li>{note.content}</li>)}
      </ul>
    </div>
  )
}

Koska li-tagit generoiva koodi on Javascriptia, tulee se sijoittaa JSX-templatessa aaltosulkujen sisälle kaiken muun Javascript-koodin tapaan.

Usein vastaavissa tilanteissa dynaamisesti generoitava sisältö eristetään omaan metodiin, jota JSX-template kutsuu:

const App = (props) => {
  const { notes } = props;
  const rivit = () => notes.map(note => <li>{note.content}</li>)

  return (
    <div>
      <h1>Muistiinpanot</h1>
      <ul>
        {rivit()}
      </ul>
    </div>
  )
}

Vaikka sovellus näyttää toimivan, tulee konsoliin ikävä varoitus

Kuten virheilmoituksen linkittämä sivu kertoo, tulee taulukossa olevilla, eli käytännössä map-metodilla muodostetuilla elementeillä olla uniikki avain, eli kenttä nimeltään key.

Lisätään avaimet:

const App = (props) => {
  const { notes } = props;
  const rivit = () => notes.map(note => <li key={note.id}>{note.content}</li>)

  return (
    <div>
      <h1>Muistiinpanot</h1>
      <ul>
        {rivit()}
      </ul>
    </div>
  )
}

Virheilmoitus katoaa.

React käyttää taulukossa olevien elementtien key-kenttiä päätellessään miten sen tulee päivittää komponentin generoimaa näkymää silloin kun komponentti uudelleenrenderöidään. Lisää aiheesta täällä.

Map

Taulukoiden metodin map toiminnan sisäistäminen on jatkon kannalta äärimmäisen tärkeää.

Sovellus siis sisältää taulukon notes

const notes = [
  {
    id: 1,
    content: 'HTML on helppoa',
    date: '2017-12-10T17:30:31.098Z',
    important: true
  },
  {
    id: 2,
    content: 'Selain pystyy suorittamaan vain javascriptiä',
    date: '2017-12-10T18:39:34.091Z',
    important: false
  },
  {
    id: 3,
    content: 'HTTP-protokollan tärkeimmät metodit ovat GET ja POST',
    date: '2017-12-10T19:20:14.298Z',
    important: true
  }
]

Pysähdytään hetkeksi tarkastelemaan miten map toimii.

Jos esim. tiedoston loppuun lisätään seuraava koodi

const result = notes.map(note => note.id)
console.log(result)

tulostuu konsoliin [1, 2, 3] eli map muodostaa uuden taulukon, jonka jokainen alkio on saatu alkuperäisen taulukon notes alkioista mappaamalla komennon parametrina olevan funktion avulla.

Funktio on

note => note.id

eli kompaktissa muodossa kirjoitettu nuolifunktio, joka on täydelliseltä kirjoitustavaltaan seuraava

(note) => {
  return note.id
}

eli funktio saa parametrikseen muistiinpano-olion ja palauttaa sen kentän id arvon.

Muuttamalla komento muotoon

const result = notes.map(note => note.content)

tuloksena on taulukko, joka koostuu muistiinpanojen sisällöistä.

Tämä on jo lähellä käyttämäämme React-koodia:

notes.map(note => <li key={note.id}>{note.content}</li>)

joka muodostaa jokaista muistiinpano-olioa vastaavan li-tagin, jonka sisään tulee muistiinpanon sisältö.

Koska metodin map parametrina olevan funktion

note => <li key={note.id}>{note.content}</li>

käyttötarkoitus on näkymäelementtien muodostaminen, tulee muuttujan arvo renderöidä aaltosulkeiden sisällä. Kokeile mitä koodi tekee, jos poistat aaltosulkeet.

Aaltosulkeiden käyttö tulee varmaan aiheuttamaan alussa pientä päänvaivaa, mutta totut niihin pian. Reactin antama visuaalinen feedback on välitön.

Tarkastellaan vielä erästä bugien lähdettä. Lisää koodiin seuraava

const result = notes.map(note => {note.content} )
console.log(result)

Tulostuu

[undefined, undefined, undefined]

Missä on vika? Koodihan on ihan sama kun äsken toiminut koodi. Paitsi ei ihan. Metodin map parametrina on nyt seuraava funktio

note => {
  note.content
}

Koska funktio koostuu nyt koodilohkosta on funktion paluuarvo määrittelemätön eli undefined. Nuolifunktiot siis palauttavat ainoan komentonsa arvon, ainoastaan jos nuolifunktio on määritelty kompaktissa muodossaan, ilman koodilohkoa:

note => note.content

huomaa, että ‘oneliner’-nuolifunktioissa kaikkea ei tarvitse eikä aina kannatakaan kirjoittaa samalle riville.

Parempi muotoilu ohjelmamme muistiinpanorivit tuottavalle apufunktiolle saattaakin olla seuraava useille riveille jaoteltu versio:

const rivit = () => notes.map(note =>
  <li key={note.id}>
    {note.content}
  </li>
)

Kyse on kuitenkin edelleen yhden komennon sisältävästä nuolifunktiosta, komento vain sattuu olemaan hieman monimutkaisempi.

Antipattern: taulukon indeksit avaimina

Olisimme saaneet konsolissa olevan varoituksen katoamaan myös käyttämällä avaimina taulukon indeksejä. Indeksit selviävät käyttämällä map-metodissa myös toista parametria:

notes.map((note, i) => ...)

näin kutsuttaessa i saa arvokseen sen paikan indeksin taulukossa, missä note sijaitsee.

Eli virheetön tapa määritellä rivien generointi on

const rivit = () => notes.map((note, i) => <li key={i}>{note.content}</li>)

Tämä ei kuitenkaan ole suositeltavaa ja voi näennäisestä toimimisestaan aiheuttaa joissakin tilanteissa pahoja ongelmia. Lue lisää esim. täältä.

Refaktorointia - moduulit

Siistitään koodia hiukan. Koska olemme kiinnostuneita ainoastaan propsien kentästä notes, otetaan se vastaan suoraan destrukturointia hyödyntäen:

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

  return (
    <div>
      <h1>Muistiinpanot</h1>
      <ul>
        {rivit()}
      </ul>
    </div>
  )
}

Erotetaan yksittäisen muistiinpanon esittäminen oman komponenttinsa Note vastuulle:

const Note = ({ note }) => {
  return (
    <li>{note.content}</li>
  )
}

const App = ({ notes }) => {
  return (
    <div>
      <h1>Muistiinpanot</h1>
      <ul>
        {notes.map(note=><Note key={note.id} note={note}/>)}
      </ul>
    </div>
  )
}

Huomaa, että key-attribuutti täytyy nyt määritellä Note-komponenteille, eikä li-tageille kuten ennen muutosta.

Koko React-sovellus on mahdollista määritellä samassa tiedostossa, mutta se ei luonnollisesti ole järkevää. Usein käytäntönä on määritellä yksittäiset komponentit omassa tiedostossaan ES6-moduuleina.

Koodissamme on käytetty koko ajan moduuleja. Tiedoston ensimmäiset rivit

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

importtaavat eli ottavat käyttöönsä kaksi moduulia. Moduuli react sijoitetaan muuttujaan React ja react-dom muuttujaan ReactDOM.

Siirretään nyt komponentti Note omaan moduuliinsa.

Pienissä sovelluksissa komponentit sijoitetaan yleensä src-hakemiston alle sijoitettavaan hakemistoon components. Konventiona on nimetä tiedosto komponentin mukaan, eli tehdään hakemisto components ja sinne tiedosto Note.js jonka sisältö on seuraava:

import React from 'react'

const Note = ({ note }) => {
  return (
    <li>{note.content}</li>
  )
}

export default Note

Koska kyseessä on React-komponentti, tulee React importata komponentissa.

Moduulin viimeisenä rivinä eksportataan määritelty komponentti, eli muuttuja Note.

Nyt komponenttia käyttävä tiedosto index.js voi importata moduulin:

import React from 'react'
import ReactDOM from 'react-dom'
import Note from './components/Note'

Moduulin eksporttaama komponentti on nyt käytettävissä muuttujassa Note täysin samalla tavalla kuin aiemmin.

Huomaa, että itse määriteltyä komponenttia importatessa komponentin sijainti tulee ilmaista suhteessa importtaavaan tiedostoon:

'./components/Note'

Piste alussa viittaa nykyiseen hakemistoon, eli kyseessä on nykyisen hakemiston alihakemisto components ja sen sisällä tiedosto Note.js. Tiedoston päätteen voi jättää pois.

Koska myös App on komponentti, eristetään sekin omaan moduuliinsa. Koska kyseessä on sovelluksen juurikomponentti, sijoitetaan se suoraan hakemistoon src. Tiedoston sisältö on seuraava:

import React from 'react'
import Note from './components/Note'

const App = ({ notes }) => {
  return (
    <div>
      <h1>Muistiinpanot</h1>
      <ul>
        {notes.map(note => <Note key={note.id} note={note} />)}
      </ul>
    </div>
  )
}

export default App

Tiedoston index.js sisällöksi jää:

import React from 'react'
import ReactDOM from 'react-dom'
import App from './App'

const notes = [
  ...
]

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

Moduuleilla on paljon muutakin käyttöä kuin mahdollistaa komponenttien määritteleminen omissa tiedostoissaan, palaamme moduuleihin tarkemmin myöhemmin kurssilla.

Sovelluksen tämänhetkinen koodi on kokonaisuudessaan githubissa

Huomaa, että repositorion master-haarassa on myöhemmän vaiheen koodi, tämän hetken koodi on tagissa part2-1:

Jos kloonaat projektin itsellesi, suorita komento npm install ennen käynnistämistä eli komentoa npm start.

Tehtäviä kokoelmien renderöinnistä

Tee nyt tehtävät 2.1-2.5

Lomakkeet

Jatketaan sovelluksen laajentamista siten, että se mahdollistaa uusien muistiinpanojen lisäämisen.

Jotta saisimme sivun päivittymään uusien muistiinpanojen lisäyksen yhteydessä, on parasta sijoittaa muistiinpanot komponentin App tilaan. Funktionaalisilla komponenteilla ei ole tilaa, joten muutetaan App luokkaan perustuvaksi komponentiksi:

class App extends React.Component {
  constructor(props) {
    super(props)
    this.state = {
      notes: props.notes
    }
  }

  render() {
    return (
      <div>
        <h1>Muistiinpanot</h1>
        <ul>
          {this.state.notes.map(note => <Note key={note.id} note={note} />)}
        </ul>
      </div>
    )
  }
}

Konstruktori asettaa nyt propseina saatavan notes-taulukon tilaan avaimen notes arvoksi:

constructor(props) {
  super(props)
  this.state = {
    notes: props.notes
  }
}

tila siis näyttää komponentin alustuksen jälkeen seuraavalta:

this.state = {
  notes: [
    {
      id: 1,
      content: 'HTML on helppoa',
      date: '2017-12-10T17:30:31.098Z',
      important: true
    },
    //...
  ]
}

HUOM komponenttien tilan alustaminen propseina välitettyjen arvojen perusteella ei välttämättä ole hyvä tapa, se on monien mukaan jopa antipattern. Jos kuitenkin tutkitaan vähän pintaa syvemmälle, kyseessä on ongelma lähinnä silloin jos propsien arvo voi muuttua. Näin ei ohjelmassamme ole, eli tilan alustaminen propsien perusteella on hyväksyttävää.

Lisätään sitten lomake uuden muistiinpanon lisäämistä varten:

class App extends React.Component {
  constructor(props) {
    super(props)
    this.state = {
      notes: props.notes
    }
  }

  addNote = (event) => {
    event.preventDefault()
    console.log('nappia painettu')
  }

  render() {
    return (
      <div>
        <h1>Muistiinpanot</h1>
        <ul>
          {this.state.notes.map(note => <Note key={note.id} note={note} />)}
        </ul>
        <form onSubmit={this.addNote}>
          <input/>
          <button type="submit">tallenna</button>
        </form>
      </div>
    )
  }
}

Lomakkeelle on lisätty myös tapahtumankäsittelijäksi metodi addNote reagoimaan sen “lähettämiseen”, eli napin painamiseen.

Tapahtumankäsittelijä on osasta 1 tuttuun tapaan määritelty seuraavasti:

addNote = (event) => {
  event.preventDefault()
  console.log('nappia painettu')
  console.log(event.target)
}

Parametrin event arvona on metodin kutsun aiheuttama tapahtuma.

Tapahtumankäsittelijä kutsuu heti tapahtuman metodia event.preventDefault() jolla se estää lomakkeen lähetyksen oletusarvoisen toiminnan, joka aiheuttaisi mm. sivun uudelleenlatautumisen.

Tapahtuman kohde, eli event.target on tulostettu konsoliin

Kohteena on siis komponentin määrittelemä lomake.

Miten pääsemme käsiksi lomakkeen input-komponenttiin syötettyyn dataan?

Tapoja on useampia, tutustumme ensin ns. kontrolloituina komponentteina toteutettuihin lomakkeisiin.

Lisätään komponentin App tilaan kenttä newNote lomakkeen syötettä varten:

class App extends React.Component {
  constructor(props) {
    super(props)
    this.state = {
      notes: props.notes,
      newNote: 'uusi muistiinpano...'
    }
  }
  // ...
}

Määritellään tilaan lisätty kenttä input-komponentin attribuutin value arvoksi:

<form onSubmit={this.addNote}>
  <input value={this.state.newNote} />
  <button type="submit">tallenna</button>
</form>

Tilaan määritelty “placeholder”-teksti uusi muistiinpano… ilmestyy syötekomponenttiin, tekstiä ei kuitenkaan voi muuttaa. Konsoliin tuleekin ikävä varoitus joka kertoo mistä on kyse

Koska määrittelimme syötekomponentille value-attribuutiksi komponentin App tilassa olevan kentän, alkaa App kontrolloimaan syötekomponentin toimintaa.

Jotta kontrolloidun syötekomponentin editoiminen olisi mahdollista, täytyy sille rekisteröidä tapahtumankäsittelijä, joka synkronoi syötekenttään tehdyt muutokset komponentin App tilaan:

class App extends React.Component {
  // ...

  handleNoteChange = (event) => {
    console.log(event.target.value)
    this.setState({ newNote: event.target.value })
  }

  render() {
    return (
      <div>
        <h1>Muistiinpanot</h1>
        <ul>
          {this.state.notes.map(note => <Note key={note.id} note={note} />)}
        </ul>
        <form onSubmit={this.addNote}>
          <input
            value={this.state.newNote}
            onChange={this.handleNoteChange}
          />
          <button type="submit">tallenna</button>
        </form>
      </div>
    )
  }
}

Lomakkeen input-komponentille on nyt rekisteröity tapahtumankäsittelijä tilanteeseen onChange:

<input
  value={this.state.newNote}
  onChange={this.handleNoteChange}
/>

Tapahtumankäsittelijää kutsutaan aina kun syötekomponentissa tapahtuu jotain. Tapahtumankäsittelijämetodi saa parametriksi tapahtumaolion event

handleNoteChange = (event) => {
  console.log(event.target.value)
  this.setState({ newNote: event.target.value })
}

Tapahtumaolion kenttä target vastaa nyt kontrolloitua input-kenttää ja event.target.value viittaa inputin syötekentän arvoon.

Huomaa, että toisin kuin tapahtuman onSubmit käsittelijässä, nyt oletusarvoisen toiminnan estävää metodikutusua event.preventDefault() ei tarvita sillä syötekentän muutoksella ei ole oletusarvoista toimintaa toisin kuin lomakkeen lähettämisellä.

Voit seurata konsolista miten tapahtumankäsittelijää kutsutaan:

Muistithan jo asentaa React devtoolsin? Devtoolsista näet, miten tila muuttuu syötekenttään kirjoitettaessa:

Nyt komponentin App tilan kenttä newNote heijastaa koko ajan syötekentän arvoa, joten voimme viimeistellä uuden muistiinpanon lisäämisestä huolehtivan metodin addNote:

addNote = (event) => {
  event.preventDefault()
  const noteObject = {
    content: this.state.newNote,
    date: new Date().toISOString(),
    important: Math.random() > 0.5,
    id: this.state.notes.length + 1
  }

  const notes = this.state.notes.concat(noteObject)

  this.setState({
    notes: notes,
    newNote: ''
  })
}

Ensin luodaan uutta muistiinpanoa vastaava olio noteObject, jonka sisältökentän arvo saadaan komponentin tilasta this.state.newNote. Yksikäsitteinen tunnus eli id generoidaan kaikkien muistiinpanojen lukumäärän perusteella. Koska muistiinpanoja ei poisteta, menetelmä toimii sovelluksessamme. Komennon Math.random() avulla muistiinpanosta tulee 50% todennäköisyydellä tärkeä.

Uusi muistiinpano lisätään vanhojen joukkoon oikeaoppisesti käyttämällä osasta 1 tuttua taulukon metodia concat:

const notes = this.state.notes.concat(noteObject)

Metodi ei muuta alkuperäistä taulukkoa this.state.notes vaan luo uuden taulukon, joka sisältää myös lisättävän alkion. Tämä on tärkeää, sillä Reactin tilaa ei saa muuttaa suoraan!

Lopussa komponentin tila päivitetään uusilla muistiinpanoilla ja tyhjentämällä syötekomponentin arvoa kontrolloiva kenttä:

this.setState({
  notes: notes,
  newNote: ''
})

Kehittyneempi tapa olioliteraalien kirjoittamiseen

Voimme muuttaa tilan päivittämän koodin

this.setState({
  notes: notes,
  newNote: ''
})

muotoon

this.setState({
  notes,
  newNote: ''
})

Tämä johtuu siitä, että ES6:n myötä (ks. kohta property definitions) Javascriptiin on tullut uusi ominaisuus, joka mahdollistaa hieman tiiviimmän tavan muuttujien avulla tapahtuvaan olioiden määrittelyyn.

Tarkastellaan tilannetta, jossa meillä on muuttujissa arvoja

const name = 'Leevi'
const age = 0

ja haluamme määritellä näiden perusteella olion, jolla on kentät name ja age.

Vanhassa Javascriptissä olio täytyi määritellä seuraavaan tyyliin

const person = {
  name: name,
  age: age
}

koska muuttujien ja luotavan olio kenttien nimi nyt on sama, riittää ES6:ssa kirjoittaa:

const person = { name, age }

lopputulos molemmilla tavoilla luotuun olioon on täsmälleen sama.

Sovelluksen tämän hetkinen koodi on kokonaisuudessaan githubissa, tagissa part2-2.

Näytettävien elementtien filtteröinti

Tehdään sovellukseen toiminto, joka mahdollistaa ainoastaan tärkeiden muistiinpanojen näyttämisen.

Lisätään komponentin App tilaan tieto siitä näytetäänkö muistiinpanoista kaikki vai ainoastaan tärkeät:

class App extends React.Component {
  constructor(props) {
    super(props)
    this.state = {
      notes: props.notes ,
      newNote: '',
      showAll: true
    }
  }
  // ...
}

Muutetaan metodia render siten, että se tallettaa muuttujaan notesToShow näytettävien muistiinpanojen listan riippuen siitä tuleeko näyttää kaikki vai vain tärkeät:

render() {
  const notesToShow =
    this.state.showAll ?
      this.state.notes :
      this.state.notes.filter(note => note.important === true)

  return (
    <div>
      <h1>Muistiinpanot</h1>
      <ul>
        {notesToShow.map(note => <Note key={note.id} note={note} />)}
      </ul>
      <form onSubmit={this.addNote}>
        <input
          value={this.state.newNote}
          onChange={this.handleNoteChange}
        />
        <button type="submit">tallenna</button>
      </form>
    </div>
  )
}

Muuttujan notesToShow määrittely on melko kompakti

const notesToShow =
  this.state.showAll ?
    this.state.notes :
    this.state.notes.filter(note => note.important === true)

Käytössä on monissa muissakin kielissä oleva ehdollinen operaatio.

Operaatio toimii seuraavasti. Jos meillä on esim:

const tulos = ehto ? val1 : val2

muuttujan tulos arvoksi asetetaan val1:n arvo jos ehto on tosi. Jos ehto ei ole tosi, muuttujan tulos arvoksi tulee val2:n arvo.

Jos ehto this.state.showAll on epätosi, muuttuja notesToShow saa arvokseen vaan ne muistiinpanot, joiden important-kentän arvo on tosi. Filtteröinti tapahtuu taulukon metodilla filter:

this.state.notes.filter(note => note.important === true)

vertailu-operaatio on oikeastaan turha koska note.important on arvoltaan joko true tai false, eli riittää kirjoittaa

this.state.notes.filter(note => note.important)

Tässä käytettiin kuitenkin ensin vertailuoperaattoria, mm. korostamaan erästä tärkeää seikkaa: Javascriptissa arvo1 == arvo2 ei toimi kaikissa tilanteissa loogisesti ja onkin varmempi käyttää aina vertailuissa muotoa arvo1 === arvo2. Enemmän aiheesta täällä.

Filtteröinnin toimivuutta voi jo nyt kokeilla vaihtelemalla sitä, miten tilan kentän showAll alkuarvo määritelään konstruktorissa.

Lisätään sitten toiminnallisuus, mikä mahdollistaa showAll:in tilan muuttamisen sovelluksesta.

Oleelliset muutokset ovat seuraavassa:

class App extends React.Component {
  // ...

  toggleVisible = () => {
    this.setState({showAll: !this.state.showAll})
  }

  render() {
    const notesToShow =
      this.state.showAll ?
        this.state.notes :
        this.state.notes.filter(note => note.important === true)

    const label = this.state.showAll ? 'vain tärkeät' : 'kaikki'

    return (
      <div>
        <h1>Muistiinpanot</h1>

        <div>
          <button onClick={this.toggleVisible}>
            näytä {label}
          </button>
        </div>

        <ul>
          {notesToShow.map(note => <Note key={note.id} note={note} />)}
        </ul>
        <form onSubmit={this.addNote}>
          <input
            value={this.state.newNote}
            onChange={this.handleNoteChange}
          />
          <button type="submit">tallenna</button>
        </form>
      </div>
    )
  }
}

Näkyviä muistiinpanoja (kaikki vai ainoastaan tärkeät) siis kontrolloidaan napin avulla. Napin tapahtumankäsittelijä on yksinkertainen, se muuttaa this.state.showAll:n arvon truesta falseksi ja päinvastoin:

toggleVisible = () => {
  this.setState({showAll: !this.state.showAll})
}

Napin teksti määritellään muuttujaan, jonka arvo määräytyy tilan perusteella:

const label = this.state.showAll ? 'vain tärkeät' : 'kaikki'

Sovelluksen tämän hetkinen koodi on kokonaisuudessaan githubissa, tagissa part2-3.

Tehtäviä lomakkeista

Tee nyt tehtävät 2.6-2.10

Datan haku palvelimelta

Olemme nyt viipyneet tovin keskittyen pelkkään “frontendiin”, eli selainpuolen toiminnallisuuteen. Rupeamme itse toteuttamaan “backendin”, eli palvelinpuolen toiminnallisuutta vasta kurssin kolmannessa osassa, mutta otamme nyt jo askeleen sinne suuntaan tutustumalla siihen miten selaimessa suoritettava koodi kommunikoi backendin kanssa.

Käytetään nyt palvelimena sovelluskehitykseen tarkoitettua JSON Serveriä.

Tee projektin juurihakemistoon tiedosto db.json, jolla on seuraava sisältö:

{
  "notes": [
    {
      "id": 1,
      "content": "HTML on helppoa",
      "date": "2017-12-10T17:30:31.098Z",
      "important": true
    },
    {
      "id": 2,
      "content": "Selain pystyy suorittamaan vain javascriptiä",
      "date": "2017-12-10T18:39:34.091Z",
      "important": false
    },
    {
      "id": 3,
      "content": "HTTP-protokollan tärkeimmät metodit ovat GET ja POST",
      "date": "2017-12-10T19:20:14.298Z",
      "important": true
    }
  ]
}

JSON server on mahdollista asentaa koneelle ns. globaalisti komennolla npm install -g json-server. Globaali asennus edellyttää kuitenkin pääkäyttäjän oikeuksia, eli se ei ole mahdollista laitoksen koneilla tai uusilla fuksiläppäreillä.

Globaali asennus ei kuitenkaan ole tarpeen, voimme käynnistää json-serverin komennon npx avulla:

npx json-server --port=3001 --watch db.json

Oletusarvoisesti json-server käynnistyy porttiin 3000, mutta create-react-app:illa luodut projektit varaavat portin 3000, joten joudumme nyt määrittelemään json-server:ille vaihtoehtoisen portin 3001.

Mennään selaimella osoitteeseen http://localhost:3001/notes. Kuten huomaamme, json-server tarjoaa osoitteessa tiedostoon tallentamamme muistiinpanot JSON-muodossa:

Ideana jatkossa onkin se, että muistiinpanot talletetaan palvelimelle, eli tässä vaiheessa json-server:ille. React-koodi lataa muistiinpanot palvelimelta ja renderöi ne ruudulle. Kun sovellukseen lisätään uusi muistiinpano, React-koodi lähettää sen myös palvelimelle, jotta uudet muistiinpanot jäävät pysyvästi “muistiin”.

json-server tallettaa kaiken datan palvelimella sijaitsevaan tiedostoon db.json. Todellisuudessa data tullaan tallentamaan johonkin tietokantaan. json-server on kuitenkin käyttökelpoinen apuväline, joka mahdollistaa palvelinpuolen toiminnallisuuden käyttämisen kehitysvaiheessa ilman tarvetta itse ohjelmoida mitään.

Tutustumme palvelinpuolen toteuttamisen periaatteisiin tarkemmin kurssin osassa 3.

Selain suoritusympäristönä

Ensimmäisenä tehtävänämme on siis hakea React-sovellukseen jo olemassaolevat mustiinpanot osoitteesta http://localhost:3001/notes.

Osan 0 esimerkkiprojektissa nähtiin jo eräs tapa hakea Javascript-koodista palvelimella olevaa dataa. Esimerkin koodissa data haettiin XMLHttpRequest- eli XHR-olion avulla muodostetulla HTTP-pyynnöllä. Kyseessä on vuonna 1999 lanseerattu tekniikka, jota kaikki web-selaimet ovat jo pitkään tukeneet.

Nykyään XHR:ää ei kuitenkaan kannata käyttää ja selaimet tukevatkin jo laajasti fetch-metodia, joka perustuu XHR:n käyttämän tapahtumapohjaisen mallin sijaan ns. promiseihin.

Muistutuksena edellisestä osasta (oikeastaan tätä tapaa pitää lähinnä muistaa olla käyttämättä ilman painavaa syytä), XHR:llä haettiin dataa seuraavasti

const xhttp = new XMLHttpRequest()

xhttp.onreadystatechange = function () {
  if (this.readyState == 4 && this.status == 200) {
    const data = JSON.parse(this.responseText)
    // käsittele muuttujaan data sijoitettu kyselyn tulos
  }
}

xhttp.open('GET', '/data.json', true)
xhttp.send()

Heti alussa HTTP-pyyntöä vastaavalle xhttp-oliolle rekisteröidään tapahtumankäsittelijä, jota Javascript runtime kutsuu kun xhttp-olion tila muuttuu. Jos tilanmuutos tarkoittaa että pyynnön vastaus on saapunut, käsitellään data halutulla tavalla.

Huomionarvoista on se, että tapahtumankäsittelijän koodi on määritelty jo ennen kun itse pyyntö lähetetään palvelimelle. Tapahtumankäsittelijäfunktio tullaan kuitenkin suorittamaan vasta jossain myöhäisemmässä vaiheessa. Koodin suoritus ei siis etene synkronisesti “ylhäältä alas”, vaan asynkronisesti, Javascript kutsuu sille rekisteröityä tapahtumankäsittelijäfunktiota jossain vaiheessa.

Esim. Java-ohjelmoinnista tuttu synkroninen tapa tehdä kyselyjä etenisi seuraavaan tapaan (huomaa että kyse ei ole oikeasti toimivasta Java-koodista):

HTTPRequest request = new HTTPRequest()

String url = "https://fullstack-exampleapp.herokuapp.com/data.json";
List<Muistiinpano> muistiinpanot = request.get(url);

muistiinpanot.forEach(m => {
  System.out.println(m.content);
})

Javassa koodi etenee nyt rivi riviltä ja koodi pysähtyy odottamaan HTTP-pyynnön, eli komennon request.get(…) valmistumista. Komennon palauttama data, eli muistiinpanot talletetaan muuttujaan ja dataa aletaan käsittelemään halutulla tavalla.

Javascript-enginet eli suoritusympäristöt kuitenkin noudattavat asynkronista mallia, eli periaatteena on se, että kaikki IO-operaatiot (poislukien muutama poikkeus) suoritetaan ei-blokkaavana, eli operaatioiden tulosta ei jäädä odottamaan vaan koodin suoritusta jatketaan heti eteenpäin.

Siinä vaiheessa kun operaatio valmistuu tai tarkemmin sanoen jonain valmistumisen jälkeisenä ajanhetkenä, kutsuu Javascript-engine operaatiolle rekisteröityjä tapahtumankäsittelijöitä.

Nykyisellään Javascript-moottorit ovat yksisäikeisiä eli ne eivät voi suorittaa rinnakkaista koodia. Tämän takia on käytännössä pakko käyttää ei-blokkaavaa mallia IO-operaatioiden suorittamiseen, sillä muuten selain ‘jäätyisi’ siksi aikaa kun esim. palvelimelta haetaan dataa.

Javascript-moottoreiden yksisäikeisyydellä on myös sellainen seuraus, että jos koodin suoritus kestää erittäin pitkään, menee selain jumiin suorituksen ajaksi. Jos lisätään jonnekin kohtaa sovellustamme, esim. konstruktoriin seuraava koodi:

setTimeout(() => {
  console.log('loop..')
  let i = 0
  while(i < 50000000000) {
    i++
  }
  console.log('end')
}, 5000)

Kaikki toimii 5 sekunnin ajan normaalisti. Kun setTimeout:in parametrina määritelty funktio suoritetaan, menee selaimen sivu jumiin pitkän loopin suorituksen ajaksi. Ainakaan Chromessa selaimen tabia ei pysty edes sulkemaan luupin suorituksen aikana.

Eli jotta selain säilyy responsiivisena, eli että se reagoi koko ajan riittävän nopeasti käyttäjän haluamiin toimenpiteisiin, koodin logiikan tulee olla sellainen, että yksittäinen laskenta ei saa kestää liian kauaa.

Aiheesta löytyy paljon lisämateriaalia internetistä, eräs varsin havainnollinen esitys aiheesta Philip Robertsin esitelmä What the heck is the event loop anyway?

Nykyään selaimissa on mahdollisuus suorittaa myös rinnakkaista koodia ns. web workerien avulla. Yksittäisen selainikkunan koodin ns. event loopista huolehtii kuitenkin edelleen vain yksi säie.

npm

Palaamme jälleen asiaan, eli datan hakemiseen palvelimelta.

Voisimme käyttää datan palvelimelta hakemiseen aiemmin mainittua promiseihin perustuvaa funktiota fetch. Fetch on hyvä työkalu, se on standardoitu ja kaikkien modernien selaimien (poislukien IE) tukema.

Käytetään selaimen ja palvelimen väliseen kommunikaatioon kuitenkin axios-kirjastoa, joka toimii samaan tapaan kuin fetch, mutta on hieman mukavampikäyttöinen. Hyvä syy axios:in käytölle on myös se, että pääsemme tutustumaan siihen miten ulkopuolisia kirjastoja eli npm-paketteja liitetään React-projektiin.

Nykyään lähes kaikki Javascript-projektit määritellään node “pakkausmanagerin” eli npm:n avulla. Myös create-react-app:in avulla generoidut projektit ovat npm-muotoisia projekteja. Varma tuntomerkki siitä on projektin juuressa oleva tiedosto package.json:

{
  "name": "osa2",
  "version": "0.1.0",
  "private": true,
  "dependencies": {
    "react": "^16.2.0",
    "react-dom": "^16.2.0",
    "react-scripts": "1.0.17"
  },
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test --env=jsdom",
    "eject": "react-scripts eject"
  }
}

Tässä vaiheessa meitä kiinnostaa osa dependencies, joka määrittelee mitä riippuvuuksia eli ulkoisia kirjastoja projektilla on.

Haluamme nyt käyttöömme axioksen. Voisimme määritellä kirjaston suoraan tiedostoon package.json, mutta on parempi asentaa se komentoriviltä

npm install axios --save

Huomaa, että npm-komennot tulee antaa aina projektin juurihakemistossa, eli siinä minkä sisältä tiedosto package.json löytyy.

Nyt axios on mukana riippuvuuksien joukossa:

{
  "dependencies": {
    "axios": "^0.17.1",
    "react": "^16.2.0",
    "react-dom": "^16.2.0",
    "react-scripts": "1.0.17"
  },
  /*...*/
}

Sen lisäksi, että komento npm install lisäsi axiosin riippuvuuksien joukkoon, se myös latasi kirjaston koodin. Koodi löytyy muiden riippuvuuksien tapaan projektin juuren hakemistosta node_modules, mikä kuten huomata saattaa sisältääkin runsaasti kaikenlaista.

Tehdään toinenkin pieni lisäys. Asennetaan myös json-server projektin riippuvuudeksi komennolla

npm install json-server --save

ja lisätään tiedoston package.json osaan scripts rivi

"server": "json-server -p3001 db.json"

eli muutetaan se muotoon

{
  /*...*/
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test --env=jsdom",
    "eject": "react-scripts eject",
    "server": "json-server -p3001 db.json"
  }
}

Nyt voimme käynnistää (muista sammuttaa aiemmin käynnistämäsi!) json-serverin projektin hakemistosta mukavasti ilman tarvetta parametrien määrittelylle komennolla

npm run server

Tutustumme npm:n tarkemmin kurssin kolmannessa osassa.

Axios ja promiset

Olemme nyt valmiina käyttämään axiosia. Jatkossa oletetaan että json-server on käynnissä portissa 3001.

Kirjaston voi ottaa käyttöön samaan tapaan kuin esim. React otetaan käyttöön, eli sopivalla import-lauseella.

Lisätään seuraava tiedostoon index.js

import axios from 'axios'

const promise = axios.get('http://localhost:3001/notes')
console.log(promise)

const promise2 = axios.get('http://localhost:3001/foobar')
console.log(promise2)

Konsoliin tulostuu seuraavaa

Axiosin metodi get palauttaa promisen.

Mozillan dokumentaatio sanoo promisesta seuraavaa:

A Promise is an object representing the eventual completion or failure of an asynchronous operation.

Promise siis edustaa asynkronista operaatiota. Promise voi olla kolmessa eri tilassa:

  • aluksi promise on pending, eli promisea vastaava asynkroninen operaatio ei ole vielä tapahtunut
  • jos operaatio päättyy onnistuneesti, menee promise tilaan fulfilled, josta joskus käytetään nimitystä resolved
  • kolmas mahdollinen tila on rejected, joka edustaa epäonnistunutta operaatiota

Esimerkkimme ensimmäinen promise on fulfilled, eli vastaa onnistunutta axios.get(‘http://localhost:3001/notes’) pyyntöä. Promiseista toinen taas on rejected, syy selviää konsolista, eli yritettiin tehdä HTTP GET -pyyntöä osoitteeseen, jota ei ole olemassa.

Jos ja kun haluamme tietoon promisea vastaavan operaation tuloksen, tulee promiselle rekisteröidä tapahtumankuuntelija. Tämä tapahtuu metodilla then:

const promise = axios.get('http://localhost:3001/notes')

promise.then(response => {
  console.log(response)
})

Konsoliin tulostuu seuraavaa

Javascriptin suoritusympäristö kutsuu then-metodin avulla rekisteröityä takaisinkutsufunktiota antaen sille parametriksi olion result, joka sisältää kaiken oleellisen HTTP GET -pyynnön vastaukseen liittyvän, eli palautetun datan, statuskoodin ja headerit.

Promise-olioa ei ole yleensä tarvetta tallettaa muuttujaan, ja onkin tapana ketjuttaa metodin then kutsu suoraan axiosin metodin kutsun perään:

axios.get('http://localhost:3001/notes').then(response => {
  const notes = response.data
  console.log(notes)
})

Takaisinkutsufunktio ottaa nyt vastauksen sisällä olevan datan muuttujaan ja tulostaa muistiinpanot konsoliin.

Luettavampi tapa formatoida ketjutettuja metodikutsuja on sijoittaa jokainen kutsu omalle rivilleen:

axios
  .get('http://localhost:3001/notes')
  .then(response => {
    const notes = response.data
    console.log(notes)
  })

näin jo nopea, ruudun vasempaan laitaan kohdistunut vilkaisu kertoo mistä on kyse.

Palvelimen palauttama data on pelkkää tekstiä, käytännössä yksi iso merkkijono. Asian voi todeta, esim. tekemällä HTTP-pyyntö komentoriviltä curl:illa

Axios-kirjasto osaa kuitenkin parsia datan Javascript-taulukoksi, sillä palvelin on kertonut headerin content-type avulla että datan muoto on application/json; charset=utf-8 (ks ylempi kuva).

Voimme vihdoin siirtyä käyttämään sovelluksessamme palvelimelta haettavaa dataa.

Tehdään se aluksi “huonosti”, eli lisätään sovellusta vastaavan komponentin App renderöinti takaisinkutsufunktion sisälle muuttamalla index.js seuraavaan muotoon:

import ReactDOM from 'react-dom'
import React from 'react'
import App from './App'

import axios from 'axios'

axios.get('http://localhost:3001/notes').then(response => {
  const notes = response.data
  ReactDOM.render(
    <App notes={notes} />,
    document.getElementById('root')
  )
})

Joissain tilanteissa tämäkin tapa voisi olla ok, mutta se on hieman ongelmallinen ja päätetäänkin siirtää datan hakeminen komponenttiin App.

Ei ole kuitenkaan ihan selvää, mihin kohtaan komponentin koodia komento axios.get olisi hyvä sijoittaa.

Komponenttien lifecycle-metodit

Reactin luokkien avulla määritellyillä komponenteilla voidaan määritellä joukko lifecycle-metodeita, eli metodeita, joita React kutsuu tietyssä komponentin “elinkaaren” vaiheessa.

Yleinen tapa datan palvelimelta tapahtuvaan hakemiseen on suorittaa se metodissa componentDidMount. React kutsuu metodia sen jälkeen kun konstruktori on suoritettu ja render-metodi on suoritettu ensimmäistä kertaa.

Muutetaan sovellusta nyt seuraavasti.

Poistetaan datan hakeminen tiedostosta index.js:

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

Komponentille App ei ole enää tarvetta välittää dataa propseina.

Komponentti App muuttuu seuraavasti:

import React from 'react'
import axios from 'axios'

class App extends React.Component {
  constructor(props) {
    super(props)
    this.state = {
      notes: [],
      newNote: '',
      showAll: true
    }
    console.log('constructor')
  }

  componentDidMount() {
    console.log('did mount')
    axios
      .get('http://localhost:3001/notes')
      .then(response => {
        console.log('promise fulfilled')
        this.setState({ notes: response.data })
      })
  }

  render() {
    console.log('render')
    // ...
  }
}

Eli konstruktorissa asetetaan tilan notes kentäksi tyhjä taulukko. Lifecycle-metodi componentDidMount hakee datan axiosin avulla ja rekisteröi takaisinkutsufunktion, joka promisen valmistumisen (fulfillment) yhteydessä päivittää komponentin tilan asettamalla palvelimen palauttamat muistiinpanot tilan kentän notes arvoksi.

Koodiin on myös lisätty muutama aputulostus, jotka auttavat hahmottamaan miten suoritus etenee.

Konsoliin tulostuu

constructor
render
did mount
promise fulfilled
render

Ensin siis suoritetaan konstruktori ja metodi render, ja sen jälkeen metodi componentDidMount. Tämän jälkeen kutsutaan kuitenkin vielä metodia render; miksi näin?

Metodissa componentDidMount suoritetaan axiosin avulla HTTP GET -pyyntö ja samalla rekisteröidään pyynnön palauttamalle promiselle tapahtumankäsittelijä:

axios.get('http://localhost:3001/notes').then(response => {
  console.log('promise fulfilled')
  this.setState({ notes: response.data })
})

Tapahtumankäsittelijän koodia, eli then:in parametrina olevaa funktiota ei siis suoriteta vielä tässä vaiheessa. Javascriptin runtime kutsuu sitä jossain vaiheessa sen jälkeen kun palvelin on vastannut HTTP GET -pyyntöön.

Kun kutsutaan metodia render ensimmäistä kertaa (heti konstruktorin jälkeen) komponentti App piirtyy ruudulle aluksi siten, että yhtään muistiinpanoa ei näytetä. Emme kuitenkaan ehdi huomaamaan asiaa, sillä palvelimen vastaus tulee pian, ja se taas saa aikaan tapahtumankäsittelijän suorituksen. Tapahtumankäsittelijä päivittää komponentin tilaa kutsumalla setState ja tämä saa aikaan komponentin uudelleenrenderöinnin.

Mieti tarkasti äsken läpikäytyä tapahtumasarjaa, sen ymmärtäminen on erittäin tärkeää!

Huomaa, että olisimme voineet kirjoittaa koodin myös seuraavasti:

const eventHandler = (response) => {
  console.log('promise fulfilled')
  this.setState({ notes: response.data })
}

const promise = axios.get('http://localhost:3001/notes')

promise.then(eventHandler)

Muuttujaan eventHandler on sijoitettu viite funktioon. Axiosin metodin get palauttama promise on talletettu muuttujaan promise. Takaisinkutsun rekisteröinti tapahtuu antamalla promisen then-metodin parametrina muuttuja eventHandler, joka viittaa käsittelijäfunktioon.

React-komponenteilla on myös joukko muita lifecycle-metodeja, palaamme niihin myöhemmin.

Kokeillaan mitä tapahtuu, jos muistiinpanojen tallettavaa kenttää notes ei alusteta konstruktorissa:

class App extends React.Component {
  constructor() {
    super()
    this.state = {
      //notes: [],
      newNote: '',
      showAll: true
    }
  }

  // ...
}

Seurauksena on ongelmia:

Virheen aiheuttaa komento notesToShow.map sillä muuttujan notesToShow arvo ei ole määritelty ja näin ollen metodin map kutsuminen on mahdotonta.

Muuttuja saa arvonsa metodin render alkuosassa:

const notesToShow =
  this.state.showAll ?
    this.state.notes :
    this.state.notes.filter(note => note.important === true)

Koska metodia render kutsutaan ensimmäisen kerran ennen kuin palvelimelta haettava data saapuu, ei tilan kentälle notes ole asetettu mitään arvoa.

Tulet 100% varmuudella törmäämään kurssilla vastaavaan ongelmaan, eli render metodissa on jollain tavalla aina varauduttava siihen, että ensimmäinen renderöitymiskerta tapahtuu ennen kuin palvelimelta haettava data on saapunut.

Palautetaan konstruktori ennalleen.

Sovelluksen tämän hetkinen koodi on kokonaisuudessaan githubissa, tagissa part2-4.

Tehtäviä datan hakemisesta palvelimelta

Tee nyt tehtävät 2.11-2.13

REST API:n käyttö

Kun sovelluksella luodaan uusia muistiinpanoja, täytyy ne tallentaa palvelimelle.

json-server mainitsee olevansa ns. REST tai RESTful API

Get a full fake REST API with zero coding in less than 30 seconds (seriously)

Ihan alkuperäisen määritelmän mukainen RESTful API json-server ei ole, mutta ei ole kovin moni muukaan itseään REST:iksi kutsuva rajapinta.

Tutustumme REST:iin tarkemmin kurssin seuraavassa osassa, mutta jo nyt on tärkeä ymmärtää minkälaista konventiota json-server ja yleisemminkin REST API:t käyttävät reittien, eli URL:ien ja käytettävien HTTP-pyyntöjen tyyppien suhteen.

REST:issä yksittäisiä asioita esim. meidän tapauksessamme muistiinpanoja kutsutaan resursseiksi. Jokaisella resurssilla on yksilöivä osoite eli URL. json-serverin noudattaman yleisen konvention mukaan yksittäistä muistiinpanoa kuvaavan resurssin URL on muotoa notes/3, missä 3 on resurssin tunniste. Osoite notes taas vastaa kaikkien yksittäisten muistiinpanojen kokoelmaa.

Resursseja haetaan palvelimelta HTTP GET -pyynnöillä. Esim. HTTP GET osoitteeseen notes/3 palauttaa muistiinpanon, jonka id-kentän arvo on 3. Kun taas HTTP GET -pyyntö osoitteeseen notes palauttaa kaikki muistiinpanot.

Uuden muistiinpanoa vastaavan resurssin luominen tapahtuu json-serverin noudattamassa REST-konventiossa tekemällä HTTP POST -pyyntö, joka kohdistuu myös samaan osoiteeseen notes. Pyynnön mukana sen runkona eli bodynä lähetetään luotavan muistiinpanon tiedot.

json-server vaatii, että tiedot lähetetään JSON-muodossa, eli käytännössä sopivasti muotoiltuna merkkijonona ja asettamalla headerille Content-Type arvo application/json.

Datan lähetys palvelimelle

Muutetaan nyt uuden muistiinpanon lisäämisestä huolehtivaa tapahtumankäsittelijää seuraavasti:

addNote = (event) => {
  event.preventDefault()
  const noteObject = {
    content: this.state.newNote,
    date: new Date(),
    important: Math.random() > 0.5
  }

  axios.post('http://localhost:3001/notes', noteObject)
    .then(response => {
      console.log(response)
    })
}

eli luodaan muistiinpanoa vastaava olio, ei kuitenkaan lisätä sille kenttää id, parempi jättää id:n generointi palvelimen vastuulle!

Lähetetään sitten olio palvelimelle käyttämällä axiosin metodia post. Rekisteröidään tapahtumankäsittelijä, joka tulostaa konsoliin palvelimen vastauksen.

Kun nyt kokeillaan luoda uusi muistiinpano, konsoliin tulostus näyttää seuraavalta:

Uusi muistiinpano on siis response-olion kentän data arvona. Palvelin on lisännyt muistiinpanolle tunnisteen, eli id-kentän.

Joskus on hyödyllistä tarkastella HTTP-pyyntöjä osan 0 alussa paljon käytetyn konsolin Network-välilehden kautta:

Voimme esim. tarkastaa onko POST-pyynnön mukana menevä data juuri se mitä oletimme, onko headerit asetettu oikein ym.

Koska POST-pyynnössä lähettämämme data oli Javascript-olio, osasi axios automaattisesti asettaa pyynnön content-type headerille oikean arvon eli application/json.

Uusi muistiinpano ei vielä renderöidy ruudulle, sillä emme aseta komponentille App uutta tilaa muistiinpanon luomisen yhteydessä. Viimeistellään sovellus vielä tältä osin:

addNote = (event) => {
  event.preventDefault()
  const noteObject = {
    content: this.state.newNote,
    date: new Date(),
    important: Math.random() > 0.5
  }

  axios
    .post('http://localhost:3001/notes', noteObject)
    .then(response => {
      this.setState({
        notes: this.state.notes.concat(response.data),
        newNote: ''
      })
    })
}

Palvelimen palauttama uusi muistiinpano siis lisätään tilassa olevien muiden muistiinpanojen joukkoon (kannattaa muistaa tärkeä detalji siitä, että metodi concat ei muuta komponentin alkuperäistä tilaa, vaan luo uuden taulukon) ja tyhjennetään lomakkeen teksti.

Kun palvelimella oleva data alkaa vaikuttaa web-sovelluksen toimintalogiikkaan, tulee sovelluskehitykseen heti iso joukko uusia haasteita, joita tuo mukanaan mm. kommunikoinnin asynkronisuus. Debuggaamiseenkin tarvitaan uusia strategiota, debug-printtaukset ym. muuttuvat vain tärkeämmäksi, myös Javascriptin runtimen periaatteita ja React-komponenttien elinkaarta on pakko tuntea riittävällä tasolla, arvaileminen ei riitä.

Palvelimen tilaa kannattaa tarkastella myös suoraan, esim. selaimella:

näin on mahdollista varmistua, mm. siirtyykö kaikki oletettu data palvelimelle.

Kurssin seuraavassa osassa alamme toteuttaa itse myös palvelimella olevan sovelluslogiikan, tutustumme silloin tarkemmin palvelimen debuggausta auttaviin työkaluihin, mm. postmaniin. Tässä vaiheessa json-server-palvelimen tilan tarkkailuun riittänee selain.

HUOM: sovelluksen nykyisessä versiossa selain lisää uudelle muistiinpanolle sen luomishetkeä kuvaavan kentän. Koska koneen oma kello voi näyttää periaatteessa mitä sattuu, on aikaleimojen generointi todellisuudessa viisaampaa hoitaa palvelimella ja tulemmekin tekemään tämän muutoksen kurssin seuraavassa osassa.

Sovelluksen tämän hetkinen koodi on kokonaisuudessaan githubissa, tagissa part2-5.

Muistiinpanon tärkeyden muutos

Lisätään muistiinpanojen yhteyteen painike, millä niiden tärkeyttä voi muuttaa.

Muistiinpanon määrittelevän komponentin muutos on seuraava:

const Note = ({note, toggleImportance}) => {
  const label = note.important ? 'make not important' : 'make important'
  return (
    <li>{note.content} <button onClick={toggleImportance}>{label}</button></li>
  )
}

Komponentissa on nappi, jolle on rekisteröity klikkaustapahtuman käsittelijäksi propsien avulla välitetty funktio toggleImportance.

Tapahtumankäsittelijän alustava versio on määritelty komponentissa App seuraavasti:

toggleImportanceOf = (id) => {
  return () => {
    console.log('importance of '+id+' needs to be toggled')
  }
}

Kyseessä on jälleen funktio, joka palauttaa funktion. Palataan sen sisältöön kohta.

Komponentin App metodissa render välitetään jokaiselle muistiinpanolle tapahtumankäsittelijäfunktio:

<ul>
  {notesToShow.map(note =>
    <Note
      key={note.id}
      note={note}
      toggleImportance={this.toggleImportanceOf(note.id)}
    />
  )}
</ul>

Jokaisen muistiinpanon tapahtumankäsittelijä on nyt yksilöllinen, sillä se sisältää muistiinpanon id:n. Esim. jos note.id on 3 tulee tapahtumankäsittelijäksi this.toggleImportance(note.id) eli käytännössä:

() => {
  console.log('importance of 3 needs to be toggled')
}

Eli komponentin App metodi toggleImportanceOf ei itsessään ole tapahtumankäsittelijä, vaan tehdas, jonka avulla kullekin muistiinpanolle luodaan oma tapahtumankäsittelijä.

Pieni huomio tähän väliin. Tapahtumankäsittelijän koodin tulostuksessa muodostetaan tulostettava merkkijono Javan tyyliin plussaamalla stringejä:

console.log('importance of '+id+' needs to be toggled')

ES6:n template string -ominaisuuden ansiosta Javascriptissa vastaavat merkkijonot voidaan kirjottaa hieman mukavammin:

console.log(`importance of ${id} needs to be toggled`)

Merkkijonon sisälle voi nyt määritellä “dollari-aaltosulku”-syntaksilla kohtia, minkä sisälle evaluoidaan javascript-lausekkeita, esim. muuttujan arvo. Huomaa, että template stringien hipsutyyppi poikkeaa Javascriptin normaaleista merkkijonojen käyttämistä hipsuista.

Yksittäistä json-serverillä olevaa muistiinpanoa voi muuttaa kahdella tavalla, joko korvaamalla sen tekemällä HTTP PUT -pyyntö muistiinpanon yksilöivään osoitteeseen tai muuttamalla ainoastaan joidenkin muistiinpanon kenttien arvoja HTTP PATCH -pyynnöllä.

Korvaamme nyt muistiinpanon kokonaan, sillä samalla tulee esille muutama tärkeä React:iin ja Javascriptiin liittyvä seikka.

Metodi on seuraavassa:

toggleImportanceOf = (id) => {
  return () => {
    const url = `http://localhost:3001/notes/${id}`
    const note = this.state.notes.find(n => n.id === id)
    const changedNote = { ...note, important: !note.important }

    axios
      .put(url, changedNote)
      .then(response => {
        this.setState({
          notes: this.state.notes.map(note => note.id !== id ? note : response.data)
        })
      })
  }
}

Melkein joka riville sisältyy tärkeitä yksityiskohtia. Ensimmäinen rivi määrittelee jokaiselle muistiinpanolle id-kenttään perustuvan yksilöivän url:in.

Taulukon metodilla find etsitään muutettava muistiinpano ja talletetaan muuttujaan note viite siihen.

Sen jälkeen luodaan uusi olio, jonka sisältö on sama kuin vanhan olion sisältö poislukien kenttä important. Luominen näyttää hieman erikoiselta:

const changedNote = { ...note, important: !note.important }

Kyseessä on vielä standardoimattoman object spread -operaation soveltaminen.

Käytännössä {...note} luo olion, jolla on kenttinään kopiot olion note kenttien arvoista. Kun aaltosulkeisiin lisätään asioita, esim. { ...note, important: true }, tulee uuden olion kenttä important saamaan arvon true. Eli esimerkissämme important saa uudessa oliossa vanhan arvonsa käänteisarvon.

Uusi olio olisi voitu luoda myös vanhemmalla komennolla Object.assign

const changedNote = Object.assign({}, note, {important: !note.important} }

Object spread -syntaksi on kuitenkin yleisesti käytössä Reactissa, joten mekin käytämme sitä.

Pari huomioita. Miksi teimme muutettavasta oliosta kopion vaikka myös seuraava koodi näyttää toimivan:

const note = this.state.notes.find(n => n.id === id)
note.important = !note.important

axios.put(url, note).then(response => {

Näin ei ole suositetavaa tehdä, sillä muuttuja note on viite komponentin tilassa, eli this.state.notes-taulukossa olevaan olioon, ja kuten muistamme tilaa ei Reactissa saa muuttaa suoraan!

Kannattaa myös huomata, että uusi olio changedNote on ainoastaan ns shallow copy, eli uuden olion kenttien arvoina on vanhan olion kenttien arvot. Jos vanhan olion kentät olisivat itsessään olioita, viittaisivat uuden olion kentät samoihin olioihin.

Uusi muistiinpano lähetetään sitten PUT-pyynnön mukana palvelimelle, jossa se korvaa aiemman muistiinpanon.

Takaisinkutsufunktiossa asetetaan komponentin App tilaan kaikki vanhat muistiinpanot paitsi muuttuneen, josta tilaan asetetaan palvelimen palauttama versio:

axios
  .put(url, changedNote)
  .then(response => {
    this.setState({
      notes: this.state.notes.map(note => note.id !== id ? note : response.data)
    })
  })

Tämä saadaan aikaan metodilla map joka siis luo uuden taulukon vanhan taulukon perusteella. Jokainen uuden taulukon alkio luodaan ehdollisesti siten, että jos ehto note.id !== id on tosi, otetaan uuteen taulukkoon suoraan vanhan taulukon kyseinen alkio. Jos ehto on epätosi, eli kyseessä on muutettu muistiinpano, otetaan uuteen taulukkoon palvelimen palauttama olio.

Käytetty map-kikka saattaa olla aluksi hieman hämmentävä. Asiaa kannattaakin miettiä tovi. Tapaa tullaan käyttämään kurssilla vielä kymmeniä kertoja.

Palvelimen kanssa tapahtuvan kommunikoinnin eristäminen omaan moduuliin

App-komponentti alkaa kasvaa uhkaavasti kun myös palvelimen kanssa kommunikointi tapahtuu komponentissa. Single responsibility -periaatteen hengessä kommunikointi onkin viisainta eristää omaan moduuliinsa.

Luodaan hakemisto src/services ja sinne tiedosto notes.js:

import axios from 'axios'
const baseUrl = 'http://localhost:3001/notes'

const getAll = () => {
  return axios.get(baseUrl)
}

const create = (newObject) => {
  return axios.post(baseUrl, newObject)
}

const update = (id, newObject) => {
  return axios.put(`${baseUrl}/${id}`, newObject)
}

export default { getAll, create, update }

Moduuli palauttaa nyt olion, jonka kenttinä on kolme muistiinpanojen käsittelyä hoitavaa funktiota. Funktiot palauttavat suoraan axiosin metodien palauttaman promisen.

Komponentti App saa moduulin käyttöön import-lauseella

import noteService from './services/notes'

App extends React.Component {

moduulin funktioita käytetään importatun muuttujan noteService kautta seuraavasti:

componentDidMount() {
  noteService
    .getAll()
    .then(response => {
      this.setState({notes: response.data})
    })
}

addNote = (event) => {
  // ...
  noteService
    .create(noteObject)
    .then(response => {
      this.setState({
        notes: this.state.notes.concat(response.data),
        newNote: ''
      })
    })
}

toggleImportanceOf = (id) => {
  return () => {
    // ...
    noteService
      .update(id, changedNote)
      .then(response => {
        this.setState({
          notes: this.state.notes.map(note => note.id !== id ? note : response.data)
        })
      })
  }
}

Voisimme viedä ratkaisua vielä askeleen pidemmälle, sillä käyttäessään moduulin funktioita komponentti App saa olion, joka sisältää koko HTTP-pyynnön vastauksen:

noteService
  .getAll()
  .then(response => {
    this.setState({notes: response.data})
  })

Eli asia mistä App on kiinnostunut on parametrin kentässä response.data.

Moduulia olisi miellyttävämpi käyttää, jos se HTTP-pyynnön vastauksen sijaan palauttaisi suoraan muistiinpanot sisältävän taulukon. Tällöin moduulin käyttö näyttäisi seuraavalta

noteService
  .getAll()
  .then(notes => {
    this.setState({notes: notes})
  })

joka voitaisiin ilmaista hieman tiiviimmin seuraavasti:

noteService
  .getAll()
  .then(notes => {
    this.setState({notes})
  })

Tämä onnistuu muuttamalla moduulin koodia seuraavasti (koodiin jää ikävästi copy-pastea, emme kuitenkaan nyt välitä siitä):

import axios from 'axios'
const baseUrl = 'http://localhost:3001/notes'

const getAll = () => {
  const request = axios.get(baseUrl)
  return request.then(response => response.data)
}

const create = (newObject) => {
  const request = axios.post(baseUrl, newObject)
  return request.then(response => response.data)
}

const update = (id, newObject) => {
  const request = axios.put(`${baseUrl}/${id}`, newObject)
  return request.then(response => response.data)
}

export default { getAll, create, update }

eli enää ei palautetakaan suoraan axiosin palauttamaa promisea, vaan otetaan promise ensin muuttujaan request ja kutsutaan sille metodia then:

const getAll = () => {
  const request = axios.get(baseUrl)
  return request.then(response => response.data)
}

Täydellisessä muodossa kirjoitettuna viimeinen rivi olisi:

const getAll = () => {
  const request = axios.get(baseUrl)
  return request.then(response => { return response.data })
}

Myös nyt funktio getAll palauttaa promisen, sillä promisen metodi then palauttaa promisen.

Koska then:in parametri palauttaa suoraan arvon response.data, on funktion getAll palauttama promise sellainen, että jos HTTP-kutsu onnistuu, antaa promise takaisinkutsulleen HTTP-pyynnön mukana olleen datan, eli se toimii juuri niin kuin haluamme.

Moduulin muutoksen jälkeen täytyy komponentti App muokata noteService:n metodien takaisinkutsujen osalta ottamaan huomioon, että ne palauttavat datan suoraan:

class App extends React.component {

  componentDidMount() {
    noteService
      .getAll()
      .then(response => {
        this.setState({ notes: response })
      })
  }

  addNote = (event) => {
    // ...
    noteService
      .create(noteObject)
      .then(newNote => {
        this.setState({
          notes: this.state.notes.concat(newNote),
          newNote: ''
        })
      })
  }

  toggleImportanceOf = (id) => {
    return () => {
      // ...

      noteService
        .update(id, changedNote)
        .then(changedNote => {
          const notes = this.state.notes.filter(n => n.id !== id)
          this.setState({
            notes: notes.concat(changedNote)
          })
        })
    }
  }
}

Tämä kaikki on hieman monimutkaista ja asian selittäminen varmaan vaan vaikeuttaa sen ymmärtämistä. Internetistä löytyy paljon vaihtelevatasoista materiaalia aiheesta, esim. tämä.

You do not know JS sarjan kirja “Async and performance” selittää asian hyvin mutta tarvitsee selitykseen kohtuullisen määrän sivuja.

Promisejen ymmärtäminen on erittäin keskeistä modernissa Javascript-sovelluskehityksessä, joten asiaan kannattaa uhrata kohtuullisessa määrin aikaa.

Promise ja virheet

Jos sovelluksemme mahdollistaisi muistiinpanojen poistamisen, voisi syntyä tilanne, missä käyttäjä yrittää muuttaa sellaisen muistiinpanon tärkeyttä, joka on jo poistettu järjestelmästä.

Simuloidaan tälläistä tilannetta “kovakoodaamalla” noteServiceen funktioon getAll muistiinpano, jota ei ole todellisuudessa (eli palvelimella) olemassa:

const getAll = () => {
  const request = axios.get(baseUrl)
  const nonExisting = {
    id: 10000,
    content: 'Tätä muistiinpanoa ei ole palvelimelta',
    date: '2017-12-10T17:30:31.098Z',
    important: true
  }
  return request.then(response => response.data.concat(nonExisting))
}

Kun valemuistiinpanon tärkeyttä yritetään muuttaa, konsoliin tulee virheilmoitus, joka kertoo palvelimen vastanneen urliin /notes/10000 tehtyyn HTTP PUT -pyyntöön statuskoodilla 404 not found:

Sovelluksen tulisi pystyä käsittelemään tilanne hallitusti. Jos konsoli ei ole auki, ei käyttäjä huomaa mitään muuta kuin sen, että muistiinpanon tärkeys ei vaihdu napin painelusta huolimatta.

Jo aiemmin mainittiin, että promisella voi olla kolme tilaa. Kun HTTP-pyyntö epäonnistuu, menee pyyntöä vastaava promise tilaan rejected. Emme tällä hetkellä käsittele koodissamme promisen epäonnistumista mitenkään.

Promisen epäonnistuminen käsitellään antamalla then –metodille parametriksi myös toinen takaisinkutsufunktio, jota kutsutaan siinä tapauksessa jos promise epäonnistuu.

Ehkä yleisempi tapa kuin kahden tapahtumankäsittelijän käyttö on liittää promiseen epäonnistumistilanteen käsittelijä kutsumalla metodia catch.

Käytännössä virhetilanteen käsittelijän rekisteröiminen tapahtuisi seuraavasti

axios.get('http://example.com/probably_will_fail')
  .then(response => {
    console.log('success!')
  })
  .catch(error => {
    console.log('fail')
  })

Jos pyyntö epäonnistuu, kutsutaan catch-metodin avulla rekisteröityä käsittelijää.

Metodia catch hyödynnetään usein siten, että se sijoitetaan syvemmälle promiseketjuun.

Kun sovelluksemme tekee HTTP-operaation syntyy oleellisesti ottaen promiseketju:

axios
  .put(`${baseUrl}/${id}`, newObject)
  .then(response => response.data)
  .then(changedNote => {
    // ...
  })

Metodilla catch voidaan määritellä ketjun lopussa käsittelijäfunktio, jota kutsutaan siinä vaiheessa jos mikä tahansa ketjun promisesta epäonnistuu, eli menee tilaan rejected:

axios
  .put(`${baseUrl}/${id}`, newObject)
  .then(response => response.data)
  .then(changedNote => {
    // ...
  })
  .catch(error => {
    console.log('fail')
  })

Hyödynnetään tätä ominaisuutta, ja sijoitetaan virheenkäsittelijä komponenttiin App:

toggleImportanceOf = (id) => {
  return () => {
    const note = this.state.notes.find(n => n.id === id)
    const changedNote = { ...note, important: !note.important }

    noteService
      .update(id, changedNote)
      .then(changedNote => {
        const notes = this.state.notes.filter(n => n.id !== id)
        this.setState({
          notes: notes.concat(changedNote)
        })
      })
      .catch(error => {
        alert(`muistiinpano '${note.content}' on jo valitettavasti poistettu palvelimelta`)
        this.setState({ notes: this.state.notes.filter(n => n.id !== id) })
      })
  }
}

Virheilmoitus annetaan vanhan kunnon alert-dialogin avulla ja palvelimelta poistettu muistiinpano poistetaan tilasta.

Olemattoman muistiinpanon poistaminen siis tapahtuu metodilla filter, joka muodostaa uuden taulukon, jonka sisällöksi tulee aluperäisen taulukon sisällöstä ne alkiot, joille parametrina oleva funktio palauttaa arvon true:

this.state.notes.filter(n => n.id !== id) }

Alertia tuskin kannattaa käyttää todellisissa React-sovelluksissa. Opimme kohta kehittyneemmän menetelmän käyttäjille tarkoitettujen tiedotteiden antamiseen. Toisaalta on tilanteita, joissa simppeli battle tested -menetelmä kuten alert riittää aluksi aivan hyvin. Hienomman tavan voi sitten tehdä myöhemmin jos aikaa ja intoa riittää.

Sovelluksen tämän hetkinen koodi on kokonaisuudessaan githubissa, tagissa part2-6.

Tehtäviä palvelimen tilan päivittämisestä

Tee nyt tehtävät 2.14-2.17

Tyylien lisääminen

Sovelluksemme ulkoasu on tällä hetkellä hyvin vaatimaton. Osaan 0 liittyvässä tehtävässä 0.1 oli tarkoitus tutustua Mozillan CSS-tutoriaaliin.

Katsotaan vielä tämän osan lopussa nopeasti erästä tapaa liittää tyylejä React-sovellukseen. Tapoja on useita ja tulemme tarkastelemaan muita myöhemmin. Liitämme nyt CSS:n sovellukseemme vanhan kansan tapaan yksittäisenä, käsin eli ilman esiprosessorien apua kirjoitettuna tiedostona (tämä ei itseasiassa ole täysin totta, kuten myöhemmin tulemme huomaamaan).

Tehdään sovelluksen hakemistoon src tiedosto index.css ja liitetään se sovellukseen lisäämällä tiedostoon index.js seuraava import:

import './index.css'

Lisätään seuraava sääntö tiedostoon index.css:

h1 {
  color: green;
}

CSS-säännöt koostuvat valitsimesta, eli selektorista ja määrittelystä eli deklaraatiosta. Valitsin määrittelee, mihin elementteihin sääntö kohdistuu. Valitsimena on nyt h1, eli kaikki sovelluksessa käytetyt h1-otsikkotägit.

Määrittelyosa asettaa ominaisuuden color, eli fontin värin arvoksi vihreän, eli green.

Sääntö voi sisältää mielivaltaisen määrän määrittelyjä. Muutetaan edellistä siten, että tekstistä tulee kursivoitua, eli fontin tyyliksi asetetaan italics:

h1 {
  color: green;
  font-style: italic;
}

Erilaisia selektoreja eli tapoja valita tyylien kohde on lukuisia.

Jos haluamme kohdistaa tyylejä esim. jokaiseen muistiinpanoon, voisimme nyt käyttää selektoria li, sillä muistiinpanot ovat li-tagien sisällä:

const Note = ({ note, toggleImportance}) => {
  const label = note.important ? 'make not important' : 'make important'
  return (
    <li>{note.content} <button onClick={toggleImportance}>{label}</button></li>
  )
}

lisätään tyylitiedostoon seuraava (koska osaamiseni tyylikkäiden web-sivujen tekemiseen on lähellä nollaa, nyt käytettävissä tyyleissä ei ole sinänsä mitään järkeä):

li {
  color: grey;
  padding-top: 5px;
  font-size: 15px;
}

Tyylien kohdistaminen elementtityypin sijaan on kuitenkin hieman ongelmallista, jos sovelluksessa olisi myös muita li-tageja, kaikki saisivat samat tyylit.

Jos haluamme kohdistaa tyylit nimenomaan muistiinpanoihin, on parempi käyttää class selectoreja.

Normaalissa HTML:ssä luokat määritellään elementtien attribuutin class arvona:

<li class="note">
  tekstiä
</li>

Reactissa tulee kuitenkin classin sijaan käyttää attribuuttia className, eli muutetaan komponenttia Note seuraavasti:

const Note = ({ note, toggleImportance}) => {
  const label = note.important ? 'make not important' : 'make important'
  return (
    <li className="note">
      {note.content} <button onClick={toggleImportance}>{label}</button>
    </li>
  )
}

Luokkaselektori määritellään syntaksilla .classname, eli:

.note {
  color: grey;
  padding-top: 5px;
  font-size: 15px;
}

Jos nyt lisäät sovellukseen muita li-elementtejä, ne eivät saa muistiinpanoille määriteltyjä tyylejä.

Parempi virheilmoitus

Toteutimme äsken olemassaolemattoman muistiinpanon tärkeyden muutokseen liittyvän virheilmoituksen alert-metodilla. Toteutetaan se nyt Reactilla omana komponenttinaan.

Komponentti on yksinkertainen:

const Notification = ({ message }) => {
  if (message === null) {
    return null
  }
  return (
    <div className="error">
      {message}
    </div>
  )
}

Jos propsin message arvo on null ei renderöidä mitään, muussa tapauksessa renderöidään viesti div-elementtiin. Elementille on liitetty tyylien lisäämistä varten luokka error.

Lisätään komponentin App tilaan kenttä error virheviestiä varten, laitetaan kentälle jotain sisältöä, jotta pääsemme heti testaamaan komponenttia:

class App extends React.Component {
  constructor(props) {
    super(props)
    this.state = {
      notes: [],
      newNote: '',
      showAll: true,
      error: 'something went wrong...'
    }
  }
  // ...
}

Renderöidään uusi komponentti:

class App extends React.Component {
  render() {
    //...

    return (
      <div>
        <h1>Muistiinpanot</h1>

        <Notification message={this.state.error}/>

        ...
      </div>
    )
  }
}

Lisätään sitten virheviestille sopiva tyyli:

.error {
  color: red;
  background: lightgrey;
  font-size: 20px;
  border-style: solid;
  border-radius: 5px;
  padding: 10px;
  margin-bottom: 10px;
}

Nyt olemme valmiina lisäämään virheviestin logiikan. Alustetaan virheviesti konstruktorissa arvoon null ja muutetaan metodia toggleImportanceOf seuraavasti:

  toggleImportanceOf = (id) => {
    return () => {
      //...

      noteService
        .update(id, changedNote)
        .then(changedNote => {
          // ...
        })
        .catch(error => {
          this.setState({
            error: `muistiinpano '${note.content}' on jo valitettavasti poistettu palvelimelta`,
            notes: this.state.notes.filter(n => n.id !== id)
          })
          setTimeout(() => {
            this.setState({error: null})
          }, 5000)
        })
    }
  }

Eli virheen yhteydessä asetetaan tilan kenttään error sopiva virheviesti. Samalla käynnistetään ajastin, joka asettaa 5 sekunnin kuluttua tilan error-kentän arvoksi null.

Lopputulos näyttää seuraavalta

Sovelluksen tämänhetkinen koodi on kokonaisuudessaan githubissa, tagissa part2-7.

Loppuhuipennus

Tee nyt tehtävät 2.18 ja 2.19