e

Tyylien lisääminen React-sovellukseen

Sovelluksemme ulkoasu on tällä hetkellä hyvin vaatimaton. Osaan 0 liittyvässä tehtävässä 0.2 tutustuttiin Mozillan CSS-tutoriaaliin.

Katsotaan vielä tämän osan lopussa nopeasti kahta tapaa liittää tyylejä React-sovellukseen. Tapoja on useita, ja tulemme tarkastelemaan muita myöhemmin. Ensimmäisenä liitämme CSS:n sovellukseemme vanhan kansan tapaan yksittäisenä tiedostona, joka on kirjoitettu käsin ilman esiprosessorien apua (tulemme myöhemmin huomaamaan, että tämä ei ole täysin totta).

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 (green).

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

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

Erilaisia selektoreja eli tapoja valita tyylien kohde on lukuisia.

Jos haluamme kohdistaa tyylejä esim. jokaiseen muistiinpanoon, voisimme 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: 3px;
  font-size: 15px;
}

Tyylien kohdistaminen elementtityyppeihin on kuitenkin ongelmallista. Jos sovelluksessa on myös muita li-tageja, kaikki saavat 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, joten 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

Aiemmin toteutimme olemassa olemattoman 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ä errorMessage virheviestiä varten. Laitetaan kentälle heti jotain sisältöä, jotta pääsemme testaamaan komponenttia:

const App = () => {
  const [notes, setNotes] = useState([]) 
  const [newNote, setNewNote] = useState('')
  const [showAll, setShowAll] = useState(true)
  const [errorMessage, setErrorMessage] = useState('some error happened...')
  // ...

  return (
    <div>
      <h1>Notes</h1>
      <Notification message={errorMessage} />      <div>
        <button onClick={() => setShowAll(!showAll)}>
          show {showAll ? 'important' : 'all' }
        </button>
      </div>      
      // ...
    </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. Muutetaan metodia toggleImportanceOf seuraavasti:

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

    noteService
      .update(id, changedNote).then(returnedNote => {
        setNotes(notes.map(note => note.id !== id ? note : returnedNote))
      })
      .catch(error => {
        setErrorMessage(          `Note '${note.content}' was already removed from server`        )        setTimeout(() => {          setErrorMessage(null)        }, 5000)        setNotes(notes.filter(n => n.id !== id))
      })
  }

Virheen yhteydessä asetetaan tilaan errorMessage sopiva virheviesti. Samalla käynnistetään ajastin, joka asettaa viiden sekunnin kuluttua tilan errorMessage-kentän arvoksi null.

Lopputulos näyttää seuraavalta:

fullstack content

Sovelluksen tämänhetkinen koodi on kokonaisuudessaan GitHubissa, branchissa part2-7.

Inline-tyylit

React mahdollistaa tyylien kirjoittamisen myös suoraan komponenttien koodin joukkoon niin sanoittuina inline-tyyleinä.

Periaate inline-tyylien määrittelyssä on erittäin yksinkertainen. Mihin tahansa React-komponenttiin tai elementtiin voi liittää attribuutin style, jolle annetaan arvoksi JavaScript-oliona määritelty joukko CSS-sääntöjä.

CSS-säännöt määritellään JavaScriptin avulla hieman eri tavalla kuin normaaleissa CSS-tiedostoissa. Jos haluamme asettaa jollekin elementille esimerkiksi vihreän, kursivoidun ja 16 pikselin korkuisen fontin, määrittely ilmaistaan CSS-syntaksilla seuraavasti:

{
  color: green;
  font-style: italic;
  font-size: 16px;
}

Vastaava tyyli kirjoitetaan Reactin inline-tyylin määrittelevänä oliona seuraavasti:

{
  color: 'green',
  fontStyle: 'italic',
  fontSize: 16
}

Jokainen CSS-sääntö on olion kenttä, joten ne erotetaan JavaScript-syntaksin mukaan pilkuilla. Pikseleinä ilmaistut numeroarvot voidaan määritellä kokonaislukuina. Merkittävin ero normaaliin CSS:ään on väliviivan sisältämien CSS-ominaisuuksien kirjoittaminen camelCase-muodossa.

Voimme nyt lisätä sovellukseemme alapalkin muodostavan komponentin Footer ja määritellä sille inline-tyylit seuraavasti:

const Footer = () => {
  const footerStyle = {
    color: 'green',
    fontStyle: 'italic',
    fontSize: 16
  }

  return (
    <div style={footerStyle}>
      <br />
      <em>Note app, Department of Computer Science, University of Helsinki 2022</em>
    </div>
  )
}

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

  return (
    <div>
      <h1>Notes</h1>

      <Notification message={errorMessage} />

      // ...  

      <Footer />    </div>
  )
}

Inline-tyyleillä on tiettyjä rajoituksia, esim. ns. pseudo-selektoreja ei ole mahdollisuutta käyttää (ainakaan helposti).

Inline-tyylit ja muutamat myöhemmin kurssilla katsomamme tavat lisätä tyylejä Reactiin ovat periaatteessa täysin vastoin vanhoja hyviä periaatteita, joiden mukaan web-sovellusten ulkoasujen määrittely eli CSS tulee erottaa sisällön (HTML) ja toiminnallisuuden (JavaScript) määrittelystä. Vanha koulukunta pyrkiikin siihen, että sovelluksen CSS, HTML ja JavaScript kirjoitetaan omiin tiedostoihinsa.

CSS:n, HTML:n ja JavaScriptin erottelu omiin tiedostoihinsa ei kuitenkaan ole välttämättä erityisen skaalautuva ratkaisu suurissa ohjelmistoissa. Reactissa onkin periaatteena jakaa sovelluksen koodi eri tiedostoihin noudattaen sovelluksen loogisia toiminnallisia kokonaisuuksia.

Toiminnallisen kokonaisuuden strukturointiyksikkö on React-komponentti, joka määrittelee niin sisällön rakenteen kuvaavan HTML:n, toiminnan määrittelevät JavaScript-funktiot kuin komponentin tyylinkin yhdessä paikassa siten, että komponenteista tulee mahdollisimman riippumattomia ja yleiskäyttöisiä.

Sovelluksen lopullinen koodi on kokonaisuudessaan GitHubissa, branchissa part2-8.

Muutama tärkeä huomio

Osan lopussa on vielä muutama hieman haastavampi tehtävä. Voit tässä vaiheessa jättää tehtävät tekemättä jos ne tuottavat liian paljon päänvaivaa, palaamme samoihin teemoihin uudelleen myöhemmin. Materiaali kannattanee jokatapauksessa lukea läpi.

Eräs sovelluksessamme tekemä ratkaisu piilottaa yhden hyvin tyypillisen virhetilanteen, mihin tulet varmasti törmäämään monta kertaa.

Alustimme muistiinpanot muistavan tilan alkuarvoksi tyhjän tauluon:

const App = () => {
  const [notes, setNotes] = useState([])

  // ...
}

Tämä on onkin luonnollinen tapa alustaa tila, muistiinpanot muodostavat joukon, joten tyhjä taulukko on luonteva alkuarvo muuttujalle.

Niissä tilanteissa, missä tilaan talletetaan "yksi asia" tilan luonteva alkuarvo on usein null, joka kertoo että tilassa ei ole vielä mitään. Kokeillaan miten käy jos alustamme nyt tilan nulliksi:

const App = () => {
  const [notes, setNotes] = useState(null)
  // ...
}

Sovellus hajoaa:

fullstack content

Virheilmoitus kertoo vian syyn ja sijainnin. Ongelmallinen kohta on seuraava:

  // notesToShow gets the value of notes
  const notesToShow = showAll
    ? notes
    : notes.filter(note => note.important)

  // ...

  {notesToShow.map(note =>    <Note key={note.id} note={note} />
  )}

Virheilmoitus siis on

Cannot read properties of null (reading 'map')

Muuttuja notesToShow saa arvokseen tilan notes arvon ja koodi yrittää kutsua olemattomalle oliolle (jonka arvo on null) metodia map.

Mistä tämä johtuu?

Efektohookki asettaa tilaan notes palvelimen palauttamat muistiinpanot funktiolla setNotes:

  useEffect(() => {
    noteService
      .getAll()
      .then(initialNotes => {
        setNotes(initialNotes)      })
  }, [])

Ongelma on kuitenkin siinä, että efekti suoritetaan vasta ensimmäisen renderöinnin jälkeen. Koska tilalle notes on asetettu alkuarvo null:

const App = () => {
  const [notes, setNotes] = useState(null)
  // ...

ensimmäisen renderöinnin tapahtuessa tullaan suorittamaan

notesToShow = notes

// ...

notesToShow.map(note => ...)

ja tämä aiheuttaa ongelman, sillä arvolle null ei voida kutsua metodia filter.

Kun annoimme tilalle notes alkuarvoksi tyhjän taulukon, ei samaa ongelmaa esiinny, tyhjälle taulukolle on luvallista kutsua metodia filter.

Sopiva tilan alustaminen siis "peitti" ongelman, joka johtuu siitä että muistiinpanoja ei ole vielä alustettu palvelimelta haettavalla datalla.

Toinen tapa kiertää ongelma on tehdä ehdollinen renderöinti, ja palauttaa ainoastaan null jos komponentin tila ei ole vielä alustettu:

const App = () => {
  const [notes, setNotes] = useState(null)  // ... 

  useEffect(() => {
    noteService
      .getAll()
      .then(initialNotes => {
        setNotes(initialNotes)
      })
  }, [])

  // do not render anything if notes is still null
  if (!notes) {     return null   }
  // ...
} 

Nyt ensimmäisellä renderöinnillä ei renderöidä mitään. Kun muistiinpanot saapuvat palvelimelta, asetetaan ne tilaan notes kutsumalla funktiota setNotes. Tämä saa aikaan uuden renderöinnin ja muistiinpanot piirtyvät ruudulle.

Tämä tapa sopii erityisesti niihin tilanteisiin, joissa tilaa ei voi alustaa muuten komponentille sopivaan, renderöinnin mahdollistavaan alkuarvoon kuten tyhjäksi taulukoksi.

Toinen huomiomme liittyy useEffectin toiseen parametriin:

  useEffect(() => {
    noteService
      .getAll()
      .then(initialNotes => {
        setNotes(initialNotes)  
      })
  }, [])

Funktion useEffect toista parametria käytetään tarkentamaan sitä, miten usein efekti suoritetaan. Periaate on se, että efekti suoritetaan aina ensimmäisen renderöinnin yhteydessä ja silloin kuin toisena parametrina olevan taulukon sisältö muuttuu.

Kun toisena parametrina on tyhjä taulukko [], sen sisältö ei koskaan muutu ja efekti suoritetaan ainoastaan komponentin ensimmäisen renderöinnin jälkeen. Tämä on juuri se mitä haluamme kun alustamme sovelluksen tilan.

On kuitenkin tilanteita, missä efekti halutaan suorittaa muulloinkin, esim. komponentin tilan muuttuessa sopivalla tavalla.

Tarkastellaan seuraavaa yksinkertaista sovellusta, jonka avulla voidaan kysellä valuuttojen vaihtokursseja Exchange rate API -palvelusta:

import { useState, useEffect } from 'react'
import axios from 'axios'

const App = () => {
  const [value, setValue] = useState('')
  const [rates, setRates] = useState({})
  const [currency, setCurrency] = useState(null)

  useEffect(() => {
    console.log('effect run, currency is now', currency)

    // skip if currency is not defined
    if (currency) {
      console.log('fetching exchange rates...')
      axios
        .get(`https://open.er-api.com/v6/latest/${currency}`)
        .then(response => {
          setRates(response.data.rates)
        })
    }
  }, [currency])

  const handleChange = (event) => {
    setValue(event.target.value)
  }

  const onSearch = (event) => {
    event.preventDefault()
    setCurrency(value)
  }

  return (
    <div>
      <form onSubmit={onSearch}>
        currency: <input value={value} onChange={handleChange} />
        <button type="submit">exchange rate</button>
      </form>
      <pre>
        {JSON.stringify(rates, null, 2)}
      </pre>
    </div>
  )
}

Sovelluksen käyttöliittymässä on lomake, jonka syötekenttään halutun valuutan nimi kirjoitetaan. Jos valuutta on olemassa, renderöi sovellus valuutan vaihtokurssit muihin valuuttoihin:

fullstack content

Sovellus asettaa käyttäjän hakulomakkeelle kirjoittaman valuutan nimen tilaan currency sillä hetkellä kun nappia painetaan.

Kun currency saa uuden arvon, sovellus tekee useEffectin määrittelemässä funktiossa haun valuuttakurssit kertovaan rajapintaan:

const App = () => {
  // ...
  const [currency, setCurrency] = useState(null)

  useEffect(() => {
    console.log('effect run, currency is now', currency)

    // skip if currency is not defined
    if (currency) {
      console.log('fetching exchange rates...')
      axios
        .get(`https://open.er-api.com/v6/latest/${currency}`)
        .then(response => {
          setRates(response.data.rates)
        })
    }
  }, [currency])  // ...
}

Efektifunktio siis suoritetaan ensimmäisen renderöinnin jälkeen, ja aina sen jälkeen kun sen toisena parametrina oleva taulukko eli esimerkin tapauksessa [currency] muuttuu. Eli kun tila currency saa uuden arvon, muuttuu taulukon sisältö ja efektifunktio suoritetaan.

Efektiin on tehty ehto

if (currency) { 
  // haetaan valuuttakurssit
}

joka estää valuuttakurssien hakemisen ensimmäisen renderöininin yhteydessä, eli siinä vaiheessa kuin muuttujalla currency on vasta alkuarvo eli tyhjää merkkijono.

Jos käyttäjä siis kirjoittaa hakukenttään esim. eur, suorittaa sovellus Axiosin avulla HTTP GET -pyynnön osoitteeseen https://open.er-api.com/v6/latest/eur ja tallentaa vastauksen tilaaan rates.

Kun käyttäjä tämän jälkeen kirjoittaa hakukenttään jonkin toisen arvon, esim. usd suoritetaan efekti jälleen ja uuden valuutan kurssit haetaan.

Tässä esitelty tapa API-kyselyjen tekemiseen saattaa tuntua hieman hankalalta. Tämä kyseinen sovellus olisikin voitu tehdä kokonaan ilman useEffectin käyttöä, ja tehdä API-kyselyt suoraan lomakkeen napin painamisen hoitavassa käsittelijäfunktiossa:

  const onSearch = (event) => {
    event.preventDefault()
    axios
      .get(`https://open.er-api.com/v6/latest/${value}`)
      .then(response => {
        setRates(response.data.rates)
      })
  }

On kuitenkin tilanteita, missä vastaava tekniikka ei onnistu. Esim. eräs tapa tehtävässä 2.20 tarvittavien kaupungin säätietojen hakemiseen on nimenomaan useEffectin hyödyntäminen. Tehtävässä selviää myös hyvin ilman kyseistä kikkaa, esim. malliratkaisu ei sitä tarvitse.