Olemme käyttäneet redux-storea react-redux-kirjaston hook-apin, eli funktioiden useSelector ja useDispatch avulla.

Tarkastellaan tämän osan lopuksi toista, hieman vanhempaa ja jonkin verran monimutkaisempaa tapaa reduxin käyttöön, eli react-redux -kirjaston määrittelemää connect-funktiota.

Uusissa sovelluksissa kannattaa ehdottomasti käyttää hook-apia, mutta connectin tuntemisesta on hyötyä vanhempia reduxia käyttäviä projekteja ylläpidettävissä.

Redux Storen välittäminen komponentille connect-funktiolla

Muutetaan sovelluksen komponenttia Notes, siten että korvataan hook-apin eli funktioiden useDispatch ja useSelector käyttö funktioilla connect. Komponentin seuraavat osat tulee siis muuttaa:

import React from 'react'
import { useDispatch, useSelector } from 'react-redux'import { toggleImportanceOf } from '../reducers/noteReducer'

const Notes = () => {
  const dispatch = useDispatch()   const notes = useSelector(({filter, notes}) => {    if ( filter === 'ALL' ) {      return notes    }    return filter  === 'IMPORTANT'       ? notes.filter(note => note.important)      : notes.filter(note => !note.important)  })
  return(
    <ul>
      {notes.map(note =>
        <Note
          key={note.id}
          note={note}
          handleClick={() => 
            dispatch(toggleImportanceOf(note.id))          }
        />
      )}
    </ul>
  )
}

export default Notes

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 Notes connectin avulla yhdistetty komponentti:

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

const Notes = () => {
  // ...
}

const ConnectedNotes = connect()(Notes)export default ConnectedNotes

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 Notes = (props) => {  const dispatch = useDispatch()

  const notesToShow = () => {    if ( props.filter === 'ALL ') {      return props.notes    }        return props.filter  === 'IMPORTANT'       ? props.notes.filter(note => note.important)      : props.notes.filter(note => !note.important)  }
  return(
    <ul>
      {notesToShow().map(note =>        <Note
          key={note.id}
          note={note}
          handleClick={() => 
            dispatch(toggleImportanceOf(note.id))
          }
        />
      )}
    </ul>
  )
}

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

const ConnectedNotes = connect(mapStateToProps)(Notes)
export default ConnectedNotes

on komponentin Notes sisällä mahdollista viitata storen tilaan, esim. muistiinpanoihin suoraan propsin kautta props.notes. Vastaavasti props.filter viittaa storessa olevaan filter-kentän tilaan.

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

fullstack content

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

Komponentti NoteList ei oikeastaan tarvitse mihinkään tietoa siitä mikä filtteri on valittuna, eli filtteröintilogiikka voidaan siirtää kokonaan sen ulkopuolelle, ja palauttaa propsina notes suoraan sopivalla tavalla filtteröidyt muistiinpanot:

const Notes = (props) => {  const dispatch = useDispatch()

  return(
    <ul>
      {props.notes.map(note =>
        <Note
          key={note.id}
          note={note}
          handleClick={() => 
            dispatch(toggleImportanceOf(note.id))
          }
        />
      )}
    </ul>
  )
}

const mapStateToProps = (state) => {  if ( state.filter === 'ALL' ) {    return {      notes: state.notes    }  }  return {    notes: (state.filter  === 'IMPORTANT'       ? state.notes.filter(note => note.important)      : state.notes.filter(note => !note.important)    )  }}
const ConnectedNotes = connect(mapStateToProps)(Notes)
export default ConnectedNotes  

mapDispatchToProps

Olemme nyt päässeet eroon hookista useSelector, mutta Notes käyttää edelleen hookia useDispatch ja sen palauttavaa funktiota dispatch:

const Notes = (props) => {
  const dispatch = useDispatch()
  return(
    <ul>
      {props.notes.map(note =>
        <Note
          key={note.id}
          note={note}
          handleClick={() => 
            dispatch(toggleImportanceOf(note.id))          }
        />
      )}
    </ul>
  )
}

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 = {  toggleImportanceOf,}
const ConnectedNotes = connect(
  mapStateToProps,
  mapDispatchToProps)(Notes)

export default ConnectedNotes

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

const Notes = (props) => {
  return(
    <ul>
      {props.notes.map(note =>
        <Note
          key={note.id}
          note={note}
          handleClick={() => props.toggleImportanceOf(note.id)}
        />
      )}
    </ul>
  )
}

Eli se sijaan että kutsuttaisiin action creator-funktiota dispatch-funktion kanssa

dispatch(toggleImportanceOf(note.id))

connect-metodia käytettäessä actionin dispatchaamiseen riittää

props.toggleImportanceOf(note.id)

Storen dispatch-funktiota ei enää tarvitse kutsua, sillä connect on muokannut action creatorin toggleImportanceOf 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:

fullstack content

eli sen lisäksi että Notes pääsee storen tilaan propsin props.notes kautta, se viittaa props.toggleImportanceOf:lla funktioon, jonka avulla storeen saadaan dispatchattua TOGGLE_IMPORTANCE-tyyppisiä actioneja.

Connectia käyttämään refaktoroitu komponentti Notes on kokonaisuudessaan seuraava:

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

const Notes = (props) => {
  return(
    <ul>
      {props.notes.map(note =>
        <Note
          key={note.id}
          note={note}
          handleClick={() => props.toggleImportanceOf(note.id)}
        />
      )}
    </ul>
  )
}

const mapStateToProps = (state) => {
  if ( state.filter === 'ALL' ) {
    return {
      notes: state.notes
    }
  }

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

const mapDispatchToProps = {
  toggleImportanceOf
}

// eksportoidaan suoraan connectin palauttama komponentti
export default connect(
  mapStateToProps,
  mapDispatchToProps
)(Notes)

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

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

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

  return (
    <form onSubmit={addNote}>
      <input name="note" />
      <button type="submit">add</button>
    </form>
  )
}

export default connect(  null,   { createNote })(NewNote)

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

Sovelluksen koodi on githubissa branchissa part6-5.

Huomio propsina välitettyyn action creatoriin viittaamisesta

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

import React from 'react'
import { connect } from 'react-redux' 
import { createNote } from '../reducers/noteReducer'
const NewNote = (props) => {
  
  const addNote = async (event) => {
    event.preventDefault()
    const content = event.target.note.value
    event.target.note.value = ''
    props.createNote(content)  }

  return (
    <form onSubmit={addNote}>
      <input name="note" />
      <button type="submit">add</button>
    </form>
  )
}

export default connect(
  null, 
  { createNote })(NewNote)

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

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

Moduulissa olevan import-lauseen

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

ansiosta komponentin sisältä on mahdollista viitata funktioon myös suoraan, eli createNote. 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)

const NewNote = (props) => {
  console.log(createNote)
  console.log(props.createNote)

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

  // ...
}

näemme eron:

fullstack content

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 abstraktiuutensa takia se voi aluksi tuntua hankalalta.

mapDispatchToPropsin vaihtoehtoinen käyttötapa

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

const NewNote = (props) => {
  // ...
}

export default connect(
  null,
  { createNote }
)(NewNote)

Eli määrittelyn ansiosta komponentti dispatchaa uuden muistiinpanon lisäyksen suorittavan actionin suoraan komennolla props.createNote('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

{
  createNote
}

on lyhempi tapa määritellä olioliteraali

{
  createNote: createNote
}

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

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

const NewNote = (props) => {
  // ...
}

const mapDispatchToProps = (dispatch) => {  return {    createNote: (value) => {      dispatch(createNote(value))    },  }}
export default connect(
  null,
  mapDispatchToProps
)(NewNote)

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 createNote olevan funktion

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

eli action creatorilla luodun actionin dispatchaus.

Komponentti siis viittaa funktioon propsin props.createNote kautta:

const NewNote = (props) => {

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

  return (
    <form onSubmit={addNote}>
      <input name="note" />
      <button type="submit">add</button>
    </form>
  )
}

Konsepti on hiukan monimutkainen ja sen selittäminen sanallisesti on haastavaa. Useimmissa tapauksissa onneksi 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.

Egghead.io:sta löytyy Reduxin kehittäjän Dan Abramovin loistava tutoriaali Getting started with Redux, jonka katsomista voin suositella kaikille. Neljässä viimeisessä videossa käsitellään connect-metodia ja nimenomaan sen "hankalampaa" käyttötapaa.

Presentational/Container revisited

Connect-funktiota hyödyntävä versio komponentista Notes keskittyy lähes ainoastaan muistiinpanojen renderöimiseen, se on hyvin lähellä sitä minkä sanotaan olevan presentational-komponentti, joita Dan Abramovin sanoin kuvaillaan 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 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 mapStateToProps = (state) => {
  if ( state.filter === 'ALL' ) {
    return {
      notes: state.notes
    }
  }

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

const mapDispatchToProps = {
  toggleImportanceOf,
}

export default connect(
  mapStateToProps,
  mapDispatchToProps
)(Notes)

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 muun muassa 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. Notes 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 ovat 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.

Reactin hook-apin ilmestymisen jälkeen HOC:ien suosio on kääntynyt laskuun, ja melkein kaikki kirjastot, joiden käyttö on aiemmin perustunut HOC:eihin, ovat saaneet hook-perustaisen apin. Useimmiten, kuten myös reduxin kohdalla, hook-perustaiset apit ovat HOC-apeja huomattavasti yksinkertaisempia.

Redux ja komponenttien tila

Kurssi on ehtinyt pitkälle, ja olemme vihdoin päässeet siihen pisteeseen missä käytämme Reactia "oikein", eli React keskittyy pelkästään näkymien muodostamiseen ja sovelluksen tila sekä sovelluslogiikka on eristetty kokonaan React-komponenttien ulkopuolelle, Reduxiin ja action reducereihin.

Entä useState-hookilla saatava komponenttien oma tila, onko sillä roolia jos sovellus käyttää Reduxia tai muuta komponenttien ulkoista tilanhallintaratkaisua? Jos sovelluksessa on monimutkaisempia lomakkeita, saattaa niiden lokaali tila olla edelleen järkevä toteuttaa funktiolla useState saatavan tilan avulla. Lomakkeidenkin tilan voi toki tallettaa myös reduxiin, mutta jos lomakkeen tila on oleellinen ainoastaan lomakkeen täyttövaiheessa (esim. syötteen muodon validoinnin kannalta), voi olla viisaampi jättää tilan hallinta suoraan lomakkeesta huolehtivan komponentin vastuulle.

Kannattaako reduxia käyttää aina? Tuskinpa. Reduxin kehittäjä Dan Abramov pohdiskelee asiaa artikkelissaan You Might Not Need Redux

Reduxin kaltainen tilankäsittely on mahdollista toteuttaa nykyään myös ilman reduxia, käyttämällä Reactin context-apia ja useReducer-hookia, lisää asiasta esim täällä ja täällä. Tutustumme tähän tapaan myös kurssin yhdeksännessä osassa.