Creative Commons -lisenssi

Osan 6 oppimistavoitteet

  • Redux
    • Combined reducers
    • Asynkroniset actionit
  • React+redux
    • connect
  • React
    • presenter/container-patterni
    • High order -komponentit
    • React router
    • Inline-tyylit
    • UI-frameworkien käyttö

Muistiinpano-sovelluksen refaktorointia

Jatketaan osan 5 loppupuolella tehdyn muistiinpanosovelluksen yksinkertaistetun redux-version laajentamista.

Sovelluksen tämänhetkinen koodi on githubissa tagissa part5-6.

Tehdään koodiin muutamia rakenteellisia muutoksia. Siirretään reducerin määrittelevä tiedosto noteReducer.js hakemistoon src/reducers.

Sovelluskehitystä helpottaaksemme laajennetaan reduceria siten, että storelle määritellään alkutila, jossa on pari muistiinpanoa:

const initialState = [
  { content: 'reduxin storen toiminnan määrittelee reduceri', important: true, id: 1},
  { content: 'storen tilassa voi olla mielivaltaista dataa', important: false, id: 2}
]
const noteReducer = (state = initialState, action) => {
  // ...
}

export default noteReducer

Siirretään action creatorit, eli sopivia action-olioita generoivat apufunktiot reducerin kanssa samaan moduuliin:

const initialState = [
  { content: 'reduxin storen toiminnan määrittelee reduceri', important: true, id: 1 },
  { content: 'storen tilassa voi olla mielivaltaista dataa', important: false, id: 2 }
]

const noteReducer = (state = initialState, action) => {
  switch (action.type) {
    case 'NEW_NOTE':
      return [...state, action.data]
    case 'TOGGLE_IMPORTANCE':
      const id = action.data.id
      const noteToChange = state.find(n => n.id === id)
      const changedNote = { ...noteToChange, important: !noteToChange.important }
      return state.map(note => note.id !== id ? note : changedNote )
    default:
      return state
  }
}

const generateId = () => Number((Math.random() * 1000000).toFixed(0))

export const noteCreation = (content) => {
  return {
    type: 'NEW_NOTE',
    data: {
      content,
      important: false,
      id: generateId()
    }
  }
}

export const importanceToggling = (id) => {
  return {
    type: 'TOGGLE_IMPORTANCE',
    data: { id }
  }
}

export default noteReducer

Moduulissa on nyt useita export-komentoja.

Reducer-funktio palautetaan edelleen komennolla export default. Tämän ansiosta reducer importataan (tiedostossa index.js) tuttuun tapaan:

import noteReducer from './reducers/noteReducer'

Moduulilla voi olla vain yksi default export, mutta useita “normaaleja” exporteja, kuten Action creator -funktiot esimerkissämme

export const noteCreation = (content) => {
  // ...
}

export const importanceToggling = (id) => {
  // ...
}

Normaalisti exportattujen funktioiden käyttöönotto tapahtuu aaltosulkusyntaksilla:

import { noteCreation } from './../reducers/noteReducer'

Sovelluksen tämänhetkinen koodi on githubissa tagissä part6-1.

ESlint

Konfiguroimme osassa 3 koodin tyylistä huolehtivan ESlintin backendiin. Otetaan nyt ESlint käyttöön myös frontendissa.

Create-react-app on asentanut projektille eslintin valmiiksi, joten ei tarvita muuta kun sopiva konfiguraatio tiedoston .eslintrc.js.

Tiedoston voi generoida komennolla

npx eslint --init

ja vastailemalla sopivasti kysymyksiin:

Jotta pääsemme eroon testeissä olevista turhista huomautuksista asennetaan eslint-jest-plugin

npm add --save-dev eslint-plugin-jest

ja otetaan se käyttöön manuaalin opastamalla tavalla.

Jos vastailit initialisoinnissa kysymyksiin kuvan osoittamalla tavalla, asentuu projektiin eslint-plugin-react. Laajennetaan konfiguraatiota pluginin manuaalin ohjeen mukaan.

Joudumme asentamaan myös babel-eslint-pluginin, jotta ESlint osaisi tulkita koodissa käyttämäämme class property -syntaksia. Pluginin asennus tapahtuu komennolla

npm install babel-eslint --save-dev

ja se tulee muistaa ottaa käyttöön konfiguraatiossa.

Seuraavassa lopullinen konfiguraatio, mihin on lisätty muutama muukin osassa 3 käyttöönotettu sääntö:

module.exports = {
    "env": {
        "browser": true,
        "es6": true,
        "jest/globals": true
    },
    "extends": [
        "eslint:recommended",
        "plugin:react/recommended"
    ],
    "parser": "babel-eslint",
    "parserOptions": {
        "ecmaFeatures": {
            "jsx": true
        },
        "ecmaVersion": 2018,
        "sourceType": "module"
    },
    "plugins": [
        "react", "jest"
    ],
    "rules": {
        "indent": [
            "error",
            2
        ],
        "linebreak-style": [
            "error",
            "unix"
        ],
        "quotes": [
            "error",
            "single"
        ],
        "semi": [
            "error",
            "never"
        ],
        "eqeqeq": "error",
        "no-trailing-spaces": "error",
        "object-curly-spacing": [
            "error", "always"
        ],
        "arrow-spacing": [
            "error", { "before": true, "after": true }
        ],
        "no-console": 0,
        "react/prop-types": 0
    }
};

Monimutkaisempi tila storessa

Toteutetaan sovellukseen näytettävien muistiinpanojen filtteröinti, jonka avulla näytettäviä muistiinpanoja voidaan rajata. Filtterin toteutus tapahtuu radiobuttoneiden avulla:

Aloitetaan todella suoraviivaisella toteutuksella:

class App extends React.Component {
  filterSelected = (value) => () => {
    console.log(value)
  }
  render() {
    return (
      <div>
        <NoteForm />
        <div>
          kaikki    <input type="radio" name="filter"
                      onChange={this.filterSelected('ALL')}/>
          tärkeät   <input type="radio" name="filter"
                      onChange={this.filterSelected('IMPORTANT')}/>
          eitärkeät <input type="radio" name="filter"
                      onChange={this.filterSelected('NONIMPORTANT')}/>
        </div>
        <NoteList />
      </div>
    )
  }
}

Koska painikkeiden attribuutin name arvo on kaikilla sama, muodostavat ne nappiryhmän, joista ainoastaan yksi voi olla kerrallaan valittuna.

Napeille on määritelty muutoksenkäsittelijä, joka tällä hetkellä ainoastaan tulostaa painettua nappia vastaavan merkkijonon konsoliin.

Päätämme toteuttaa filtteröinnin siten, että talletamme muistiinpanojen lisäksi sovelluksen storeen myös filtterin arvon. Eli muutoksen jälkeen storessa olevan tilan tulisi näyttää seuraavalta:

{
  notes: [
    { content: 'reduxin storen toiminnan määrittelee reduceri', important: true, id: 1},
    { content: 'storen tilassa voi olla mielivaltaista dataa', important: false, id: 2}
  ],
  filter: 'IMPORTANT'
}

Tällä hetkellähän tilassa on ainoastaan muistiinpanot sisältävä taulukko. Uudessa ratkaisussa tilalla on siis kaksi avainta, notes jonka arvona muistiinpanot ovat sekä filter, jonka arvona on merkkijono joka kertoo mitkä muistiinpanoista tulisi näyttää ruudulla.

Yhdistetyt reducerit

Voisimme periaatteessa muokata jo olemassaolevaa reduceria ottamaan huomioon muuttuneen tilanteen. Parempi ratkaisu on kuitenkin määritellä tässä tilanteessa uusi, filtterin arvosta huolehtiva reduceri:

const filterReducer = (state = 'ALL', action) => {
  switch (action.type) {
    case 'SET_FILTER':
      return action.filter
    default:
      return state
  }
}

Filtterin arvon asettavat actionit ovat siis muotoa

{
  type: 'SET_FILTER',
  filter: 'IMPORTANT'
}

Määritellään samalla myös sopiva action creator -funktio. Sijoitetaan koodi moduuliin src/reducers/filterReducer.js:

const filterReducer = (state = 'ALL', action) => {
  // ...
}

export const filterChange = (filter) => {
  return {
    type: 'SET_FILTER',
    filter
  }
}

export default filterReducer

Saamme nyt muodostettua varsinaisen reducerin yhdistämällä kaksi olemassaolevaa reduceria funktion combineReducers avulla.

Määritellään yhdistetty reduceri tiedostossa index.js:

import React from 'react'
import ReactDOM from 'react-dom'
import { createStore, combineReducers } from 'redux'
import { Provider } from 'react-redux'
import App from './App'
import noteReducer from './reducers/noteReducer'
import filterReducer from './reducers/filterReducer'

const reducer = combineReducers({
  notes: noteReducer,
  filter: filterReducer
})

const store = createStore(reducer)

console.log(store.getState())

ReactDOM.render(
  <Provider store={store}>
    <div> </div>
  </Provider>,
document.getElementById('root'))

Koska sovelluksemme hajoaa tässä vaiheessa täysin, komponentin App sijasta renderöidään tyhjä div-elementti.

Konsoliin tulostuu storen tila:

eli store on juuri siinä muodossa missä haluammekin sen olevan!

Tarkastellaan vielä yhdistetyn reducerin luomista

const reducer = combineReducers({
  notes: noteReducer,
  filter: filterReducer
})

Näin tehdyn reducerin määrittelemän storen tila on olio, jossa on kaksi kenttää, notes ja filter. Tilan kentän notes arvon määrittelee noteReducer, jonka ei tarvitse välittää mitään tilan muista kentistä. Vastaavasti filter kentän käsittely tapahtuu filterReducer:in avulla.

Ennen muun koodin muutoksia, kokeillaan vielä konsolista, miten actionit muuttavat yhdistetyn reducerin muodostamaa staten tilaa:

//...
import noteReducer, { noteCreation } from './reducers/noteReducer'
import filterReducer, { filterChange } from './reducers/filterReducer'

const reducer = combineReducers({
  notes: noteReducer,
  filter: filterReducer
})

const store = createStore(reducer)
store.subscribe(() =>
  console.log(store.getState())
)
console.log(store.getState())
store.dispatch(filterChange('IMPORTANT'))
store.dispatch(noteCreation('combineReducers muodostaa yhdistetyn reducerin'))

Konsoliin tulostuu storen tila:

Jo tässä vaiheessa kannattaa laittaa mieleen eräs tärkeä detalji. Jos lisäämme molempien reducerien alkuun konsoliin tulostuksen:

const filterReducer = (state = 'ALL', action) => {
  console.log('ACTION: ', action)
  // ...
}

Näyttää konsolin perusteella siltä, että jokainen action kahdentuu:

Onko koodissa bugi? Ei. Yhdistetty reducer toimii siten, että jokainen action käsitellään kaikissa yhdistetyn reducerin osissa. Usein tietystä actionista on kiinnostunut vain yksi reduceri, on kuitenkin tilanteita, joissa useampi reduceri muuttaa hallitsemaansa staten tilaa jonkin actionin seurauksena.

Sovelluksen viimeistely

Viimeistellään nyt sovellus käyttämään yhdistettyä reduceria, eli palautetaan tiedostossa index.js suoritettava renderöinti muotoon

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

Korjataan sitten bugi, joka johtuu siitä, että koodi olettaa storen tilan olevan mustiinpanot tallettava taulukko:

Korjaus on helppo. Viitteen this.context.store.getState() sijaan kaikki muistiinpanot sisältävään taulukkoon viitataan this.context.store.getState().notes.

Ennakoiden tulevaa eriytetään näytettävien muistiinpanojen selvittämisen huolehtiminen funktioon notesToShow, joka vielä tässä vaiheessa palauttaa kaikki muistiinpanot:

class NoteList extends React.Component {
  // ...

  render() {
    const notesToShow = () => {
      return this.context.store.getState().notes
    }

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

Eriytetään näkyvyyden säätelyfiltteri omaksi, tiedostoon sijoitettavaksi src/components/VisibilityFilter.js komponentiksi:

import React from 'react'
import PropTypes from 'prop-types'
import { filterChange } from '../reducers/filterReducer'

class VisibilityFilter extends React.Component {
  componentDidMount() {
    const { store } = this.context
    this.unsubscribe = store.subscribe(() =>
      this.forceUpdate()
    )
  }

  componentWillUnmount() {
    this.unsubscribe()
  }

  filterClicked = (value) => () => {
    this.context.store.dispatch(filterChange(value))
  }

  render() {
    return (
      <div>
        kaikki    <input type="radio" name="filter" onChange={this.filterClicked('ALL')} />
        tärkeät   <input type="radio" name="filter" onChange={this.filterClicked('IMPORTANT')} />
        eitärkeät <input type="radio" name="filter" onChange={this.filterClicked('NONIMPORTANT')} />
      </div>
    )
  }
}

VisibilityFilter.contextTypes = {
  store: PropTypes.object
}

export default VisibilityFilter

Toteutus on suoraviivainen, radiobuttonin klikkaaminen muuttaa storen kentän filter tilaa.

Muutetaan vielä komponentin NoteList metodi notesToShow ottamaan huomioon filtteri

const notesToShow = () => {
  const { notes, filter } = this.context.store.getState()
  if (filter === 'ALL') {
    return notes
  }

  return filter === 'IMPORTANT'
    ? notes.filter(note => note.important)
    : notes.filter(note => !note.important)
}

Huomaa miten storen tilan kentät on otettu tuttuun tapaan destrukturoimalla apumuuttujiin

const { notes, filter } = this.context.store.getState()

siis on sama kuin kirjoittaisimme

const notes = this.context.store.getState().notes
const filter = this.context.store.getState().filter

Sovelluksen tämänhetkinen koodi on githubissa tagissä part6-2.

Sovelluksessa on vielä pieni kauneusvirhe, vaikka oletusarvosesti filtterin arvo on ALL, eli näytetään kaikki muistiinpanot, ei vastaava radiobutton ole valittuna. Ongelma on luonnollisestikin mahdollista korjata, mutta koska kyseessä on ikävä, mutta harmiton feature, jätämme korjauksen myöhemmäksi.

Tehtäviä

Tee nyt tehtävät 6.1-6.5

Connect

Kaikissa Redux-storea käyttävissä komponenteissa on runsaasti samaa koodia

class ComponentUsingReduxStore extends React.Component {
  componentDidMount() {
    const { store } = this.context
    this.unsubscribe = store.subscribe(() =>
      this.forceUpdate()
    )
  }

  componentWillUnmount() {
    this.unsubscribe()
  }

}

ComponentUsingReduxStore.contextTypes = {
  store: PropTypes.object
}

Vaikka rivit on helppo copy-pasteta aina uusiin komponentteihin, ei tämä ole tarkoituksenmukaista. Osan 5 luvussa staten välittäminen propseissa ja contextissa myös varoiteltiin luottamasta liikaa Reactin Context API:iin, se on kokeellinen ja saattaa poistua tulevissa versioissa. Contextia on siis ainakin tässä vaiheessa käytettävä varovasti.

React Redux -kirjaston määrittelemä funktio connect on paras ratkaisu siihen, miten Redux-store saadaan välitettyä React-componenteille.

Connect voi olla aluksi haastava sisäistää, mutta hieman vaivaa kannattaa ehdottomasti nähdä. Tutustutaan nyt connectin käyttöön. Asensimme jo edellisessä osassa kirjaston, joten voimme aloittaa sen käytön suoraan.

Tutkitaan ensin komponenttia NoteList. Funktiota connect käyttämällä “normaaleista” React-komponenteista saadaan muodostettua komponentteja, joiden propseihin on “mäpätty” eli yhdistetty haluttuja osia storen määrittelemästä tilasta.

Muodostetaan ensin komponentista NoteList connectin avulla yhdistetty komponentti:

// ...
import { connect } from 'react-redux'

class NoteList extends React.Component {
  // ...
}

const ConnectedNoteList = connect()(NoteList)

export default ConnectedNoteList

Moduuli eksporttaa nyt alkuperäisen komponentin sijaan yhdistetyn komponentin, joka toimii toistaiseksi täsmälleen alkuperäisen komponentin kaltaisesti.

Komponentti tarvitsee storesta sekä muistiinpanojen listan, että filtterin arvon. Funktion connect ensimmäisenä parametrina voidaan määritellä funktio mapStateToProps, joka liittää joitakin storen tilan perusteella määriteltyjä asioita connectilla muodostetun yhdistetyn komponentin propseiksi.

Jos määritellään:

const mapStateToProps = (state) => {
  return {
    notes: state.notes,
    filter: state.filter
  }
}

const ConnectedNoteList = connect(
  mapStateToProps
)(NoteList)

export default ConnectedNoteList

on komponentin NoteList sisällä mahdollista viitata storen tilaan, esim. muistiinpanoihin suoraan propsin kautta props.notes sen sijaan, että käytettäisiin suoraan contextia muodossa this.context.store.getState().notes. Vastaavasti props.filter viittaa storessa olevaan filter-kentän tilaan.

Metodin render sisältö pelkistyy seuraavasti

class NoteList extends React.Component {

  render() {
    const notesToShow = () => {
      const { notes, filter } = this.props
      if (filter === 'ALL') {
        return notes
      }

      return filter === 'IMPORTANT'
        ? notes.filter(note => note.important)
        : notes.filter(note => !note.important)
    }

    // ...
  }
}

Connect-komennolla, ja mapStateToProps-määrittelyllä aikaan saatua tilannetta voidaan visualisoida seuraavasti:

eli komponentin NoteList sisältä on propsien props.notes ja props.filter kautta “suora pääsy” tarkastelemaan Redux storen sisällä olevaa tilaa.

NoteList viittaa edelleen suoraan kontekstin kautta storen metodiin dispatch, jota se tarvitsee action creatorin importanceToggling avulla tehdyn actionin dispatchaamiseen:

toggleImportance = (id) => () => {
  this.context.store.dispatch(
    importanceToggling(id)
  )

Connect-funktion toisena parametrina voidaan määritellä mapDispatchToProps eli joukko action creator -funktioita, jotka välitetään yhdistetylle komponentille propseina. Laajennetaan connectausta seuraavasti

const mapStateToProps = (state) => {
  return {
    notes: state.notes,
    filter: state.filter
  }
}

const mapDispatchToProps = {
  importanceToggling
}

const ConnectedNoteList = connect(
  mapStateToProps,
  mapDispatchToProps
)(NoteList)

export default ConnectedNoteList

Nyt komponentti voi dispatchata suoraan action creatorin importanceToggling määrittelemän actionin kutsumalla propsien kautta saamaansa funktiota koodissa:

class NoteList extends React.Component {
  toggleImportance = (id) => () => {
    this.props.importanceToggling(id)
  }

  // ...
}

Storen dispatch-funktiota ei enää tarvitse kutsua, sillä connect on muokannut action creatorin importanceToggling sellaiseen muotoon, joka sisältää dispatchauksen.

mapDispatchToProps lienee aluksi hieman haastava ymmärtää, etenkin sen kohta käsiteltävä vaihtoehtoinen käyttötapa.

Connectin aikaansaamaa tilannetta voidaan havainnollistaa seuraavasti:

eli sen lisäksi että NoteList pääsee storen tilaan propsien props.notes ja props.filter kautta, se viittaa props.importanceToggling:lla funktioon, jonka avulla storeen saadaan dispatchattua TOGGLE_IMPORTANCE-tyyppisiä actioneja.

Koska komponentti saa storeen liittyvät asiat propseina, voidaan koodista poistaa metodit componentDidMount ja componentWillUnMount jotka huolehtivat komponentin uudelleenrenderöitymisestä storen tilan muuttuessa. Connect tekee tämän puolestamme.

Komponentti NoteList ei tarvitse storea enää mihinkään, se saa kaiken tarvitsemansa propseina connect-funktion ansiosta. Komponentti ei käytä enää suoraan contextia, joten koodi yksinkertaistuu seuraavaan muotoon:

import React from 'react'
import { connect } from 'react-redux'
import { importanceToggling } from './../reducers/noteReducer'
import Note from './Note'

class NoteList extends React.Component {
  render() {
    const notesToShow = () => {
      const { notes, filter } = this.props
      if (filter === 'ALL') {
        return notes
      }

      return filter === 'IMPORTANT'
        ? notes.filter(note => note.important)
        : notes.filter(note => !note.important)
    }

    return (
      <ul>
        {notesToShow().map(note =>
          <Note
            key={note.id}
            note={note}
            handleClick={() => this.props.importanceToggling(note.id)}
          />
        )}
      </ul>
    )
  }
}

const mapStateToProps = (state) => {
  return {
    notes: state.notes,
    filter: state.filter
  }
}

const ConnectedNoteList = connect(
  mapStateToProps,
  { importanceToggling }
)(NoteList)

export default ConnectedNoteList

Koodi sisältää pari muutakin oikaisua, mm. apumetodista toggleImportance on hankkiuduttu eroon. Itseasiassa komponentti on nyt niin yksinkertainen että se voitaisiin määritellä funktionaalisena komponenttina. Emme kuitenkaan tee muutosta nyt.

Otetaan connect käyttöön myös uuden muistiinpanon luomisessa:

import React from 'react'
import { noteCreation } from './../reducers/noteReducer'
import { connect } from 'react-redux'

class NoteForm extends React.Component {

  addNote = (event) => {
    event.preventDefault()
    this.props.noteCreation(event.target.note.value)
    event.target.note.value = ''
  }

  render() {
    return (
      <form onSubmit={this.addNote}>
        <input name="note" />
        <button>lisää</button>
      </form>
    )
  }
}

export default connect(
  null,
  { noteCreation }
)(NoteForm)

Koska komponentti ei tarvitse storen tilasta mitään, on funktion connect ensimmäinen parametri null.

Sovelluksen tämänhetkinen koodi on githubissa tagissä part6-3.

Provider

Funktion connect käytön edellytyksenä on se, että sovellus on määritelty React redux kirjaston tarjoaman Provider-komponentin lapseksi ja että sovelluksen käyttämä store on annettu Provider-komponentin attribuutiksi store:

import React from 'react'
import ReactDOM from 'react-dom'
import { createStore, combineReducers } from 'redux'
import { Provider } from 'react-redux'
import App from './App'
import noteReducer from './reducers/noteReducer'
import filterReducer from './reducers/filterReducer'

const reducer = combineReducers({
  notes: noteReducer,
  filter: filterReducer
})

const store = createStore(reducer)

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

Lisäsimme jo edellisen osan lopussa sovellukseen Providerin, joten connect oli tällä kertaa suoraan käytettävissä.

Huomio propsina välitettyyn action creatoriin viittaamisesta

Tarkastellaan vielä erästä mielenkiintoista seikkaa komponentista NoteForm:

import React from 'react'
import { noteCreation } from './../reducers/noteReducer'
import { connect } from 'react-redux'

class NoteForm extends React.Component {

  addNote = (event) => {
    event.preventDefault()
    this.props.noteCreation(event.target.note.value)
    event.target.note.value = ''
  }

  render() {
    // ...
  }
}

export default connect(
  null,
  { noteCreation }
)(NoteForm)

Aloittelevalle connectin käyttäjälle aiheuttaa joskus ihmetystä se, että action creatorista noteCreation on komponentin sisällä käytettävissä kaksi eri versiota.

Funktioon tulee viitata propsien kautta, eli this.props.noteCreation, tällöin kyseessä on connectin muotoilema, dispatchauksen sisältävä versio funktiosta.

Moduulissa olevan import-lauseen

import { noteCreation } from './../reducers/noteReducer'

ansiosta komponentin sisältä on mahdollista viitata funktioon myös suoraan, eli noteCreation. Näin ei kuitenkaan tule tehdä, sillä silloin on kyseessä alkuperäinen action creator joka ei sisällä dispatchausta.

Jos tulostamme funktiot koodin sisällä (emme olekaan vielä käyttäneet kurssilla tätä erittäin hyödyllistä debug-kikkaa)

render() {
  console.log(noteCreation)
  console.log(this.props.noteCreation)
  return (
    <form onSubmit={this.addNote}>
      <input name="note" />
      <button>lisää</button>
    </form>
  )
}

näemme eron:

Ensimmäinen funktioista siis on normaali action creator, toinen taas connectin muotoilema funktio, joka sisältää storen metodin dispatch-kutsun.

Connect on erittäin kätevä työkalu, mutta abstraktiutensa takia kenties käsitteellisesti haastavin kurssin tähänastisista asioista.

Viimeistään nyt kannattaa katsoa kokonaisuudessaan Egghead.io:ta Reduxin kehittäjän Dan Abramovin loistava tuoriaali Getting started with Redux. Neljässä viimeisessä videossa käsitellään connect-metodia.

Siinä vaiheessa kun videot on tehty, connectin käyttö oli asteen verran nykyistä hankalampaa, sillä esimerkeissä käyttämämme tapa määritellä connectin toinen parametri mapDispatchToProps suoraan action creator -funktioiden avulla ei ollut vielä mahdollinen. Katsotaan seuraavassa luvussa lyhyesti vaihtoehtoista, “hankalampaa” tapaa, sitä näkee usein vanhemmassa React-koodissa, joten sen tunteminen on oleellista.

mapDispatchToPropsin vaihtoehtoinen käyttötapa

Määrittelimme siis connectin komponentille NoteForm antamat actioneja dispatchaavan funktion seuraavasti:

class NoteForm extends React.Component {
  // ...
}

export default connect(
  null,
  { noteCreation }
)(NoteForm)

Eli määrittelyn ansiosta komponentti dispatchaa uuden muistiinpanon lisäyksen suorittavan actionin suoraan komennolla this.props.noteCreation('uusi muistiinpano').

Parametrin mapDispatchToProps kenttinä ei voi antaa mitä tahansa funktiota, vaan funktion on oltava action creator, eli Redux-actionin palauttava funktio.

Kannattaa huomata, että parametri mapDispatchToProps on nyt olio, sillä määrittely

{ noteCreation }

on lyhempi tapa määritellä olioliteraali

{ noteCreation: noteCreation }

eli olio, jonka ainoan kentän noteCreation arvona on funktio noteCreation.

Voimme määritellä saman myös “pitemmän kaavan” kautta, antamalla connectin toisena parametrina seuraavanlaisen funktion:

class NoteForm extends React.Component {
  // ...
}

const mapDispatchToProps = (dispatch) => {
  return {
    createTodo: (value) => {
      dispatch(noteCreation(value))
    }
  }
}

export default connect(
  null,
  mapDispatchToProps
)(NoteForm)

Tässä vaihtoehtoisessa tavassa mapDispatchToProps on funktio, jota connect kutsuu antaen sille parametriksi storen dispatch-funktion. Funktion paluuarvona on olio, joka määrittelee joukon funktioita, jotka annetaan connectoitavalle komponentille propsiksi. Esimerkkimme määrittelee propsin createTodo olevan funktion

(value) => {
  dispatch(noteCreation(value))
}

eli action creatorilla luodun actionin dispatchaus.

Komponentti siis viittaa funktioon propsin this.props.createTodo kautta:

class NoteForm extends React.Component {

  addNote = (event) => {
    event.preventDefault()
    this.props.createTodo(event.target.note.value)
    event.target.note.value = ''
  }

  render() {
    return (
      <form onSubmit={this.addNote}>
        <input name="note" />
        <button>lisää</button>
      </form>
    )
  }
}

Konsepti on hiukan monimutkainen ja sen selittäminen sanallisesti on haastavaa. Kannattaa katsoa huolellisesti Dan Abramovin videot ja koittaa miettiä mistä on kyse.

Useimmissa tapauksissa riittää mapDispatchToProps:in yksinkertaisempi muoto. On kuitenkin tilanteita, joissa monimutkaisempi muoto on tarpeen, esim. jos määriteltäessä propseiksi mäpättyjä dispatchattavia actioneja on viitattava komponentin omiin propseihin.

Presentational/Container revisited

Komponentti NoteList käyttää apumetodia notesToShow, joka päättelee filtterin perusteella näytettävien muistiinpanojen listan:

const notesToShow = () => {
  const { notes, filter } = this.props
  if (filter === 'ALL') {
    return notes
  }

  return filter === 'IMPORTANT'
    ? notes.filter(note => note.important)
    : notes.filter(note => !note.important)
}

Komponentin on tarpeetonta sisältää kaikkea tätä logiikkaa. Eriytetään se komponentin ulkopuolelle connect-metodin parametrin mapStateToProps yhteyteen. Muutetaan komponentti samalla funktionaaliseksi:

import React from 'react'
import { connect } from 'react-redux'
import { importanceToggling } from './../reducers/noteReducer'
import Note from './Note'

const NoteList = (props) => (
  <ul>
    {props.visibleNotes.map(note =>
      <Note
        key={note.id}
        note={note}
        handleClick={() => props.importanceToggling(note.id)}
      />
    )}
  </ul>
)

const notesToShow = (notes, filter) => {
  if (filter === 'ALL') {
    return notes
  }
  return filter === 'IMPORTANT'
    ? notes.filter(note => note.important)
    : notes.filter(note => !note.important)
}

const mapStateToProps = (state) => {
  return {
    visibleNotes: notesToShow(state.notes, state.filter)
  }
}

export default connect(
  mapStateToProps,
  { importanceToggling }
)(NoteList)

mapStateToProps ei siis tällä kertaa mäppää propsiksi suoraan storessa olevaa asiaa, vaan storen tilasta funktion notesToShow avulla muodostetun sopivasti filtteröidyn datan.

Uudistettu NoteList keskittyy lähes ainoastaan muistiinpanojen renderöimiseen, se on hyvin lähellä sitä minkä sanotaan olevan presentational-komponentti, joita Dan Abramovin sanoin kuvailee seuraavasti:

  • Are concerned with how things look.
  • May contain both presentational and container components inside, and usually have some DOM markup and styles of their own.
  • Often allow containment via this.props.children.
  • Have no dependencies on the rest of the app, such Redux actions or stores.
  • Don’t specify how the data is loaded or mutated.
  • Receive data and callbacks exclusively via props.
  • Rarely have their own state (when they do, it’s UI state rather than data).
  • Are written as functional components unless they need state, lifecycle hooks, or performance optimizations.

Connect-metodin avulla muodostettu yhdistetty komponentti

const notesToShow = (notes, filter) => {
  if (filter === 'ALL') {
    return notes
  }
  return filter === 'IMPORTANT'
    ? notes.filter(note => note.important)
    : notes.filter(note => !note.important)
}

const mapStateToProps = (state) => {
  return {
    visibleNotes: notesToShow(state.notes, state.filter)
  }
}

connect(
  mapStateToProps,
  { importanceToggling }
)(NoteList)

taas on selkeästi container-komponentti, joita Dan Abramov luonnehtii seuraavasti:

  • Are concerned with how things work.
  • May contain both presentational and container components inside but usually don’t have any DOM markup of their own except for some wrapping divs, and never have any styles.
  • Provide the data and behavior to presentational or other container components.
  • Call Redux actions and provide these as callbacks to the presentational components.
  • Are often stateful, as they tend to serve as data sources.
  • Are usually generated using higher order components such as connect from React Redux, rather than written by hand.

Komponenttien presentational vs. container -jaottelu on eräs hyväksi havaittu tapa strukturoida React-sovelluksia. Jako voi olla toimiva tai sitten ei, kaikki riippuu kontekstista.

Abramov mainitsee jaon eduiksi muunmuassa seuraavat

  • Better separation of concerns. You understand your app and your UI better by writing components this way.
  • Better reusability. You can use the same presentational component with completely different state sources, and turn those into separate container components that can be further reused.
  • Presentational components are essentially your app’s “palette”. You can put them on a single page and let the designer tweak all their variations without touching the app’s logic. You can run screenshot regression tests on that page.

Abramov mainitsee termin high order component. Esim. NoteList on normaali komponentti, React-reduxin connect metodi taas on high order komponentti, eli käytännössä funktio, joka haluaa parametrikseen komponentin muuttuakseen “normaaliksi” komponentiksi.

High order componentit eli HOC:t ovatkin yleinen tapa määritellä geneeristä toiminnallisuutta, joka sitten erikoistetaan esim. renderöitymisen määrittelyn suhteen parametrina annettavan komponentin avulla. Kyseessä on funktionaalisen ohjelmoinnin etäisesti olio-ohjelmoinnin perintää muistuttava käsite.

HOC:it ovat oikeastaan käsitteen High Order Function (HOF) yleistys. HOF:eja ovat sellaiset funkiot, jotka joko ottavat parametrikseen funktioita tai palauttavat funkioita. Olemme oikeastaan käyttäneet HOF:eja läpi kurssin, esim. lähes kaikki taulukoiden käsittelyyn tarkoitetut metodit, kuten map, filter ja find ovat HOF:eja, samoin jo monta kertaa käyttämämme funktioita palauttavat (eli kahden nuolen) funktiot, esim.

filterClicked = (value) => () => {
  this.props.filterChange(value)
}

Sovelluksen tämänhetkinen koodi on githubissa tagissa part6-4.

Mukana on myös edellisestä unohtunut VisibilityFilter-komponentin connect-funktiota käyttävä versio, jota on myös paranneltu siten, että nappi kaikki on oletusarvoisesti valittuna. Koodissa on pieni ikävä copypaste mutta kelvatkoon.

Tehtäviä

Tee nyt tehtävät 6.6-6.9

Redux-sovelluksen kommunikointi palvelimen kanssa

Laajennetaan sovellusta siten, että muistiinpanot talletetaan backendiin. Käytetään osasta 2 tuttua json-serveriä.

Tallennetaan projektin juuren tiedostoon db.json tietokannan alkutila:

{
  "notes": [
    {
      "content": "reduxin storen toiminnan määrittelee reduceri",
      "important": true,
      "id": 1
    },
    {
      "content": "storen tilassa voi olla mielivaltaista dataa",
      "important": false,
      "id": 2
    }
  ]
}

Asennetaan projektiin json-server

npm install json-server --save

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

"scripts": {
  "server": "json-server -p3001 db.json",
  // ...
}

Käynnistetään json-server komennolla npm run server.

Tehdään sitten tuttuun tapaan axiosia hyödyntävä backendistä dataa hakeva metodi tiedostoon services/notes.js

import axios from 'axios'

const getAll = async () => {
  const response = await axios.get('http://localhost:3001/notes')
  return response.data
}

export default { getAll }

Asennetaan myös axios projektiin

npm install axios --save

Muutetaan nodeReducer:issa tapahtuva muistiinpanojen tilan alustusta, siten että oletusarvoisesti mustiinpanoja ei ole:

const noteReducer = (state = [], action) => {
  // ...
}

Nopea tapa saada storen tila alustettua palvelimella olevan datan perusteella on hakea muistiinpanot tiedostossa index.js ja dispatchata niille yksitellen action NEW_NOTE:

// ...
import noteService from './services/notes'

const reducer = combineReducers({
  notes: noteReducer,
  filter: filterReducer
})

const store = createStore(reducer)

noteService.getAll().then(notes =>
  notes.forEach(note => {
    store.dispatch({ type: 'NEW_NOTE', data: note})
  })
)

// ...

Lisätään reduceriin tuki actionille INIT_NOTES, jonka avulla alustus voidaan tehdä dispatchaamalla yksittäinen action. Luodaan myös sitä varten oma action creator -funktio noteInitialization:

// ...
const noteReducer = (state = [], action) => {
  console.log('ACTION:', action)
  switch(action.type) {
    case 'NEW_NOTE':
      return [...state, action.data]
    case 'INIT_NOTES':
      return action.data
    // ...
  }
}

export const noteInitialization = (data) => {
  return {
    type: 'INIT_NOTES',
    data
  }
}

// ...

index.js yksinkertaistuu:

import noteReducer, { noteInitialization} from './reducers/noteReducer'
// ...

noteService.getAll().then(notes =>
  store.dispatch(noteInitialization(notes))
)

HUOM: miksi emme käyttäneet koodissa promisejen ja then-metodilla rekisteröidyn tapahtumankäsittelijän sijaan awaitia?

await toimii ainoastaan async-funktioiden sisällä, ja index.js:ssä oleva koodi ei ole funktiossa, joten päädyimme tilanteen yksinkertaisuuden takia tällä kertaa jättämään async:in käyttämättä.

Päätetään kuitenkin siirtää muistiinpanojen alustus App-komponentin metodiin componentDidMount, se on luonteva paikka alustuksille, sillä metodi suoritetaan heti sovelluksemme ensimmäisen renderöinnin jälkeen.

Jotta saamme action creatorin noteInitialization käyttöön komponentissa App tarvitsemme jälleen connect-metodin apua:

import React from 'react'
import NoteForm from './components/NoteForm.js'
import NoteList from './components/NoteList.js'
import VisibilityFilter from './components/VisibilityFilter'
import { connect } from 'react-redux'
import { noteInitialization } from './reducers/noteReducer'
import noteService from './services/notes'

class App extends React.Component {
  componentDidMount = async () => {
    const notes = await noteService.getAll()
    this.props.noteInitialization(notes)
  }

  render() {
    return (
      <div>
        <NoteForm />
        <VisibilityFilter />
        <NoteList />
      </div>
    )
  }
}

export default connect(
  null,
  { noteInitialization }
)(App)

Näin funktio noteInitialization tulee komponentin App propsiksi this.props.noteInitialization ja sen kutsumiseen ei tarvita dispatch-metodia koska connect hoitaa asian puolestamme.

Pääsimme nyt myös käyttämään aina mukavaa async/awaitia. Palvelimen kanssa kommunikointi tapahtuu joka tapauksessa funktiossa, joten sen määrittely asyncina on vaivatonta:

componentDidMount = async () => {
  const notes = await noteService.getAll()
  this.props.noteInitialization(notes)
}

Voimme toimia samoin myös uuden muistiinpanon luomisen suhteen. Laajennetaan palvelimen kanssa kommunikoivaa koodia:

const url = 'http://localhost:3001/notes'

const getAll = async () => {
  const response = await axios.get(url)
  return response.data
}

const createNew = async (content) => {
  const response = await axios.post(url, { content, important: false})
  return response.data
}

export default {
  getAll, createNew
}

Komponentin NoteForm metodi addNote muuttuu hiukan:

import React from 'react'
import { noteCreation } from './../reducers/noteReducer'
import { connect } from 'react-redux'
import noteService from '../services/notes'

class NoteForm extends React.Component {

  addNote = async (event) => {
    event.preventDefault()
    const content = event.target.note.value
    event.target.note.value = ''
    const newNote = await noteService.createNew(content)
    this.props.noteCreation(newNote)
  }

  render() {
    //...
  }
}

export default connect(
  null,
  {noteCreation}
)(NoteForm)

Koska backend generoi muistiinpanoille id:t, muutetaan action creator noteCreation muotoon

export const noteCreation = (data) => {
  return {
    type: 'NEW_NOTE',
    data
  }
}

Muistiinpanojen tärkeyden muuttaminen olisi mahdollista toteuttaa samalla periaatteella, eli tehdä palvelimelle ensin asynkroninen metodikutsu ja sen jälkeen dispatchata sopiva action.

Sovelluksen tämänhetkinen koodi on githubissa tagissä part6-5.

Tehtäviä

Tee nyt tehtävät 6.10-6.12

Asynkroniset actionit ja redux thunk

Lähestymistapamme on ok, mutta siinä mielessä ikävä, että palvelimen kanssa kommunikointi tapahtuu komponenttien metodeissa. Olisi parempi, jos kommunikointi voitaisiin abstrahoida komponenteilta siten, että niiden ei tarvitsisi kuin kutsua sopivaa action creatoria, esim. App alustaisi sovelluksen tilan seuraavasti:

class App extends React.Component {
  componentDidMount() {
    this.props.initializeNotes()
  }
  // ...
}

ja NoteForm loisi uuden muistiinpanon seuraavasti:

class NoteForm extends React.Component {

  addNote = async (event) => {
    event.preventDefault()
    const content = event.target.note.value
    event.target.note.value = ''
    this.props.createNote(content)
  }
}

Molemmat komponentit käyttäisivät ainoastaan propsina saamaansa funktiota, välittämättä siitä että taustalla tapahtuu todellisuudessa palvelimen kanssa tapahtuvaa kommunikointia.

Asennetaan nyt redux-thunk-kirjasto, joka mahdollistaa asynkronisten actionien luomisen. Asennus tapahtuu komennolla:

npm install --save redux-thunk

redux-thunk-kirjasto on ns. redux-middleware joka täytyy ottaa käyttöön storen alustuksen yhteydessä. Eriytetään samalla storen määrittely omaan tiedostoon store.js:

import { createStore, combineReducers, applyMiddleware } from 'redux'
import thunk from 'redux-thunk'

import noteReducer from './reducers/noteReducer'
import filterReducer from './reducers/filterReducer'

const reducer = combineReducers({
  notes: noteReducer,
  filter: filterReducer
})

const store = createStore(
  reducer,
  applyMiddleware(thunk)
)

export default store

Tiedosto src/index.js on muutoksen jälkeen seuraava

import React from 'react'
import ReactDOM from 'react-dom'
import { Provider } from 'react-redux'
import App from './App'
import store from './store'

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

redux-thunkin ansiosta on mahdollista määritellä action creatoreja siten, että ne palauttavat funktion, jonka parametrina on redux-storen dispatch-metodi. Tämän ansiosta on mahdollista tehdä asynkronisia action creatoreja, jotka ensin odottavat jonkin toimenpiteen valmistumista ja vasta sen jälkeen dispatchaavat varsinaisen actionin.

Voimme nyt määritellä muistiinpanojen alkutilan palvelimelta hakevan action creatorin initializeNotes seuraavasti:

export const initializeNotes = () => {
  return async (dispatch) => {
    const notes = await noteService.getAll()
    dispatch({
      type: 'INIT_NOTES',
      data: notes
    })
  }
}

Sisemmässä funktiossaan, eli asynkronisessa actionissa operaatio hakee ensin palvelimelta kaikki muistiinpanot ja sen jälkeen dispatchaa muistiinpanot storeen lisäävän actionin.

Komponentti App voidaan nyt määritellä seuraavasti:

class App extends React.Component {
  componentDidMount () {
    this.props.initializeNotes()
  }

  render() {
    return (
      <div>
        <NoteForm />
        <NoteList />
        <VisibilityFilter />
      </div>
    )
  }
}

export default connect(
  null, { initializeNotes }
)(App)

Ratkaisu on elegantti, muistiinpanojen alustuslogiikka on eriytetty kokonaan React-komponenttien ulkopuolelle.

Uuden muistiinpanon lisäävä action creator createNew on seuraavassa

export const createNew = (content) => {
  return async (dispatch) => {
    const newNote = await noteService.createNew(content)
    dispatch({
      type: 'NEW_NOTE',
      data: newNote
    })
  }
}

Periaate on jälleen sama, ensin suoritetaan asynkroninen operaatio, ja sen valmistuttua dispatchataan storen tilaa muuttava action.

Lomake muuttuu seuraavasti:

class NoteForm extends React.Component {

  addNote = async (event) => {
    event.preventDefault()
    const content = event.target.note.value
    event.target.note.value = ''
    this.props.createNew(content)
  }

  render() {
    return (
      <form onSubmit={this.addNote}>
        <input name='note' />
        <button>lisää</button>
      </form>
    )
  }
}

export default connect(
  null, { createNew }
)(NoteForm)

Sovelluksen tämänhetkinen koodi on githubissa tagissä part6-6.

Redux DevTools

Chromeen on asennettavissa Redux DevTools, jonka avulla Redux-storen tilaa ja sitä muuttavia actioneja on mahdollisuus seurata selaimen konsolista.

Selaimen lisäosan lisäksi debugatessa tarvitaan kirjastoa redux-devtools-extension. Asennetaan se komennolla

npm install --save redux-devtools-extension

Storen luomistapaa täytyy hieman muuttaa, että kirjasto saadaan käyttöön:

// ...
import { createStore, combineReducers, applyMiddleware } from 'redux'
import thunk from 'redux-thunk'
import { composeWithDevTools } from 'redux-devtools-extension'

import noteReducer from './reducers/noteReducer'
import filterReducer from './reducers/filterReducer'

const reducer = combineReducers({
  notes: noteReducer,
  filter: filterReducer
})

const store = createStore(
  reducer,
  composeWithDevTools(
    applyMiddleware(thunk)
  )
)

export default store

Kun nyt avaat konsolin, välilehti redux näyttää seuraavalta:

Konsolin avulla on myös mahdollista dispatchata actioneja storeen

Storen tietyn hetkisen tilan lisäksi on myös mahdollista tarkastella, mikä on kunkin actionin tilalle aiheuttama muutos:

tehtäviä

Tee nyt tehtävät 6.13-6.15

React router

Palataan jälleen Reduxittoman Reactin pariin.

On erittäin tyypillistä, että web-sovelluksissa on navigaatiopalkki, jonka avulla on mahdollista vaihtaa sovelluksen näkymää. Muistiinpanosovelluksemme voisi sisältää pääsivun:

ja omat sivunsa muistiinpanojen ja käyttäjien tietojen näyttämiseen:

Vanhan koulukunnan websovelluksessa sovelluksen näyttämän sivun vaihto tapahtui siten että selain teki palvelimelle uuden HTTP GET -pyynnön ja renderöi sitten palvelimen palauttaman näkymää vastaavan HTML-koodin.

Single page appeissa taas ollaan todellisuudessa koko ajan samalla sivulla, ja selaimessa suoritettava Javascript-koodi luo illuusion eri “sivuista”. Jos näkymää vaihdettaessa tehdään HTTP-kutsuja, niiden avulla haetaan ainoastaan JSON-muotoista dataa jota uuden näkymän näyttäminen ehkä edellyttää.

Navigaatiopalkki ja useita näkymiä sisältävä sovellus on erittäin helppo toteuttaa Reactilla.

Seuraavassa on eräs tapa:

const Home = () => (
  <div> <h2>TKTL notes app</h2> </div>
)

const Notes = () => (
  <div> <h2>Notes</h2> </div>
)

const Users = () => (
  <div> <h2>Users</h2> </div>
)

class App extends React.Component {
  constructor() {
    super()
    this.state = {
      page: 'home'
    }
  }

  toPage = (page) => (event) => {
    event.preventDefault()
    this.setState({ page })
  }

  render() {
    const content = () => {
      if (this.state.page === 'home') {
        return <Home />
      } else if (this.state.page === 'notes') {
        return <Notes />
      } else if (this.state.page === 'users') {
        return <Users />
      }
    }

    return (
      <div>
        <div>
          <a href="" onClick={ this.toPage('home') }>home</a> &nbsp;
          <a href="" onClick={ this.toPage('notes') }>notes</a> &nbsp;
          <a href="" onClick={ this.toPage('users') }>users</a>
        </div>

        {content()}
      </div>
    )
  }
}

Eli jokainen näkymä on toteutettu omana komponenttinaan ja sovelluksen tilassa pidetään tieto siitä, minkä näkymää vastaava komponentti menupalkin alla näytetään.

Huom: navigointivalikossa oleva &nbsp; tarkoittaa a-tagien väliin sjijoitettavaa välilyöntiä. CSS:n käyttö olisi luonnollisesti parempi tapa sivun ulkoasun muotoilulle mutta nyt tyydymme quick’n’dirty-ratkaisuun.

Menetelmä ei kuitenkaan ole optimaalinen. Kuten kuvista näkyy, sivuston osoite pysyy samana vaikka välillä ollaankin eri näkymässä. Jokaisella näkymällä tulisi kuitenkin olla oma osoitteensa, jotta esim. bookmarkien tekeminen olisi mahdollista. Sovelluksessamme ei myöskään selaimen back-painike toimi loogisesti, eli back ei vie edelliseksi katsottuun sovelluksen näkymään vaan jonnekin ihan muualle. Jos sovellus kasvaisi suuremmaksi ja sinne haluttaisiin esim. jokaiselle käyttäjälle sekä muistiinpanolle oma yksittäinen näkymänsä, itse koodattu reititys eli sivuston navigaationhallinta menisi turhan monimutkaiseksi.

Reactissa on onneksi valmis komponentti React router joka tarjoaa erinomaisen ratkaisun React-sovelluksen navigaation hallintaan.

Muutetaan ylläoleva sovellus käyttämään React routeria. Asennetaan React router komennolla

npm install --save react-router-dom

React routerin tarjoama reititys saadaan käyttöön muuttamalla sovellusta seuraavasti:

import { BrowserRouter as Router, Route, Link } from 'react-router-dom'

class App extends React.Component {

  render() {
    return (
      <div>
        <Router>
          <div>
            <div>
              <Link to="/">home</Link> &nbsp;
              <Link to="/notes">notes</Link> &nbsp;
              <Link to="/users">users</Link>
            </div>
            <Route exact path="/" render={() => <Home />} />
            <Route path="/notes" render={() => <Notes />} />
            <Route path="/users" render={() => <Users />} />
          </div>
        </Router>
      </div>
    )
  }
}

Reititys, eli komponenttien ehdollinen, selaimen urliin perustuva renderöinti otetaan käyttöön sijoittamalla komponentteja Router-komponentin lapsiksi, eli Router-tagien sisälle.

Huomaa, että vaikka komponenttiin viitataan nimellä Router kyseessä on BrowserRouter, sillä importtaus tapahtuu siten, että importattava olio uudelleennimetään:

import { BrowserRouter as Router ... } from 'react-router-dom'

Manuaalin mukaan

BrowserRouter is a Router that uses the HTML5 history API (pushState, replaceState and the popstate event) to keep your UI in sync with the URL.

Normaalisti selain lataa uuden sivun osoiterivillä olevan urlin muuttuessa. HTML5 history API:n avulla BrowserRouter kuitenkin mahdollistaa sen, että selaimen osoiterivillä olevaa urlia voidaan käyttää React-sovelluksen sisäiseen “reitittämiseen”, eli vaikka osoiterivillä oleva url muuttuu, sivun sisältöä manipuloidaan ainoastaan Javascriptillä ja selain ei lataa uutta sisältöä palvelimelta. Selaimen toiminta back- ja forward-toimintojen ja bookmarkien tekemisen suhteen on kuitenkin loogista, eli toimii kuten perinteisillä web-sivuilla.

Routerin sisälle määritellään selaimen osoiteriviä muokkaavia linkkejä komponentin Link avulla. Esim.

<Link to="/notes">notes</Link>

luo sovellukseen linkin, jonka teksti on notes ja jonka klikkaaminen vaihtaa selaimen osoiteriville urliksi /notes.

Selaimen urliin perustuen renderöitävät komponentit määritellään komponentin Route avulla. Esim.

<Route path="/notes" render={() => <Notes />} />

määrittelee, että jos selaimen osoiteena on /notes, renderöidään komponentti Notes.

Sovelluksen juuren, eli osoitteen / määritellään renderöivän komponentti Home:

<Route exact path="/" render={() => <Home />} />

joudumme käyttämään routen path attribuutin edessä määrettä exact, muuten Home renderöityy kaikilla muillakin poluilla, sillä juuri / on kaikkien muiden polkujen alkuosa.

parametroitu route

Tarkastellaan sitten hieman modifioitua versiota edellisestä esimerkistä. Esimerkin koodi kokonaisuudessaan on täällä.

Sovellus sisältää nyt viisi eri näkymää, joiden näkyvyyttä kontrolloidaan routerin avulla. Edellisestä esimerkistä tuttujen komponenttien Home, Notes ja Users lisäksi mukana on kirjautumisnäkymää vastaava Login ja yksittäisen muistiinpanon näkymää vastaava Note.

Home ja Users ovat kuten aiemmassa esimerkissä. Notes on hieman monimutkaisempi, se renderöi propseina saamansa muistiinpanojen listan siten, että jokaisen muistiinpanon nimi on klikattavissa

Nimen klikattavuus on toteutettu komponentilla Link ja esim. muistiinpanon, jonka id on 3 nimen klikkaaminen aiheuttaa selaimen osoitteen arvon päivittymisen muotoon notes/3:

const Notes = ({notes}) => (
  <div>
    <h2>Notes</h2>
    <ul>
      {notes.map(note=>
        <li key={note.id}>
          <Link to={`/notes/${note.id}`}>{note.content}</Link>
        </li>
      )}
    </ul>
  </div>
)

Kun selain siirtyy muistiinpanon yksilöivään osoitteeseen, esim. notes/3, renderöidään komponentti Note:

const Note = ({note}) => {
  return(
  <div>
    <h2>{note.content}</h2>
    <div>{note.user}</div>
    <div><strong>{note.important ? 'tärkeä' : ''}</strong></div>
  </div>
)}

Tämä tapahtuu laajentamalla komponentissa App olevaa reititystä seuraavasti:

<div>
  <Router>
    <div>
      <div>
        <Link to="/">home</Link> &nbsp;
        <Link to="/notes">notes</Link> &nbsp;
        <Link to="/users">users</Link> &nbsp;
      </div>

      <Route exact path="/" render={() => <Home />} />
      <Route exact path="/notes" render={() =>
        <Notes notes={this.state.notes} />}
      />
      <Route exact path="/notes/:id" render={({match}) =>
        <Note note={noteById(match.params.id)} />}
      />
    </div>
  </Router>
</div>

Kaikki muistiinpanon renderöivään routeen on lisätty määre exact path=”/notes” sillä muuten se renderöityisi myös /notes/3-muotoisten polkujen yhteydessä.

Yksittäisen muistiinpanon näkymän renderöivä route määritellään “expressin tyyliin” merkkaamalla reitin parametrina oleva osa merkinnällä :id

<Route exact path="/notes/:id" />

Renderöityvän komponentin määrittävä render-attribuutti pääsee käsiksi id:hen parametrinsa match avulla seuraavasti:

render={({match}) => <Note note={noteById(match.params.id)} />}

Muuttujassa match.params.id olevaa id:tä vastaava muistiinpano selvitetään apufunktion noteById avulla

const noteById = (id) =>
  this.state.notes.find(note => note.id === Number(id))

renderöityvä Note-komponentti saa siis propsina urlin yksilöivää osaa vastaavan muistiinpanon.

history

Sovellukseen on myös toteutettu erittäin yksinkertainen kirjautumistoiminto. Jos sovellukseen ollaan kirjautuneena, talletetaan tieto kirjautuneesta käyttäjästä komponentin App tilaan this.state.user.

Mahdollisuus Login-näkymään navigointiin renderöidään menuun ehdollisesti

<Router>
  <div>
    <div>
      <Link to="/">home</Link> &nbsp;
      <Link to="/notes">notes</Link> &nbsp;
      <Link to="/users">users</Link> &nbsp;
      {this.state.user
        ? <em>{this.state.user} logged in</em>
        : <Link to="/login">login</Link>
      }
    </div>
  ...
  </div>
</Router>

eli jos käyttäjä on kirjaantunut, renderöidäänkin linkin Login sijaan kirjautuneen käyttäjän käyttäjätunnus:

Kirjautumisen toteuttamiseen liittyy eräs mielenkiintoinen seikka. Kirjaantumislomakkeelle mennään selaimen osoitteen ollessa /login. Toiminnallisuuden määrittelevä Route on seuraavassa

<Route path="/login" render={({history}) =>
  <Login history={history} onLogin={this.login} />}
/>

Routen render-attribuutissa määritelty metodi ottaa nyt vastaan olion history, joka tarjoaa mm. mahdollisuuden manipuloida selaimen osoiterivin arvoa ohjelmallisesti.

Renderöitävälle Login-näkymälle annetaan parametriksi history-olio ja kirjautumisen komponentin App tilaan synkronoiva funktio this.login:

<Login history={history} onLogin={this.login}/>}

Komponentin koodi seuraavassa

const Login = ({onLogin, history}) => {
  const onSubmit = (event) => {
    event.preventDefault()
    onLogin(event.target.username.value)
    history.push('/')
  }
  return (
    <div>
      <h2>login</h2>
      <form onSubmit={onSubmit}>
        <div>
          username: <input />
        </div>
        <div>
          password: <input type="password" />
        </div>
        <button type="submit">login</button>
      </form>
    </div>
  )
}

Kirjautumisen yhteydessä funktiossa onSubmit kutsutaan history-olion metodia push. Käytetty komento history.push('/') saa aikaan sen, että selaimen osoiteriville tulee osoitteeksi / ja sovellus renderöi osoitetta vastaavan komponentin Home.

redirect

Näkymän Users routeen liittyy vielä eräs mielenkiintoinen detalji:

<Route path="/users" render={() =>
  this.state.user
    ? <Users />
    : <Redirect to="/login" />
  }/>

Jos käyttäjä ei ole kirjautuneena, ei renderöidäkään näkymää Users vaan sen sijaan uudelleenohjataan käyttäjä Redirect-komponentin avulla kirjautumisnäkymään

<Redirect to="/login" />

Todellisessa sovelluksessa olisi kenties parempi olla kokonaan näyttämättä navigaatiovalikossa kirjautumista edellyttäviä näkymiä jos käyttäjä ei ole kirjautunut sovellukseen.

Seuraavassa vielä komponentin App koodi kokonaisuudessaan:

class App extends React.Component {
  constructor() {
    super()
    this.state = {
      notes: [
        {
          id: 1,
          content: 'HTML on helppoa',
          important: true,
          user: 'Matti Luukkainen'
        },
        // ...
      ],
      user: null
    }
  }

  login = (user) => {
    this.setState({user})
  }

  render() {
    const noteById = (id) =>
      this.state.notes.find(note => note.id === Number(id))

    return (
      <div>
        <Router>
          <div>
            <div>
              <Link to="/">home</Link> &nbsp;
              <Link to="/notes">notes</Link> &nbsp;
              <Link to="/users">users</Link> &nbsp;
              {this.state.user
                ? <em>{this.state.user} logged in</em>
                : <Link to="/login">login</Link>
              }
            </div>

            <Route exact path="/" render={() => <Home />} />
            <Route exact path="/notes" render={() => <Notes notes={this.state.notes}/>} />
            <Route exact path="/notes/:id" render={({match}) =>
              <Note note={noteById(match.params.id)} />}
            />
            <Route path="/users" render={() =>
              this.state.user
                ? <Users />
                : <Redirect to="/login" />
              }/>
            <Route path="/login" render={({history}) =>
              <Login history={history} onLogin={this.login} />}
            />
          </div>
        </Router>
        <div>
          <em>Note app, Department of Computer Science 2018</em>
        </div>
      </div>
    )
  }
}

Render-metodissa määritellään myös kokonaan Router:in ulkopuolella oleva nykyisille web-sovelluksille tyypillinen footer-elementti, eli sivuston pohjalla oleva osa, joka on näkyvillä riippumatta siitä mikä komponentti sovelluksen reititetyssä osassa näytetään.

Huom: edellä olevassa esimerkissä käytetään React Routerin versiota 4.2.6. Jos ja kun etsit esimerkkejä internetistä, kannattaa varmistaa, että niissä käytetään Routerista vähintään versiota 4.0. Nelosversio ei ole ollenkaan alaspäinyhteensopiva kolmosen kanssa, eli vanhaa React Routeria käyttävä koodi on täysin käyttökelvotonta Routerin versiota 4 käytettäessä.

tehtäviä

Tee nyt tehtävät 6.16-6.18

Inline-tyylit

Osan 2 lopussa lisäsimme React-sovellukseen tyylejä vanhan koulukunnan tapaan yhden koko sovelluksen tyylit määrittelevän CSS-tiedoston avulla.

Olemme jo muutamaan kertaan määritelleet komponenteille inline-tyylejä, eli määritelleet CSS:ää suoraan komponentin muun koodin seassa.

Edellisessä osassa piilotimme inline-tyylin avulla napin ruudusta tietyissä tapauksissa:

const hideWhenVisible = { display: this.state.visible ? 'none' : '' }

<div style={hideWhenVisible}>
  <button onClick={this.toggleVisibility}>{this.props.buttonLabel}</button>
</div>

eli jos this.state.visible oli arvoltaan tosi, liitetään div-komponenttiin sen näkymättömäksi asettava tyyli

{ display: 'none' }

Periaate inline-tyylien määrittelyssä on siis 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 hieman eri tavalla kuin normaaleissa CSS-tiedostoissa. Jos haluamme asettaa jollekin elementille vihreän, kursivoidun ja 16 pikselin korkuisen fontin, eli CSS-syntaksilla ilmaistuna

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

tulee tämä muotilla Reactin inline-tyylin määrittelevänä oliona seuraavasti

const footerStyle = {
  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 muotoilla edellisen luvun footer-elementin olion footerStyle avulla seuraavasti:

<div style={footerStyle}>
  <br />
  <em>Note app, Department of Computer Science 2018</em>
</div>

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

Inline-tyylit ja muutamat seuraavassa osassa 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 on kaikki kirjoitettu omiin tiedostoihinsa.

Itseasiassa Reactin filosofia on täysin päinvastainen. Koska CSS:n, HTML:n ja Javascriptin erottelu eri tiedostoihin ei ole kuitenkaan osoittautunut erityisen skaalautuvaksi ratkaisuksi suurissa järjestelmissä, on Reactissa periaatteena tehdä erottelu (eli 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ä.

tehtäviä

Tee nyt tehtävät 6.19 ja 6.20

Valmiit käyttöliittymätyylikirjastot

Eräs lähestymistapa sovelluksen tyylien määrittelyyn on valmiin “UI frameworkin”, eli suomeksi ehkä käyttöliittymätyylikirjaston käyttö.

Ensimmäinen laajaa kuuluisuutta saanut UI framework oli Twitterin kehittämä Bootstrap, joka lienee edelleen UI frameworkeista eniten käytetty. Viime aikoina UI frameworkeja on noussut kuin sieniä sateella. Valikoima on niin iso, ettei tässä kannata edes yrittää tehdä tyhjentävää listaa.

Monet UI-frameworkit sisältävät web-sovellusten käyttöön valmiiksi määriteltyjä teemoja sekä “komponentteja”, kuten painikkeita, menuja, taulukkoja. Termi komponentti on edellä kirjotettu hipsuissa sillä kyse ei ole samasta asiasta kuin React-komponentti. Useimmiten UI-frameworkeja käytetään sisällyttämällä sovellukseen frameworkin määrittelemät CSS-tyylitiedostot sekä Javascript-koodi.

Monesta UI-frameworkista on tehty React-ystävällisiä versiota, joissa UI-frameworkin avulla määritellyistä “komponenteista” on tehty React-komponentteja. Esim. Bootstrapista on olemassa parikin React-versiota reactstrap ja react-bootstrap.

Katsotaan seuraavaksi kahta UI-framworkia bootstrapia ja semantic ui:ta. Lisätään molempien avulla samantapaiset tyylit luvun React-router sovellukseen.

react bootstrap

Aloitetaan bootstrapista, käytetään kirjastoa react-bootstrap.

Asennetaan kirjasto suorittamalla komento

npm install --save react-bootstrap

Lisätään sitten sovelluksen public/index.html tiedoston head-tagin sisään bootstrapin css-määrittelyt lataava rivi:

<head>
  <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u" crossorigin="anonymous">
  // ...
</head>

Kun sovellus ladataan uudelleen, näyttää se jo aavistuksen tyylikkäämmältä:

Bootstrapissa koko sivun sisältö renderöidään yleensä container:ina, eli käytännössä koko sovelluksen ympäröivä div-elementti merkitään luokalla container:

// ...

class App extends React.Component {
  // ...
  render() {
    return (
      <div className="container">
        // ...
      </div>
    )
  }
}

Sovelluksen ulkoasu muuttuu siten, että sisältö ei ole enää yhtä kiinni selaimen reunoissa:

Muutetaan seuraavaksi komponenttia Notes siten, että se renderöi muistiinpanojen listan taulukkona. React bootstrap tarjoaa valmiin komponentin Table, joten CSS-luokan käyttöön ei ole tarvetta.

const Notes = ({notes}) => (
  <div>
    <h2>Notes</h2>
    <Table striped>
      <tbody>
        {notes.map(note=>
          <tr key={note.id}>
            <td>
              <Link to={`/notes/${note.id}`}>{note.content}</Link>
            </td>
            <td>
              {note.user}
            </td>
          </tr>
        )}
      </tbody>
    </Table>
  </div>
)

Ulkoasu on varsin tyylikäs:

Huomaa, että koodissa käytettävät React bootstrapin komponentit täytyy importata, eli koodiin on lisättävä:

import { Table } from 'react-bootstrap'

Lomake

Parannellaan seuraavaksi näkymän Login kirjautumislomaketta Bootstrapin lomakkeiden avulla.

React bootstrap tarjoaa valmiit komponentit myös lomakkeiden muodostamiseen (dokumentaatio tosin ei ole paras mahdollinen):

const Login = ({onLogin, history}) => {
  // ...
  return (
    <div>
      <h2>login</h2>
      <form onSubmit={onSubmit}>
        <FormGroup>
          <ControlLabel>username:</ControlLabel>
          <FormControl
            type="text"
            name="username"
          />
          <ControlLabel>password:</ControlLabel>
          <FormControl
            type="password"
          />
          <Button bsStyle="success" type="submit">login</Button>
        </FormGroup>
      </form>
    </div>
)}

Importoitavien komponenttien määrä kasvaa:

import { Table, FormGroup, FormControl, ControlLabel, Button } from 'react-bootstrap'

Lomake näyttää parantelun jälkeen seuraavalta:

Notifikaatio

Toteutetaan sovellukseen kirjautumisen jälkeinen notifikaatio:

Asetetaan notifikaatio kirjautumisen yhteydessä komponentin App tilan kenttään message:

login = (user) => {
  this.setState({user, message: `welcome ${user}`})
  setTimeout(() => {
    this.setState({message: null})
  }, 10000)
}

ja renderöidään viesti Bootstrapin Alert-komponentin avulla. React bootstrap tarjoaa tähän jälleen valmiin React-komponentin:

{(this.state.message &&
  <Alert color="success">
    {this.state.message}
  </Alert>
)}

Muutetaan vielä lopuksi sovelluksen navigaatiomenu käyttämään Bootstrapin Navbaria. Tähänkin React bootstrap tarjoaa valmiit komponentit, dokumentaatio on hieman kryptistä, mutta trial and error johtaa lopulta toimivaan ratkaisuun:

<Navbar inverse collapseOnSelect>
  <Navbar.Header>
    <Navbar.Brand>
      Anecdote app
    </Navbar.Brand>
    <Navbar.Toggle />
  </Navbar.Header>
  <Navbar.Collapse>
    <Nav>
      <NavItem href="#">
        <Link to="/">home</Link>
      </NavItem>
      <NavItem href="#">
        <Link to="/notes">notes</Link>
      </NavItem>
      <NavItem href="#">
        <Link to="/users">users</Link>
      </NavItem>
      <NavItem>
        {this.state.user
          ? <em>{this.state.user} logged in</em>
          : <Link to="/login">login</Link>
        }
      </NavItem>
    </Nav>
  </Navbar.Collapse>
</Navbar>

Ulkoasu on varsin tyylikäs

Jos selaimen kokoa kaventaa, huomaamme että menu “kollapsoituu” ja sen saa näkyville vain klikkaamalla:

Bootstrap ja valtaosa tarjolla olevista UI-frameworkeista tuottavat responsiivisia näkymiä, eli sellaisia jotka renderöityvät vähintään kohtuullisesti monen kokoisilla näytöillä.

Chromen konsolin avulla on mahdollista simuloida sovelluksen käyttöä erilaisilla mobiilipäätteillä

Sovellus toimii hyvin, mutta konsoliin vilkaisu paljastaa erään ikävän detaljin:

Syy valituksiin on navigaatiorakenteessa

<NavItem href="#">
  <Link to="/">home</Link>
</NavItem>

Nämä sisäkkäiset komponentit sisältävät molemmat a-tagin ja React hermostuu tästä.

Ongelma on ikävä ja sen kiertäminen on toki mahdollista, katso esim. https://serverless-stack.com/chapters/adding-links-in-the-navbar.html

Esimerkin sovelluksen koodi kokonaisuudessaan täällä.

Semantic UI

Olen käyttänyt bootstrapia vuosia, mutta siirryin hiljattain Semantic UI:n käyttäjäksi. Kurssin tehtävien palautusovellus on tehty Semanticilla ja kokemukset ovat olleet rohkaisevia, erityisesti semanticin React-tuki on ensiluokkainen ja dokumentaatiokin huomattavasti parempi kuin bootstrapissa.

Lisätään nyt React-router-sovellukselle edellisen luvun tapaan tyylit semanticilla.

Aloitetaan asentamalla semantic-ui-react-kirjasto:

npm install --save semantic-ui-react

Lisätään sitten sovelluksen tiedostoon public/index.html head-tagin sisään semanticin css-määrittelyt lataava rivi (joka löytyy tästä):

<head>
  <link rel="stylesheet" href="//cdnjs.cloudflare.com/ajax/libs/semantic-ui/2.2.12/semantic.min.css"></link>
  // ...
</head>

Sijoitetaan koko sovelluksen renderöimä sisältö Semanticin komponentin Container sisälle.

Semanticin dokumentaatio sisältää jokaisesta komponentista useita esimerkkikoodinpätkiä, joiden avulla komponenttien käytön periaatteet on helppo omaksua:

Muutetaan komponentin App uloin div-elementti Containeriksi:

import { Container } from 'semantic-ui-react'

// ...

class App extends React.Component {
  // ...
  render() {
    return (
      <Container>
        // ...
      </Container>
    )
  }
}

Sivun sisältö ei ole enää reunoissa kiinni:

Edellisen luvun tapaan, renderöidään muistiinpanot taulukkona, komponentin Table avulla. Koodi näyttää seuraavalta

import { Table } from 'semantic-ui-react'

const Notes = ({ notes }) => (
  <div>
    <h2>Notes</h2>
    <Table striped celled>
      <Table.Body>
        {notes.map(note =>
          <Table.Row key={note.id}>
            <Table.Cell>
              <Link to={`/notes/${note.id}`}>{note.content}</Link>
            </Table.Cell>
            <Table.Cell>
              {note.user}
            </Table.Cell>
          </Table.Row>
        )}
      </Table.Body>
    </Table>
  </div>
)

Muistiinpanojen lista näyttää seuraavalta:

Lomake

Otetaan kirjautumissivulla käyttöön Semanticin Form-komponentti:

import { Form, Button } from 'semantic-ui-react'

const Login = ({ onLogin, history }) => {
  const onSubmit = (event) => {
    // ...
  }
  return (
    <div>
      <h2>login</h2>
      <Form onSubmit={onSubmit}>
        <Form.Field>
          <label>username</label>
          <input name='username' />
        </Form.Field>
        <Form.Field>
          <label>password</label>
          <input type='password' />
        </Form.Field>
        <Button type='submit'>login</Button>
      </Form>
    </div>
  )
}

Ulkoasu näyttää seuraavalta:

Notifikaatio

Edellisen luvun tapaan, toteutetaan sovellukseen kirjautumisen jälkeinen notifikaatio:

Kuten edellisessä luvussa, asetetaan notifikaatio kirjautumisen yhteydessä komponentin App tilan kenttään message:

login = (user) => {
  this.setState({user, message: `welcome ${user}`})
  setTimeout(() => {
    this.setState({message: null})
  }, 10000)
}

ja renderöidään viesti käyttäen komponenttia Message:

{(this.state.message &&
  <Message success>
    {this.state.message}
  </Message>
)}

Navigaatiorakenne toteutetaan komponentin Menu avulla:

<Menu inverted>
  <Menu.Item link>
    <Link to="/">home</Link>
  </Menu.Item>
  <Menu.Item link>
    <Link to="/notes">notes</Link>
  </Menu.Item>
  <Menu.Item link>
    <Link to="/users">users</Link>
  </Menu.Item>
  <Menu.Item link>
    {this.state.user
      ? <em>{this.state.user} logged in</em>
      : <Link to="/login">login</Link>
    }
  </Menu.Item>
</Menu>

Lopputulos näyttää seuraavalta:

Bootstrapin yhteydessä esiintynyttä sisäkkäisen a-tagien ongelmaa ei semanticin kanssa ole.

Esimerkin sovelluksen koodi kokonaisuudessaan täällä.

Loppuhuomioita

Ero react-bootstrapin ja semantic-ui-reactin välillä ei ole suuri. On makuasia kummalla tuotettu ulkoasu on tyylikkäämpi. Oma vuosia kestäneen bootstrapin käytön jälkeinen siirtymiseni semanticiin johtuu semanticin saumattomammasta React-tuesta, laajemmasta valmiiden komponenttien valikoimasta ja paremmasta sekä selkeämmästä dokumentaatiosta. Semantic UI projektin kehitystyön jatkuvuuden suhteen on kuitenkin viime aikoina ollut ilmoilla muutamia kysymysmerkkejä, ja tilannetta kannattaakin seurata.

Esimerkissä käytettiin UI-frameworkeja niiden React-integraatiot tarjoavien kirjastojen kautta.

Sen sijaan että käytimme kirjastoa React bootstrap, olisimme voineet aivan yhtä hyvin käyttää Bootstrapia suoraan, liittämällä HTML-elementteihin CSS-luokkia. Eli sen sijaan että määrittelimme esim. taulukon komponentin Table avulla

<Table striped>
  // ...
</Table>

olisimme voineet käyttää normaalia HTML:n taulukkoa table ja CSS-luokkaa

<table className="table striped">
  // ...
</table>

Taulukon määrittelyssä React bootstrapin tuoma etu ei ole suuri.

Tiiviimmän ja ehkä paremmin luettavissa olevan kirjoitusasun lisäksi toinen etu React-kirjastoina olevissa UI-frameworkeissa on se, että kirjastojen mahdollisesti käyttämä Javascript-koodi on sisällytetty React-komponentteihin. Esim. osa Bootstrapin komponenteista edellyttää toimiakseen muutamaakin ikävää Javascript-riippuvuutta joita emme mielellään halua React-sovelluksiin sisällyttää.

React-kirjastoina tarjottavien UI-frameworkkien ikävä puoli verrattuna frameworkin “suoraan käyttöön” on React-kirjastojen API:n mahdollinen epästabiilius ja osittain huono dokumentaatio. Tosin react-semanticin suhteen tilanne on paljon parempi kuin monien muiden UI-frameworkien sillä kyseessä on virallinen React-integraatio.

Kokonaan toinen kysymys on se kannattaako UI-frameworkkeja ylipäätän käyttää. Kukin muodostakoon oman mielipiteensä, mutta CSS:ää taitamattomalle ja puutteellisilla design-taidoilla varustetulle ne ovat varsin käyttökelpoisia työkaluja.

Muita UI-frameworkeja

Luetellaan tässä kaikesta huolimatta muitakin UI-frameworkeja. Jos oma suosikkisi ei ole mukana, tee pull request

Alun perin tässä osassa oli tarkoitus käyttää Material UI:ta, mutta kirjasto on juuri nyt kiivaan kehityksen alla ennen version 1.0 julkaisemista ja osa dokumentaation esimerkeistä ei toiminut uusimmalla versiolla. Voikin olla viisainta odotella Materialin kanssa versiota 1.0.

Tehtäviä

Tee nyt tehtävät 6.21-6.23