a

Flux-arkkitehtuuri ja Redux

Olemme noudattaneet sovelluksen tilan hallinnassa Reactin suosittelemaa käytäntöä määritellä useiden komponenttien tarvitsema 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 sovelluksen kasvaessa, 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 actionin 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 storen 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, joten 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: 'the app state is in redux store',
    important: true,
    id: 1
  }
})

store.dispatch({
  type: 'NEW_NOTE',
  data: {
    content: 'state changes are made with actions',
    important: false,
    id: 2
  }
})

const App = () => {
  return(
    <div>
      <ul>
        {store.getState().map(note=>
          <li key={note.id}>
            {note.content} <strong>{note.important ? 'important' : ''}</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: 'state changes are made with actions',
    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: 'the app state is in redux store',
        important: true,
        id: 1
      }
    }

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

    expect(newState).toHaveLength(1)
    expect(newState).toContainEqual(action.data)
  })
})

Testi siis varmistaa, että reducerin palauttama uusi tila on taulukko, joka sisältää yhden elementin, joka on sama kun actionin kentän data sisältävä olio.

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: 'the app state is in redux store',
      important: true,
      id: 1
    },
    {
      content: 'state changes are made with actions',
      important: false,
      id: 2
    }]

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

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

  expect(newState).toHaveLength(2)

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

  expect(newState).toContainEqual({
    content: 'state changes are made with actions',
    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
    event.target.note.value = ''
    store.dispatch({
      type: 'NEW_NOTE',
      data: {
        content,
        important: false,
        id: generateId()
      }
    })
  }

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


  return (
    <div>
      <form onSubmit={addNote}>
        <input name="note" /> 
        <button type="submit">add</button>
      </form>
      <ul>
        {store.getState().map(note =>
          <li
            key={note.id} 
            onClick={() => toggleImportance(note.id)}
          >
            {note.content} <strong>{note.important ? 'important' : ''}</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  event.target.note.value = ''
  store.dispatch({
    type: 'NEW_NOTE',
    data: {
      content,
      important: false,
      id: generateId()
    }
  })
}

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">add</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 actioneiden luominen omiin funktioihinsa:

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()
    const content = event.target.note.value
    event.target.note.value = ''
    store.dispatch(createNote(content))  }
  
  const toggleImportance = (id) => {
    store.dispatch(toggleImportanceOf(id))  }

  // ...
}

Redux-storen välittäminen eri komponenteille

Koko sovellus on toistaiseksi kirjoitettu yhteen tiedostoon ja sen ansiosta joka puolelta sovellusta on päästy käsiksi redux-storeen. Entä jos haluamme jakaa sovelluksen useisiin, omiin komponentteihin sijoitettuihin tiedostoihin?

Tapoja välittää redux-store sovelluksen komponenteille on useita, tutustutaan ensin ehkä uusimpaan ja helpoimpaan tapaan react-redux-kirjaston tarjoamaan hooks-rajapintaan.

Asennetaan react-redux

npm install --save react-redux

Eriytetään komponentti App omaan tiedostoon App.js. Tarkastellaan ensin mitä sovelluksen muiden tiedostojen sisällöksi tulee.

Tiedosto index.js näyttää seuraavalta

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

const store = createStore(noteReducer)

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

Uutta tässä on se, että sovellus on määritelty react redux -kirjaston tarjoaman Provider-komponentin lapsena ja että sovelluksen käyttämä store on annettu Provider-komponentin attribuutiksi store.

Action creator -funktioiden määrittely on siirretty reducerin kanssa samaan tiedostoon reducers/noteReducer.js joka näyttää seuraavalta

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

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 createNote = (content) => {
  // ...
}

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

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

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

Komponentin App koodi

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

const App = () => {
  const dispatch = useDispatch()  const notes = useSelector(state => state)
  const addNote = (event) => {
    event.preventDefault()
    const content = event.target.note.value
    event.target.note.value = ''
    dispatch(createNote(content))  }

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

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

export default App

Komponentin koodissa on muutama mielenkiintoinen seikka. Aiemmin koodi hoiti actionien dispatchaamisen kutsumalla redux-storen metodia dispatch:

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

Nyt sama tapahtuu useDispatch-hookin avulla saatavan dispatch-funktion avulla:

import { useSelector, useDispatch } from 'react-redux'
const App = () => {
  const dispatch = useDispatch()  // ...

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

  // ...
}

React-redux-kirjaston tarjoama useDispatch-hook siis tarjoaa mille tahansa React-komponentille pääsyn tiedostossa index.js määritellyn redux-storen dispatch-funktioon, jonka avulla komponentti pääsee tekemään muutoksia redux-storen tilaan.

Storeen talletettuihin muistiinpanoihin komponentti pääsee käsiksi react-redux-kirjaston useSelector-hookin kautta:

import { useSelector, useDispatch } from 'react-redux'
const App = () => {
  // ...
  const notes = useSelector(state => state)  // ...
}

useSelector saa parametrikseen funktion, joka hakee tai valitsee (engl. select) tarvittavan datan redux-storesta. Tarvitsemme nyt kaikki muistiinpanot, eli selektorifunktiomme palauttaa koko staten, eli on muotoa

state => state

joka siis tarkoittaa samaa kuin

(state) => {
  return state
}

Yleensä selektorifunktiot ovat mielenkiintoisempia, ja valitsevat vain osan redux-storen sisällöstä. Voisimme esimerkiksi hakea storesta ainoastaan tärkeät muistiinpanot seuraavasti

const importantNotes = useSelector(state => state.filter(note => note.important))  

Lisää komponentteja

Eriytetään uuden muistiinpanon luominen omaksi komponentiksi.

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

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

export default NewNote

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 vielä muistiinpanojen lista ja yksittäisen muistiinpanon esittäminen omiksi komponenteikseen (jotka molemmat sijoitetaan tiedostoon Notes.js):

import React from 'react'
import { useDispatch, useSelector } from 'react-redux'import { toggleImportanceOf } from '../reducers/noteReducer'
const Note = ({ note, handleClick }) => {
  return(
    <li onClick={handleClick}>
      {note.content} 
      <strong> {note.important ? 'important' : ''}</strong>
    </li>
  )
}

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

export default Notes

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

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

const App = () => {

  return (
    <div>
      <NewNote />
      <Notes  />
    </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ällaisia komponentteja kutsutaan Reactin terminologiassa presentational-komponenteiksi.

Notes 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.

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