Tämä on 3. periodilla pidetyn, jo päättyneen kurssin sisältö.

Jos haluat suorittaa kurssin nyt, mene osoitteeseen https://fullstackopen-2019.github.io/

a

Flux-arkkitehtuuri ja Redux

Olemme noudattaneet sovelluksen tilan hallinnassa Reactin suosittelemaa käytäntöä määritellä tila ja sitä käsittelevät metodit sovelluksen juurikomponentissa. Tilaa ja sitä käsitteleviä funktioita on välitetty propsien avulla niitä tarvitseville komponenteille. Tämä toimii johonkin pisteeseen saakka, mutta kun sovellusten koko kasvaa, muuttuu tilan hallinta haasteelliseksi.

Flux-arkkitehtuuri

Facebook kehitti tilan hallinnan ongelmia helpottamaan Flux-arkkitehtuurin. Fluxissa sovelluksen tilan hallinta erotetaan kokonaan Reactin komponenttien ulkopuolisiin varastoihin eli storeihin. Storessa olevaa tilaa ei muuteta suoraan, vaan tapahtumien eli actionien avulla.

Kun action muuttaa storen tilaa, renderöidään näkymät uudelleen:

Jos sovelluksen käyttö, esim. napin painaminen aiheuttaa tarpeen tilan muutokseen, tehdään tilanmuutos actonin avulla. Tämä taas aiheuttaa uuden näytön renderöitymisen:

Flux tarjoaa siis standardin tavan sille miten ja missä sovelluksen tila pidetään sekä tavalle tehdä tilaan muutoksia.

Redux

Facebookilla on olemassa valmis toteutus Fluxille, käytämme kuitenkin saman periaatteen mukaan toimivaa, mutta hieman yksinkertaisempaa Redux-kirjastoa, jota myös Facebookilla käytetään nykyään alkuperäisen Flux-toteutuksen sijaan.

Tutustutaan Reduxiin tekemällä jälleen kerran laskurin toteuttava sovellus:

fullstack content

Tehdään uusi create-react-app-sovellus ja asennetaan siihen redux komennolla

npm install redux --save

Fluxin tapaan Reduxissa sovelluksen tila talletetaan storeen.

Koko sovelluksen tila talletetaan yhteen storen tallettamaan Javascript-objektiin. Koska sovelluksemme ei tarvitse mitään muuta tilaa kuin laskurin arvon, talletetaan se storeen suoraan. Jos sovelluksen tila olisi monipuolisempi, talletettaisiin "eri asiat" storessa olevaan olioon erillisinä kenttinä.

Storen tilaa muutetaan actionien avulla. Actionit ovat olioita, joilla on vähintään actionin tyypin määrittelevä kenttä type. Sovelluksessamme tarvitsemme esimerkiksi seuraavaa actionia:

{
  type: 'INCREMENT'
}

Jos actioneihin liittyy dataa, määritellään niille tarpeen vaatiessa muitakin kenttiä. Laskurisovelluksemme on kuitenkin niin yksinkertainen, että actioneille riittää pelkkä tyyppikenttä.

Actionien vaikutus sovelluksen tilaan määritellään reducerin avulla. Käytännössä reducer on funktio, joka saa parametrikseen olemassaolevan staten tilan sekä actionin ja palauttaa staten uuden tilan.

Määritellään nyt sovelluksellemme reduceri:

const counterReducer = (state, action) => {
  if (action.type === 'INCREMENT') {
    return state + 1
  } else if (action.type === 'DECREMENT') {
    return state - 1
  } else if (action.type === 'ZERO') {
    return 0
  }

  return state
}

Ensimmäinen parametri on siis storessa oleva tila. Reducer palauttaa uuden tilan actionin tyypin mukaan.

Muutetaan koodia vielä hiukan. Reducereissa on tapana käyttää if:ien sijaan switch-komentoa. Määritellään myös parametrille state oletusarvoksi 0. Näin reducer toimii vaikka store -tilaa ei olisi vielä alustettu.

const counterReducer = (state = 0, action) => {
  switch (action.type) {
    case 'INCREMENT':
      return state + 1
    case 'DECREMENT':
      return state - 1
    case 'ZERO':
      return 0
    default: // jos ei mikään ylläolevista tullaan tänne
    return state
  }
}

Reduceria ei ole tarkoitus kutsua koskaan suoraan sovelluksen koodista. Reducer ainoastaan annetaan parametrina storen luovalle createStore-funktiolle:

import { createStore } from 'redux'

const counterReducer = (state = 0, action) => {
  // ...
}

const store = createStore(counterReducer)

Store käyttää nyt reduceria käsitelläkseen actioneja, jotka dispatchataan eli "lähetetään" storelle sen dispatch-metodilla:

store.dispatch({type: 'INCREMENT'})

Storen tilan saa selville metodilla getState.

Esim. seuraava koodi

const store = createStore(counterReducer)
console.log(store.getState())
store.dispatch({type: 'INCREMENT'})
store.dispatch({type: 'INCREMENT'})
store.dispatch({type: 'INCREMENT'})
console.log(store.getState())
store.dispatch({type: 'ZERO'})
store.dispatch({type: 'DECREMENT'})
console.log(store.getState())

tulostaisi konsoliin


0
3
-1

sillä ensin storen tila on 0. Kolmen INCREMENT-actionin jälkeen tila on 3, ja lopulta actionien ZERO ja DECREMENT jälkeen -1.

Kolmas tärkeä metodi storella on subscribe, jonka avulla voidaan määritellä takaisinkutsufunktioita, joita store kutsuu sen tilan muuttumisen yhteydessä.

Jos esim. lisäisimme seuraavan funktion subscribe:lla, tulostuisi jokainen storen muutos konsoliin.

store.subscribe(() => {
  const storeNow = store.getState()
  console.log(storeNow)
})

eli koodi

const store = createStore(counterReducer)

store.subscribe(() => {
  const storeNow = store.getState()
  console.log(storeNow)
})

store.dispatch({ type: 'INCREMENT' })
store.dispatch({ type: 'INCREMENT' })
store.dispatch({ type: 'INCREMENT' })
store.dispatch({ type: 'ZERO' })
store.dispatch({ type: 'DECREMENT' })

aiheuttaisi tulostuksen


1
2
3
0
-1

Laskurisovelluksemme koodi on seuraavassa. Kaikki koodi on kirjoitettu samaan tiedostoon, jolloin store on suoraan React-koodin käytettävissä. Tutustumme React/Redux-koodin parempiin strukturointitapoihin myöhemmin.

import React from 'react'
import ReactDOM from 'react-dom'
import { createStore } from 'redux'

const counterReducer = (state = 0, action) => {
  switch (action.type) {
    case 'INCREMENT':
      return state + 1
    case 'DECREMENT':
      return state - 1
    case 'ZERO':
      return 0
    default:
      return state
  }
}

const store = createStore(counterReducer)

const App = () => {
  return (
    <div>
      <div>
        {store.getState()}
      </div>
      <button 
        onClick={e => store.dispatch({ type: 'INCREMENT' })}
      >
        plus
      </button>
      <button
        onClick={e => store.dispatch({ type: 'DECREMENT' })}
      >
        minus
      </button>
      <button 
        onClick={e => store.dispatch({ type: 'ZERO' })}
      >
        zero
      </button>
    </div>
  )
}

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

renderApp()
store.subscribe(renderApp)

Koodissa on pari huomionarvoista seikkaa. App renderöi laskurin arvon kysymällä sitä storesta metodilla store.getState(). Nappien tapahtumankäsittelijät dispatchaavat suoraan oikean tyyppiset actionit storelle.

Kun storessa olevan tilan arvo muuttuu, ei React osaa automaattisesti renderöidä sovellusta uudelleen. Olemmekin rekisteröineet koko sovelluksen renderöinnin suorittavan funktion renderApp kuuntelemaan storen muutoksia metodilla store.subscribe. Huomaa, että joudumme kutsumaan heti alussa metodia renderApp, ilman kutsua sovelluksen ensimmäistä renderöintiä ei koskaan tapahdu.

Redux-muistiinpanot

Tavoitteenamme on muuttaa muistiinpanosovellus käyttämään tilanhallintaan Reduxia. Katsotaan kuitenkin ensin eräitä konsepteja hieman yksinkertaistetun muistiinpanosovelluksen kautta.

Sovelluksen ensimmäinen versio seuraavassa

const noteReducer = (state = [], action) => {
  if (action.type === 'NEW_NOTE') {
    state.push(action.data)
    return state
  }

  return state
}

const store = createStore(noteReducer)

store.dispatch({
  type: 'NEW_NOTE',
  data: {
    content: 'sovelluksen tila talletetaan storeen',
    important: true,
    id: 1
  }
})

store.dispatch({
  type: 'NEW_NOTE',
  data: {
    content: 'tilanmuutokset tehdään actioneilla',
    important: false,
    id: 2
  }
})

const App = () => {
  return(
    <div>
      <ul>
        {store.getState().map(note=>
          <li key={note.id}>
            {note.content} <strong>{note.important ? 'tärkeä' : ''}</strong>
          </li>
        )}
        </ul>
    </div>
  )
}

Toistaiseksi sovelluksessa ei siis ole toiminnallisuutta uusien muistiinpanojen lisäämiseen, voimme kuitenkin tehdä sen dispatchaamalla NEW_NOTE-tyyppisiä actioneja koodista.

Actioneissa on nyt tyypin lisäksi kenttä data, joka sisältää lisättävän muistiinpanon:

{
  type: 'NEW_NOTE',
  data: {
    content: 'tilanmuutokset tehdään actioneilla',
    important: false,
    id: 2
  }
}

puhtaat funktiot, immutable

Reducerimme alustava versio on yksinkertainen:

const noteReducer = (state = [], action) => {
  if (action.type === 'NEW_NOTE') {
    state.push(action.data)
    return state
  }

  return state
}

Tila on nyt taulukko. NEW_NOTE-tyyppisen actionin seurauksena tilaan lisätään uusi muistiinpano metodilla push.

Sovellus näyttää toimivan, mutta määrittelemämme reduceri on huono, se rikkoo Reduxin reducerien perusolettamusta siitä, että reducerien tulee olla puhtaita funktioita.

Puhtaat funktiot ovat sellaisia, että ne eivät aiheuta mitään sivuvaikutuksia ja niiden tulee aina palauttaa sama vastaus samoilla parametreilla kutsuttaessa.

Lisäsimme tilaan uuden muistiinpanon metodilla state.push(action.data) joka muuttaa state-olion tilaa. Tämä ei ole sallittua. Ongelma korjautuu helposti käyttämällä metodia concat, joka luo uuden taulukon, jonka sisältönä on vanhan taulukon alkiot sekä lisättävä alkio:

const noteReducer = (state = [], action) => {
  if (action.type === 'NEW_NOTE') {
    return state.concat(action.data)
  }

  return state
}

Reducen tilan tulee koostua muuttumattomista eli immutable olioista. Jos tilaan tulee muutos, ei vanhaa oliota muuteta, vaan se korvataan uudella muuttuneella oliolla. Juuri näin toimimme uudistuneessa reducerissa, vanha taulukko korvaantuu uudella.

Laajennetaan reduceria siten, että se osaa käsitellä muistiinpanon tärkeyteen liittyvän muutoksen:

{
  type: 'TOGGLE_IMPORTANCE',
  data: {
    id: 2
  }
}

Koska meillä ei ole vielä koodia joka käyttää ominaisuutta, laajennetaan reduceria testivetoisesti. Aloitetaan tekemällä testi actionin NEW_NOTE käsittelylle.

Jotta testaus olisi helpompaa, siirretään reducerin koodi ensin omaan moduuliinsa tiedostoon src/reducers/noteReducer.js. Otetaan käyttöön myös kirjasto deep-freeze, jonka avulla voimme varmistaa, että reducer on määritelty oikeaoppisesti puhtaana funktiona. Asennetaan kirjasto kehitysaikaiseksi riippuvuudeksi

npm install --save-dev deep-freeze

Testi, joka määritellään tiedostoon src/reducers/noteReducer.test.js on sisällöltään seuraava:

import noteReducer from './noteReducer'
import deepFreeze from 'deep-freeze'

describe('noteReducer', () => {
  test('returns new state with action NEW_NOTE', () => {
    const state = []
    const action = {
      type: 'NEW_NOTE',
      data: {
        content: 'sovelluksen tila talletetaan storeen',
        important: true,
        id: 1
      }
    }

    deepFreeze(state)
    const newState = noteReducer(state, action)

    expect(newState.length).toBe(1)
    expect(newState).toContainEqual(action.data)
  })
})

Komento deepFreeze(state) varmistaa, että reducer ei muuta parametrina olevaa storen tilaa. Jos reduceri käyttää state:n manipulointiin komentoa push, testi ei mene läpi

fullstack content

Tehdään sitten testi actionin TOGGLE_IMPORTANCE käsittelylle:

test('returns new state with action TOGGLE_IMPORTANCE', () => {
  const state = [
    {
      content: 'sovelluksen tila talletetaan storeen',
      important: true,
      id: 1
    },
    {
      content: 'tilanmuutokset tehdään actioneilla',
      important: false,
      id: 2
    }]

  const action = {
    type: 'TOGGLE_IMPORTANCE',
    data: {
      id: 2
    }
  }

  deepFreeze(state)
  const newState = noteReducer(state, action)

  expect(newState.length).toBe(2)

  expect(newState).toContainEqual(state[0])

  expect(newState).toContainEqual({
    content: 'tilanmuutokset tehdään actioneilla',
    important: true,
    id: 2
  })
})

Eli seuraavan actionin

{
  type: 'TOGGLE_IMPORTANCE',
  data: {
    id: 2
}

tulee muuttaa id:n 2 omaavan muistiinpanon tärkeyttä.

Reduceri laajenee seuraavasti

const noteReducer = (state = [], action) => {
  switch(action.type) {
    case 'NEW_NOTE':
      return state.concat(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
  }
}

Luomme tärkeyttä muuttaneesta muistiinpanosta kopion osasta 2 tutulla syntaksilla ja korvaamme tilan uudella tilalla, mihin otetaan muuttumattomat muistiinpanot ja muutettavasta sen muutettu kopio changedNote.

Kerrataan vielä mitä koodissa tapahtuu. Ensin etsitään olio, jonka tärkeys on tarkoitus muuttaa:

const noteToChange = state.find(n => n.id === id)

luodaan sitten uusi olio, joka on muuten kopio muuttuvasta oliosta mutta kentän important arvo on muutettu päinvastaiseksi:

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

Palautetaan uusi tila, joka saadaan ottamalla kaikki vanhan tilan muistiinpanot paitsi uusi juuri luotu olio tärkeydeltään muuttavasta muistiinpanosta:

state.map(note =>
  note.id !== id ? note : changedNote 
)

array spread -syntaksi

Koska reducerilla on nyt suhteellisen hyvät testit, voimme refaktoroida koodia turvallisesti.

Uuden muistiinpanon lisäys luo palautettavan tilan taulukon concat-funktiolla. Katsotaan nyt miten voimme toteuttaa saman hyödyntämällä Javascriptin array spread -syntaksia:

const noteReducer = (state = [], action) => {
  switch(action.type) {
    case 'NEW_NOTE':
      return [...state, action.data]
    case 'TOGGLE_IMPORTANCE':
      // ...
    default:
    return state
  }
}

Spread-syntaksi toimii seuraavasti. Jos määrittelemme

const luvut = [1, 2, 3]

niin ...luvut hajottaa taulukon yksittäisiksi alkioiksi, eli voimme sijoittaa sen esim. toisen taulukon sisään:

[...luvut, 4, 5]

ja lopputuloksena on taulukko, jonka sisältö on [1, 2, 3, 4, 5].

Jos olisimme sijoittaneet taulukon toisen sisälle ilman spreadia, eli

[luvut, 4, 5]

lopputulos olisi ollut [ [1, 2, 3], 4, 5].

Samannäköinen syntaksi toimii taulukosta destrukturoimalla alkioita otettaessa siten, että se kerää loput alkiot:

const luvut = [1, 2, 3, 4, 5, 6]

const [eka, toka, ...loput] = luvut

console.log(eka)    // tulostuu 1
console.log(toka)   // tulostuu 2
console.log(loput)  // tulostuu [3, 4, 5, 6]

ei-kontrolloitu lomake

Lisätään sovellukseen mahdollisuus uusien muistiinpanojen tekemiseen sekä tärkeyden muuttamiseen:

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

const App = () => {
  const addNote = (event) => {
    event.preventDefault()
    const content = event.target.note.value
    store.dispatch({
      type: 'NEW_NOTE',
      data: {
        content,
        important: false,
        id: generateId()
      }
    })
    event.target.note.value = ''
  }

  const toggleImportance = (id) => () => {
    store.dispatch({
      type: 'TOGGLE_IMPORTANCE',
      data: { id }
    })
  }

  return (
    <div>
      <form onSubmit={addNote}>
        <input name="note" /> 
        <button type="submit">lisää</button>
      </form>
      <ul>
        {store.getState().map(note =>
          <li
            key={note.id} 
            onClick={() => toggleImportance(note.id)}
          >
            {note.content} <strong>{note.important ? 'tärkeä' : ''}</strong>
          </li>
        )}
      </ul>
    </div>
  )
}

Molemmat toiminnallisuudet on toteutettu suoraviivaisesti. Huomionarvoista uuden muistiinpanon lisäämisessä on nyt se, että toisin kuin aiemmat Reactilla toteutetut lomakkeet, emme ole nyt sitoneet lomakkeen kentän arvoa komponentin App tilaan. React kutsuu tälläisiä lomakkeita ei-kontrolloiduiksi.

Ei-kontrolloiduilla lomakkeilla on tiettyjä rajoitteita (ne eivät esim. mahdollista lennossa annettavia validointiviestejä, lomakkeen lähetysnapin disabloimista sisällön perusteella ym...), meidän käyttötapaukseemme ne kuitenkin tällä kertaa sopivat. Voit halutessasi lukea aiheesta enemmän täältä.

Muistiinpanon lisäämisen käsittelevä metodi on yksinkertainen, se ainoastaan dispatchaa muistiinpanon lisäävän actionin:

addNote = (event) => {
  event.preventDefault()
  const content = event.target.note.value  store.dispatch({
    type: 'NEW_NOTE',
    data: {
      content,
      important: false,
      id: generateId()
    }
  })
  event.target.note.value = ''
}

Uuden muistiinpanon sisältö saadaan suoraan lomakkeen syötekentästä, johon kentän nimeämisen ansiosta päästään käsiksi tapahtumaolion kautta event.target.note.value. Kannattaa huomata, että syötekentällä on oltava nimi, jotta sen arvoon on mahdollista päästä käsiksi:

<form onSubmit={addNote}>
  <input name="note" />  <button type="submit">lisää</button>
</form>

Tärkeyden muuttaminen tapahtuu klikkaamalla muistiinpanon nimeä. Käsittelijä on erittäin yksinkertainen:

toggleImportance = (id) => {
  store.dispatch({
    type: 'TOGGLE_IMPORTANCE',
    data: { id }
  })
}

action creatorit

Alamme huomata, että jo näinkin yksinkertaisessa sovelluksessa Reduxin käyttö yksinkertaistaa sovelluksen ulkoasusta vastaavaa koodia. Pystymme kuitenkin vielä paljon parempaan.

React-komponenttien on oikeastaan tarpeetonta tuntea reduxin actionien tyyppejä ja esitysmuotoja. Eristetään actioiden luominen omiksi funktioiksi:

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

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

Actioneja luovia funktioita kutsutaan action creatoreiksi.

Komponentin App ei tarvitse enää tietää mitään actionien sisäisestä esitystavasta, se saa sopivan actionin kutsumalla creator-funktiota:

const App = () => {
  const addNote = (event) => {
    event.preventDefault()
    store.dispatch(
      createNote(event.target.note.value)    )
    event.target.note.value = ''
  }
  
  const toggleImportance = (id) => () => {
    store.dispatch(
      toggleImportanceOf(id)    )
  }

  // ...
}

staten välittäminen propseissa

Sovelluksemme on reduceria lukuunottamatta tehty samaan tiedostoon. Kyseessä ei tietenkään ole järkevä käytäntö, eli on syytä eriyttää App omaan moduuliinsa.

Herää kuitenkin kysymys miten App pääsee muutoksen jälkeen käsiksi storeen? Ja yleisemminkin, kun komponentti koostuu suuresta määrästä komponentteja, tulee olla jokin mekanismi, minkä avulla komponentit pääsevät käsiksi storeen.

Tapoja on muutama. Yksinkertaisin vaihtoehto on välittää store propsien avulla. Sovelluksen käynnistyspiste index.js typistyy seuraavasti

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

const store = createStore(noteReducer)

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

renderApp()
store.subscribe(renderApp)

Muutos omaan moduuliinsa eriytettyyn komponenttiin App on pieni, storeen viitataan propsien kautta props.store:

import React from 'react'
import {   createNote, toggleImportanceOf} from './reducers/noteReducer' 
const App = (props) => {
  const store = props.store
  const addNote = (event) => {
    event.preventDefault()
    store.dispatch(
      createNote(event.target.note.value)
    )
    event.target.note.value = ''
  }

  const toggleImportance = (id) => {
    store.dispatch(
      toggleImportanceOf(id)
    )
  }

  return (
    <div>
      <form onSubmit={addNote}>
        <input name="note" />
        <button type="submit">lisää</button>
      </form>
      <ul>
        {store.getState().map(note =>
          <li
            key={note.id}
            onClick={() => toggleImportance(note.id)}
          >
            {note.content} <strong>{note.important ? 'tärkeä' : ''}</strong>
          </li>
        )}
      </ul>
    </div>
  )
}

export default App

Action creator -funktioiden määrittely on siirretty reducerin kanssa samaan tiedostoon

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

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

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

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

export default noteReducer

Jos sovelluksessa on enemmän storea tarvitsevia komponentteja, tulee App-komponentin välittää store propseina kaikille sitä tarvitseville komponenteille.

Moduulissa on nyt useita export-komentoja.

Reducer-funktio palautetaan edelleen komennolla export default. Tämän ansiosta reducer importataan tuttuun tapaan:

import noteReducer from './reducers/noteReducer'

Moduulilla voi olla vain yksi default export, mutta useita "normaaleja" exporteja

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

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

Normaalisti (eli ei defaultina) exportattujen funktioiden käyttöönotto tapahtuu aaltosulkusyntaksilla:

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

Eriytetään uuden muistiinpanon luominen omaksi komponentiksi.

import { createNote } from '../reducers/noteReducer'
const NewNote = (props) => {
  const addNote = (event) => {
    event.preventDefault()
    props.store.dispatch(
      createNote(event.target.note.value)
    )
    event.target.note.value = ''
  }

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

Toisin kuin aiemmin ilman Reduxia tekemässämme React-koodissa, sovelluksen tilaa (joka on nyt siis reduxissa) muuttava tapahtumankäsittelijä on siirretty pois App-komponentista, alikomponentin vastuulle. Itse tilaa muuttava logiikka on kuitenkin siististi reduxissa eristettynä koko sovelluksen React-osuudesta.

Eriytetään veilä muistiinpanojen lista ja yksittäisen muistiinpanon esittäminen omiksi komponenteikseen:

const Note = ({ note, handleClick }) => {
  return(
    <li onClick={handleClick}>
      {note.content} 
      <strong>{note.important ? 'tärkeä' : ''}</strong>
    </li>
  )
}

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

Muistiinpanon tärkeyttä muuttava logiikka on nyt muistiinpanojen listaa hallinnoivalla komponentilla.

Komponenttiin App ei jää enää paljoa koodia:

const App = (props) => {

  return (
    <div>
      <NewNote store={props.store}/>
      <Notes store={props.store} />
    </div>
  )
}

Yksittäisen muistiinpanon renderöinnistä huolehtiva Note on erittäin yksinkertainen, eikä ole tietoinen siitä, että sen propsina saama tapahtumankäsittelijä dispatchaa actionin. Tälläisiä komponentteja kutsutaan Reactin terminologiassa presentational-komponenteiksi.

NoteList taas on sellainen mitä kutsutaan container-komponenteiksi, se sisältää sovelluslogiikkaa, eli määrittelee mitä Note-komponenttien tapahtumankäsittelijät tekevät ja koordinoi presentational-komponenttien, eli Notejen konfigurointia.

Palaamme presentational/container-jakoon tarkemmin myöhemmin tässä osassa.

storen välittäminen sitä tarvitseviin komponentteihin propsien avulla on melko ikävää. Vaikka App ei itse tarvitse storea, sen on otettava store vastaan, pystyäkseen välittämään sen edelleen komponenteille NoteForm ja NoteList. Tähän on kuitenkin tulossa parannus hetken päästä.

Redux-sovelluksen tämänhetkinen koodi on kokonaisuudessaan githubissa, branchissa part6-1.