Siirry sisältöön

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 funktiot sovelluksen komponenttirakenteen ylimmissä kompontenteissa. Usein suurin osa tilaa ja sitä funktiot on määritelty 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 tilan hallinta muuttuu haasteelliseksi.

Flux-arkkitehtuuri

Facebook kehitti jo Reactin historian varhaisvaiheissa 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:

Action -> Dispatcher -> Store -> View

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

Action -> Dispatcher -> Store -> View -> Action -> Dispatcher -> View

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, mutta 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:

Renderöity kokonaisluku sekä kolme nappia: plus, minus ja zero

Tehdään uusi Create React App ‑sovellus ja asennetaan siihen Redux:

npm install redux

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 sellaisenaan. Jos sovelluksen tila olisi monimutkaisempi, 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 staten nykyisen tilan sekä actionin ja palauttaa staten uuden tilan.

Määritellään nyt sovelluksellemme reducer:

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. Eli esim. actionin tyypin ollessa INCREMENT tila saa arvokseen vanhan arvon plus yksi. Jos actionin tyyppi on ZERO tilan uusi arvo on nolla.

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 storen tärkeä metodi on subscribe, jonka avulla voidaan määritellä takaisinkutsufunktioita, joita store kutsuu sen tilan muuttumisen yhteydessä.

Esimerkkinä voisimme tulostaa jokaisen storen muutoksen konsoliin näin:

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

Tällöin 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' })

tulostaisi


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/client'

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 root = ReactDOM.createRoot(document.getElementById('root'))

const renderApp = () => {
  root.render(<App />)
}

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, sillä ilman kutsua sovelluksen ensimmäistä renderöintiä ei tapahdu ollenkaan.

Huomautus funktion createStore käytöstä

Tarkkasilmäisimmät huomaavat, että funktion createStore nimen päällä on viiva. Jos hiiren vie nimen päälle, tulee asialle selitystä

fullstack content

Selitys on kokonaisuudessaan seuraava

We recommend using the configureStore method of the @reduxjs/toolkit package, which replaces createStore.

Redux Toolkit is our recommended approach for writing Redux logic today, including store setup, reducers, data fetching, and more.

For more details, please read this Redux docs page: https://redux.js.org/introduction/why-rtk-is-redux-today

configureStore from Redux Toolkit is an improved version of createStore that simplifies setup and helps avoid common bugs.

You should not be using the redux core package by itself today, except for learning purposes. The createStore method from the core redux package will not be removed, but we encourage all users to migrate to using Redux Toolkit for all Redux code.

Funktion createStore sijaan siis suositellaan käytettäväksi hieman "kehittyneempää" funktiota configureStore, ja mekin tulemme ottamaan sen käyttöömme kun olemme ottaneet Reduxin perustoiminnallisuuden haltuun.

Sivuhuomio: createStore on määritelty olevan "deprecated", joka yleensä tarkoittaa sitä, että ominaisuus tulee poistumaan kirjaston jossain uudemmassa versiossa. Yllä oleva selitys ja tämäkin keskustelu paljastavat, että createStore ei tule poistumaan, ja sille onkin annettu ehkä hieman virheellisin perustein status deprecated. Funktio ei siis ole vanhentunut, mutta nykyään on olemassa suositeltavampi, uusi tapa tehdä suunnilleen sama asia.

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 on seuraava:

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

  return state
}

const store = createStore(noteReducer)

store.dispatch({
  type: 'NEW_NOTE',
  payload: {
    content: 'the app state is in redux store',
    important: true,
    id: 1
  }
})

store.dispatch({
  type: 'NEW_NOTE',
  payload: {
    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, mutta voimme toteuttaa sen dispatchaamalla NEW_NOTE-tyyppisiä actioneja koodista.

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

{
  type: 'NEW_NOTE',
  payload: {
    content: 'state changes are made with actions',
    important: false,
    id: 2
  }
}

Kentän nimen valinta ei ole sattumanvarainen. Yleinen konventio on, että actioneilla on juurikin kaksi kenttää, tyypin kertova type ja actionin mukana olevan tiedon sisältävä payload.

Puhtaat funktiot ja muuttumattomat (immutable) oliot

Reducerimme alustava versio on yksinkertainen:

const noteReducer = (state = [], action) => {
  if (action.type === 'NEW_NOTE') {
    state.push(action.payload)
    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, sillä 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 ne palauttavat aina saman vastauksen samoilla parametreilla kutsuttaessa.

Lisäsimme tilaan uuden muistiinpanon metodilla state.push(action.payload), joka muuttaa state-olion tilaa. Tämä ei ole sallittua. Ongelman voi korjata 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.payload)
  }

  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, eli vanha taulukko korvaantuu uudella.

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

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

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

Konfiguroidaan sovellukseen Jest. Aloitetaan asentamalla joukko kirjastoja:

npm install --save-dev jest @babel/preset-env @babel/preset-react eslint-plugin-jest

Luodaan tiedosto .babelrc, jolla on seuraava sisältö:

{
  "presets": [
    "@babel/preset-env",
    ["@babel/preset-react", { "runtime": "automatic" }]
  ]
}

Lisätään tiedostoon package.json testit suorittava skripti:

{
  // ...
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
    "preview": "vite preview",
    "test": "jest"  },
  // ...
}

Tiedostoon .eslintrc.cjs tulee myös pieni lisäys:

module.exports = {
  root: true,
  env: { 
    browser: true,
    es2020: true,
    "jest/globals": true  },
  // ...
}

Jotta testaus olisi helpompaa, siirretään reducerin koodi ensin omaan moduuliinsa tiedostoon src/reducers/noteReducer.js. Otetaan lisäksi käyttöön 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

Määritellään testi tiedostoon src/reducers/noteReducer.test.js:

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',
      payload: {
        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.payload)
  })
})

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

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

Testi aiheuttaa virheilmoituksen TypeError: Can not add property 0, object is not extensible. Syynä komento state.push(action.payload)

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',
    payload: {
      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',
  payload: {
    id: 2
}

tulee muuttaa tärkeys muistiinpanolle, jonka id on 2.

Reducer laajenee seuraavasti:

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

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

Array spread ‑syntaksi

Koska reducerille 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.payload]
    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',      payload: {        content,        important: false,        id: generateId()      }    })  }
  const toggleImportance = (id) => {    store.dispatch({      type: 'TOGGLE_IMPORTANCE',      payload: { 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ällaisia lomakkeita ei-kontrolloiduiksi.

Ei-kontrolloiduilla lomakkeilla on tiettyjä rajoitteita. Ne eivät mahdollista esim. lennossa annettavia validointiviestejä, lomakkeen lähetysnapin disabloimista sisällön perusteella yms. 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',
    payload: {
      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ärkeys muutetaan klikkaamalla muistiinpanon nimeä. Käsittelijä on erittäin yksinkertainen:

toggleImportance = (id) => {
  store.dispatch({
    type: 'TOGGLE_IMPORTANCE',
    payload: { 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',
    payload: {
      content,
      important: false,
      id: generateId()
    }
  }
}

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

Actioneja luovia funktioita kutsutaan action creatoreiksi.

Komponentin App ei tarvitse enää tietää mitään actionien sisäisestä esitystavasta, vaan 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 minkä ansiosta joka puolelta sovellusta on päästy käsiksi Redux-storeen. Entä jos haluamme jakaa sovelluksen useisiin, omiin tiedostoihinsa sijoitettuihin komponentteihin?

Tapoja välittää Redux-store sovelluksen komponenteille on useita. Tutustutaan ensin ehkä uusimpaan ja helpoimpaan tapaan eli React Redux-kirjaston tarjoamaan hooks-rajapintaan.

Asennetaan react-redux:

npm install react-redux

Eriytetään komponentti App tiedostoon App.jsx. Tarkastellaan kuitenkin ensin mitä sovelluksen muihin tiedostoihin tulee.

Tiedosto main.jsx näyttää seuraavalta:

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

const store = createStore(noteReducer)

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

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',
    payload: {
      content,
      important: false,
      id: generateId()
    }
  }
}

export const toggleImportanceOf = (id) => {  return {
    type: 'TOGGLE_IMPORTANCE',
    payload: { 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'

Tiedoston App.jsx sisältö on seuraava:

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',
  payload: { 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 main.jsx 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))  

Redux-sovelluksen tämänhetkinen koodi on kokonaisuudessaan GitHubissa, branchissa part6-0.

Lisää komponentteja

Eriytetään uuden muistiinpanon luominen omaksi komponentikseen:

import { useDispatch } from 'react-redux'import { createNote } from '../reducers/noteReducer'
const NewNote = () => {
  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.jsx):

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 jää vain vähän 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 komponentti, jota kutsutaan container-komponentiksi. 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.