Siirry sisältöön

d

Redux (legacy)

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ä käsittelevistä funktioista on määritelty suoraan sovelluksen juurikomponentissa ja 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 Vite‑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 tiedostoon main.jsx. Tiedosto näyttää aluksi seuraavalta:

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) => {
  switch (action.type) {
    case 'INCREMENT':
      return state + 1
    case 'DECREMENT':
      return state - 1
    case 'ZERO':
      return 0
    default:
      return state
  }
}

const store = createStore(counterReducer)

Koodieditori saattaa huomauttaa, että createStore on vanhentunut. Ei välitetä siitä toistaiseksi, alempana on tarkempi selitys asiasta.

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. Tiedoston main.jsx sisältö näyttää seuraavalta:

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={() => store.dispatch({ type: 'INCREMENT' })}>
        plus
      </button>
      <button onClick={() => store.dispatch({ type: 'DECREMENT' })}>
        minus
      </button>
      <button onClick={() => 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 tiedostossa main.jsx on seuraava:

import ReactDOM from 'react-dom/client'
import { createStore } from 'redux'

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

const root = ReactDOM.createRoot(document.getElementById('root'))

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

renderApp()
store.subscribe(renderApp)

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) => {
  switch (action.type) {
    case 'NEW_NOTE':
      state.push(action.payload)
      return state
    default:
      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) => {
  switch (action.type) {
    case 'NEW_NOTE':
      return state.concat(action.payload)    default:
      return state
  }
}

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

Testiympäristön konfigurointi

Konfiguroidaan sovellukseen Vitest. Asennetaan se sovelluksen kehityksenaikaiseksi riippuvuudeksi:

npm install --save-dev vitest

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

{
  // ...
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "lint": "eslint .",
    "preview": "vite preview",
    "test": "vitest"  },
  // ...
}

Jotta testaus olisi helpompaa, siirretään reducerin koodi ensin omaan moduuliinsa tiedostoon src/reducers/noteReducer.js:

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

export default noteReducer

Tiedosto main.jsx muuttuu seuraavasti:

import ReactDOM from 'react-dom/client'
import { createStore } from 'redux'
import noteReducer from './reducers/noteReducer'
const store = createStore(noteReducer)

// ...

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

Olemme nyt valmiita kirjoittamaan testejä.

Testit noteReducerille

Aloitetaan tekemällä testi actionin NEW_NOTE käsittelylle. Määritellään testi tiedostoon src/reducers/noteReducer.test.js:

import deepFreeze from 'deep-freeze'
import { describe, expect, test } from 'vitest'
import noteReducer from './noteReducer'

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)
  })
})

Suoritetaan testi komennolla npm test. 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äisi tilan manipulointiin komentoa push, testi ei menisi 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 
}

Lopuksi palautetaan uusi tila. Se saadaan valitsemalla kaikki vanhan tilan muistiinpanot pois lukien etsittävää id:tä vastaava muistiinpano, jonka tilalle valitaan juuri muokattu muistiinpano:

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 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 päästään käsiksi tapahtumaolion kautta:

const content = 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 reduceria lukuunottamatta 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

Jäsennellään samalla sovelluksen koodi järkevämmin useisiin eri tiedostoihin. Tiedosto main.jsx näyttää muutosten jälkeen seuraavalta:

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:

const store = createStore(noteReducer)

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

Tämän ansiosta store on kaikkien ohjelman komponenttien saavutettavissa, kuten tulemme pian näkemään.

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

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

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'

Eriytetään seuraavaksi komponentti App tiedostoon src/App.jsx. Tiedoston 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 luomisesta vastaava lomake omaksi komponentikseen tiedostoon src/components/NoteForm.jsx:

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

const NoteForm = () => {
  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 NoteForm

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. Sijoitetaan molemmat tiedostoon src/components/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.

Tiedostoon App.jsx jää vain vähän koodia:

import NoteForm from './components/NoteForm'
import Notes from './components/Notes'

const App = () => {
  return (
    <div>
      <NoteForm />
      <Notes />
    </div>
  )
}

export default App

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.

Jatketaan muistiinpanosovelluksen yksinkertaistetun Redux-version laajentamista.

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

const initialState = [  {    content: 'reducer defines how redux store works',    important: true,    id: 1,  },  {    content: 'state of store can contain any data',    important: false,    id: 2,  },]
const noteReducer = (state = initialState, action) => {  // ...
}

// ...

export default noteReducer

Monimutkaisempi tila storessa

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

Sivun alussa lomake muistiinpanon lisäämiseen (syötekenttä ja nappi add). Tämän jälkeen radiopainikevalinta mitkä muistiinpanot näytetään, vaihtoehdot all, important ja noimportant. Näiden alle renderöidän kaikki muistiinpanot ja niiden yhteyteen teksti important jos muistiinpano merkattu tärkeäksi.

Aloitetaan todella suoraviivaisella toteutuksella:

import NoteForm from './components/NoteForm'
import Notes from './components/Notes'

const App = () => {
  const filterSelected = (value) => {    console.log(value)  }
  return (
    <div>
      <NoteForm />
      <div>        <input          type="radio"          name="filter"          onChange={() => filterSelected('ALL')}        />        all        <input          type="radio"          name="filter"          onChange={() => filterSelected('IMPORTANT')}        />        important        <input          type="radio"          name="filter"          onChange={() => filterSelected('NONIMPORTANT')}        />        nonimportant      </div>      <Notes />
    </div>
  )
}

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

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

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

{
  notes: [
    { content: 'reducer defines how redux store works', important: true, id: 1},
    { content: 'state of store can contain any data', important: false, id: 2}
  ],
  filter: 'IMPORTANT'
}

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

Yhdistetyt reducerit

Voisimme periaatteessa muokata jo olemassaolevaa reduceria ottamaan huomioon muuttuneen tilanteen. Parempi ratkaisu on kuitenkin määritellä tässä tilanteessa uusi, filtterin arvosta huolehtiva reduceri. Määritellään samalla myös sopiva action creator ‑funktio. Sijoitetaan koodi moduuliin src/reducers/filterReducer.js:

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

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

export default filterReducer

Filtterin arvon asettavat actionit ovat siis muotoa:

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

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

Määritellään yhdistetty reducer tiedostossa main.jsx. Tiedoston päivitetty sisältö on seuraavanlainen:

import ReactDOM from 'react-dom/client'
import { Provider } from 'react-redux'
import { createStore, combineReducers } from 'redux'

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

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

const store = createStore(reducer)

console.log(store.getState())

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

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

console.log-komennon ansiosta konsoliin tulostuu storen tila:

Konsolista selviää että store on olio jolla kentät filter (teksti, arvona "ALL") ja notes (taulukollinen muistiinpanoja).

Store on siis juuri siinä muodossa jossa haluammekin sen olevan!

Tarkastellaan vielä yhdistetyn reducerin luomista:

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

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

Ennen muun koodin muutoksia kokeillaan vielä konsolista, miten actionit muuttavat yhdistetyn reducerin muodostamaa staten tilaa. Lisätään seuraavat rivit väliaikaisesti tiedostoon main.jsx:

// ...

const store = createStore(reducer)

console.log(store.getState())

import { createNote } from './reducers/noteReducer'import { filterChange } from './reducers/filterReducer'
store.subscribe(() => console.log(store.getState()))store.dispatch(filterChange('IMPORTANT'))store.dispatch(createNote('combineReducers forms one reducer from many simple reducers'))
ReactDOM.createRoot(document.getElementById('root')).render(
  <Provider store={store}>
    <div />
  </Provider>
)

Kun simuloimme näin filtterin tilan muutosta ja muistiinpanon luomista, konsoliin tulostuu storen tila jokaisen muutoksen jälkeen:

Storen filter-arvoksi muuttuu ensin IMPORTANT, tämän jäleen storen notesiin tulee uusi muistiinpano

Jo tässä vaiheessa kannattaa laittaa mieleen eräs tärkeä detalji. Jos molempien reducerien alkuun lisätään konsoliin tulostus

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

niin nyt konsolin perusteella näyttää siltä, että jokainen action kahdentuu:

Konsolin tulostus paljastaa että sekä noteReducer että filterReducer käsittelevät jokaisen actionin

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

Filtteröinnin viimeistely

Viimeistellään nyt sovellus käyttämään yhdistettyä reduceria. Poistetaan tiedostosta main.jsx ylimääräiset kokeilut ja palautetaan App renderöitäväksi komponentiksi. Tiedoston päivitetty sisältö on seuraava:

import ReactDOM from 'react-dom/client'
import { Provider } from 'react-redux'
import { createStore, combineReducers } from 'redux'

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

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

const store = createStore(reducer)

console.log(store.getState())

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

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

komennon notes.map(note => ...) suoritus aiheuttaa virheen TypeError notes.map is not a function)

Korjaus on helppo. Koska muistiinpanot ovat nyt storen kentässä notes, riittää pieni muutos selektorifunktioon:

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

Aiemminhan selektorifunktio palautti koko storen tilan:

const notes = useSelector(state => state)

Nyt siis palautetaan tilasta ainoastaan sen kenttä notes:

const notes = useSelector(state => state.notes)

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

import { useDispatch } from 'react-redux'
import { filterChange } from '../reducers/filterReducer'

const VisibilityFilter = () => {
  const dispatch = useDispatch()

  return (
    <div>
      <input
        type="radio"
        name="filter"
        onChange={() => dispatch(filterChange('ALL'))}
      />
      all
      <input
        type="radio"
        name="filter"
        onChange={() => dispatch(filterChange('IMPORTANT'))}
      />
      important
      <input
        type="radio"
        name="filter"
        onChange={() => dispatch(filterChange('NONIMPORTANT'))}
      />
      nonimportant
    </div>
  )
}

export default VisibilityFilter

Toteutus on suoraviivainen - radiopainikkeen klikkaaminen muuttaa storen kentän filter tilaa.

Komponentti App yksinkertaisuu nyt seuraavasti:

import NoteForm from './components/NoteForm'
import Notes from './components/Notes'
import VisibilityFilter from './components/VisibilityFilter'

const App = () => {
  return (
    <div>
      <NoteForm />
      <VisibilityFilter />
      <Notes />
    </div>
  )
}

export default App

Muutetaan vielä komponenttia Notes ottamaan huomioon filtteri:

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

Muutos kohdistuu siis ainoastaan selektorifunktioon, joka oli aiemmin muotoa

useSelector(state => state.notes)

Yksinkertaistetaan vielä selektoria destrukturoimalla parametrina olevasta tilasta sen kentät erilleen:

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

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

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

Redux Toolkit ja storen konfiguraation refaktorointi

Kuten olemme jo tähän asti huomanneet, Reduxin konfigurointi ja tilanhallinnan toteutus vaativat melko paljon vaivannäköä. Tämä ilmenee esimerkiksi reducereiden ja action creatorien koodista, jossa on jonkin verran toisteisuutta. Redux Toolkit on kirjasto, joka soveltuu näiden yleisten Reduxin käyttöön liittyvien ongelmien ratkaisemiseen. Kirjaston käyttö mm. yksinkertaistaa huomattavasti Redux-storen luontia ja tarjoaa suuren määrän tilanhallintaa helpottavia työkaluja.

Otetaan Redux Toolkit käyttöön sovelluksessamme refaktoroimalla nykyistä koodia. Aloitetaan kirjaston asennuksella:

npm install @reduxjs/toolkit

Avataan sen jälkeen main.jsx-tiedosto, jossa nykyinen Redux-store luodaan. Käytetään storen luonnissa Reduxin createStore-funktion sijaan Redux Toolkitin configureStore-funktiota:

import ReactDOM from 'react-dom/client'
import { Provider } from 'react-redux'
import { configureStore } from '@reduxjs/toolkit'
import App from './App'
import filterReducer from './reducers/filterReducer'
import noteReducer from './reducers/noteReducer'

const store = configureStore({  reducer: {    notes: noteReducer,    filter: filterReducer  }})
console.log(store.getState())

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

Pääsimme eroon jo muutamasta koodirivistä, kun reducerin muodostamiseen ei enää tarvita combineReducers-funktiota. Tulemme pian näkemään, että configureStore-funktion käytöstä on myös monia muita hyötyjä, kuten kehitystyökalujen ja usein käytettyjen kirjastojen vaivaton käyttöönotto ilman erillistä konfiguraatiota.

Siistitään vielä main.jsx-tiedostoa siirtämällä Redux-storen luontiin liittyvä koodi erilliseen tiedostoon. Luodaan uusi tiedosto src/store.js:

import { configureStore } from '@reduxjs/toolkit'

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

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

export default store

Muutosten jälkeen main.jsx-tiedosto näyttää seuraavalta:

import ReactDOM from 'react-dom/client'
import { Provider } from 'react-redux'

import App from './App'
import store from './store'

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

Redux Toolkit ja reducereiden refaktorointi

Siirrytään seuraavaksi reducereiden refaktorointiin, jossa Redux Toolkitin edut tulevat parhaiten esiin. Redux Toolkitin avulla reducerin ja siihen liittyvät action creatorit voi luoda kätevästi createSlice-funktion avulla. Voimme refaktoroida reducers/noteReducer.js-tiedostossa olevan reducerin ja action creatorit createSlice-funktion avulla seuraavasti:

import { createSlice } from '@reduxjs/toolkit'
const initialState = [
  {
    content: 'reducer defines how redux store works',
    important: true,
    id: 1,
  },
  {
    content: 'state of store can contain any data',
    important: false,
    id: 2,
  },
]

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

const noteSlice = createSlice({  name: 'notes',  initialState,  reducers: {    createNote(state, action) {      const content = action.payload      state.push({        content,        important: false,        id: generateId(),      })    },    toggleImportanceOf(state, action) {      const id = action.payload      const noteToChange = state.find(n => n.id === id)      const changedNote = {         ...noteToChange,         important: !noteToChange.important       }      return state.map(note =>        note.id !== id ? note : changedNote       )         }  },})
export const { createNote, toggleImportanceOf } = noteSlice.actionsexport default noteSlice.reducer

createSlice-funktion name-parametri määrittelee etuliitteen, jota käytetään actioneiden type-arvoissa. Esimerkiksi myöhemmin määritelty createNote-action saa type-arvon notes/createNote. Parametrin arvona on hyvä käyttää muiden reducereiden kesken uniikkia nimeä, jotta sovelluksen actioneiden type-arvoissa ei tapahtuisi odottamattomia yhteentörmäyksiä. Parametri initialState määrittelee reducerin alustavan tilan. Parametri reducers määrittelee itse reducerin objektina, jonka funktiot käsittelevät tietyn actionin aiheuttamat tilamuutokset. Huomaa, että funktioissa action.payload sisältää action creatorin kutsussa annetun parametrin:

dispatch(createNote('Redux Toolkit is awesome!'))

Tämä dispatch-kutsu vastaa seuraavan objektin dispatchaamista:

dispatch({ type: 'notes/createNote', payload: 'Redux Toolkit is awesome!' })

Jos olit tarkkana, saatoit huomata, että actionin createNote kohdalla tapahtuu jotain, mikä vaikuttaa rikkovan aiemmin mainittua reducereiden immutabiliteetin periaatetta:

createNote(state, action) {
  const content = action.payload

  state.push({
    content,
    important: false,
    id: generateId(),
  })
}

Mutatoimme state-argumentin taulukkoa kutsumalla push-metodia sen sijaan, että palauttaisimme uuden instanssin taulukosta. Mistä on kyse?

Redux Toolkit hyödyntää createSlice-funktion avulla määritellyissä reducereissa Immer-kirjastoa, joka mahdollistaa state-argumentin mutatoinnin reducerin sisällä. Immer muodostaa mutatoidun tilan perusteella uuden, immutablen tilan ja näin tilamuutosten immutabiliteetti säilyy.

Huomaa, että tilaa voi muuttaa myös "mutatoimatta" kuten esimerkiksi toggleImportanceOf ‑actionin kohdalla on tehty. Tällöin funktio palauttaa uuden tilan. Mutatointi osoittautuu kuitenkin usein hyödylliseksi etenkin rakenteeltaan monimutkaisen tilan päivittämisessä.

Funktio createSlice palauttaa objektin, joka sisältää sekä reducerin että reducers-parametrin actioneiden mukaiset action creatorit. Reducer on palautetussa objektissa noteSlice.reducer-kentässä kun taas action creatorit ovat noteSlice.actions-kentässä. Voimme muodostaa tiedoston exportit kätevästi:

const noteSlice = createSlice({
  // ...
})

export const { createNote, toggleImportanceOf } = noteSlice.actionsexport default noteSlice.reducer

Importit toimivat muissa tiedostoissa tavalliseen tapaan:

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

Joudumme hieman muuttamaan testejämme Redux Toolkitin nimeämiskäytäntöjen takia:

import deepFreeze from 'deep-freeze'
import { describe, expect, test } from 'vitest'
import noteReducer from './noteReducer'

describe('noteReducer', () => {
  test('returns new state with action notes/createNote', () => {    const state = []
    const action = {
      type: 'notes/createNote',      payload: 'the app state is in redux store'    }

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

    expect(newState).toHaveLength(1)
    expect(newState.map(note => note.content)).toContainEqual(action.payload)  })
})

test('returns new state with action notes/toggleImportanceOf', () => {  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: 'notes/toggleImportanceOf',    payload: 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
  })
})

Sovelluksen tämänhetkinen koodi on GitHubissa branchissa part6-3.

Redux Toolkit ja console.log

Kuten olemme oppineet, on console.log äärimmäisen voimakas työkalu, se pelastaa meidät yleensä aina pulasta.

Yritetään kokeeksi tulostaa Redux-storen tila konsoliin kesken funktiolla createSlice luodun reducerin:

const noteSlice = createSlice({
  name: 'notes',
  initialState,
  reducers: {
    // ...
    toggleImportanceOf(state, action) {
      const id = action.payload

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

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

      console.log(state)
      return state.map(note =>
        note.id !== id ? note : changedNote 
      )     
    }
  },
})

Kun nyt muistiinpanon tärkeyttä muuttaa klikkaamalla sen nimeä, konsoliin tulostuu seuraava

fullstack content

Tulostus on mielenkiintoinen mutta ei kovin hyödyllinen. Kyse tässä jo edellä mainitusta Redux toolkitin käyttämästä Immer-kirjastosta, mitä käytetään nyt sisäisesti storen tilan tallentamiseen.

Tilan voi muuntaa ihmisluettavaan muotoon käyttämällä immer-kirjaston current-funktiota. Funktion voi importata käyttöön komennolla:

import { current } from '@reduxjs/toolkit'

ja tämän jälkeen tilan voi tulostaa konsoliin komennolla:

console.log(current(state))

Konsolitulostus on nyt ihmisluettava:

fullstack content

Redux DevTools

Chromeen on asennettavissa Redux DevTools ‑lisäosa, jonka avulla Redux-storen tilaa ja sitä muuttavia actioneja on mahdollisuus seurata selaimen konsolista. Redux Toolkitin configureStore-funktion avulla luodussa storessa Redux DevTools on käytössä automaattisesti ilman ylimääräistä konfigurointia.

Kun lisäosa on asennettu Chromeen, konsolin Redux-välilehti pitäisi näyttää seuraavalta:

Redux DevToolsin oikea puoli "State" näyttää storen alkutilan

Kunkin actionin storen tilaan aiheuttamaa muutosta on helppo tarkastella:

edux DevToolsin vasen puoli näyttää suoritetut actionit, muuttunut tila heijastuu oikealle puolelle

Konsolin avulla on myös mahdollista dispatchata actioneja storeen:

Mahdollisuus actionien dispatchaamiseen avautuu alalaidan valinnoista

JSON Serverin käyttöönotto

Laajennetaan sovellusta siten, että muistiinpanot talletetaan backendiin. Käytetään osasta 2 tuttua JSON Serveriä.

Tallennetaan projektin juureen tiedostoon db.json tietokannan alkutila:

{
  "notes": [
    {
      "content": "the app state is in redux store",
      "important": true,
      "id": 1
    },
    {
      "content": "state changes are made with actions",
      "important": false,
      "id": 2
    }
  ]
}

Asennetaan projektiin JSON Server

npm install json-server --save-dev

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

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

Käynnistetään JSON Server komennolla npm run server.

Fetch API

Ohjelmistokehityksessä joudutaan usein pohtimaan, kannattaako jokin toiminnallisuus toteuttaa käyttämällä ulkoista kirjastoa vai onko parempi hyödyntää ympäristön tarjoamia natiiveja ratkaisuja. Molemmilla lähestymistavoilla on omat etunsa ja haasteensa.

Käytimme HTTP-pyyntöjen tekemiseen kurssin aiemmissa osissa Axios-kirjastoa. Tutustutaan nyt vaihtoehtoiseen tapaan tehdä HTTP-pyyntöjä natiivia Fetch APIa hyödyntäen.

On tyypillistä, että ulkoinen kirjasto kuten Axios on toteutettu hyödyntäen muita ulkoisia kirjastoja. Esimerkiksi jos Axioksen asentaa projektiin komennolla npm install axios, konsoliin tulostuu:

$ npm install axios

added 23 packages, and audited 302 packages in 1s

71 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities

Komento asentaisi projektiin siis Axios-kirjaston lisäksi yli 20 muuta npm-pakettia, jotka Axios tarvitsisi toimiakseen.

Fetch API tarjoaa samankaltaisen tavan tehdä HTTP-pyyntöjä kuin Axios, mutta Fetch APIn käyttäminen ei vaadi ulkoisten kirjastojen asentamista. Sovelluksen ylläpito helpottuu, kun päivitettäviä kirjastoja on vähemmän, ja myös tietoturva paranee, koska sovelluksen mahdollinen hyökkäyspinta-ala pienenee. Sovellusten tietoturvaa ja ylläpitoa sivutaan kurssin osassa 7.

Pyyntöjen tekeminen tapahtuu käytännössä käyttämällä fetch()-funktiota. Käytettävässä syntaksissa on jonkin verran eroja verrattuna Axiokseen. Huomaamme myös pian, että Axios on huolehtinut joistakin asioista puolestamme ja helpottanut elämäämme. Käytämme nyt kuitenkin Fetch APIa, koska se on laajasti käytetty natiiviratkaisu, joka jokaisen Full Stack -kehittäjän on syytä tuntea.

Datan hakeminen palvelimelta

Tehdään backendistä dataa hakeva metodi tiedostoon src/services/notes.js:

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

const getAll = async () => {
  const response = await fetch(baseUrl)

  if (!response.ok) {
    throw new Error('Failed to fetch notes')
  }

  const data = await response.json()
  return data
}

export default { getAll }

Tutkitaan getAll-metodin toteutusta tarkemmin. Muistiinpanot haetaan backendistä nyt kutsumalla fetch()-funktiota, jolle on annettu argumentiksi backendin URL-osoite. Pyynnön tyyppiä ei ole erikseen määritelty, joten fetch toteuttaa oletusarvoisen toiminnon eli GET-pyynnön.

Kun vastaus on saapunut, tarkistetaan pyynnön onnistuminen vastauksen kentästä response.ok ja heitetään tarvittaessa virhe:

if (!response.ok) {
  throw new Error('Failed to fetch notes')
}

Attribuutti response.ok saa arvon true, jos pyyntö on onnistunut eli jos vastauksen statuskoodi on välillä 200-299. Kaikilla muilla statuskoodeilla, esimerkiksi 404 tai 500, se saa arvon false.

Huomaa, että fetch ei automaattisesti heitä virhettä, vaikka vastauksen statuskoodi olisi esimerkiksi 404. Virheenkäsittely tulee toteuttaa manuaalisesti, kuten olemme nyt tehneet.

Jos pyyntö on onnistunut, vastauksen sisältämä data muunnetaan JSON-muotoon:

const data = await response.json()

fetch ei siis automaattisesti muunna vastauksen mukana mahdollisesti olevaa dataa JSON-muotoon, vaan muunnos tulee tehdä manuaalisesti. On myös hyvä huomata, että response.json() on asynkroninen metodi, eli sen kanssa tulee käyttää await-avainsanaa.

Suoraviivaistetaan koodia vielä hieman palauttamalla suoraan metodin response.json() palauttama data:

const getAll = async () => {
  const response = await fetch(baseUrl)

  if (!response.ok) {
    throw new Error('Failed to fetch notes')
  }

  return await response.json()}

Storen alustaminen palvelimelta haetulla datalla

Muutetaan nyt sovellustamme siten, että sovelluksen tila alustetaan palvelimelta haetuilla muistiinpanoilla.

Muutetaan tiedostossa noteReducer.js tapahtuvaa muistiinpanojen tilan alustusta siten, että oletusarvoisesti muistiinpanoja ei ole:

const noteSlice = createSlice({
  name: 'notes',
  initialState: [],  // ...
})

Lisätään action creator setNotes, jonka avulla muistiinpanojen taulukon voi suoraan korvata. Saamme createSlice-funktion avulla luotua haluamamme action creatorin seuraavasti:

// ...

const noteSlice = createSlice({
  name: 'notes',
  initialState: [],
  reducers: {
    createNote(state, action) {
      const content = action.payload
      state.push({
        content,
        important: false,
        id: generateId()
      })
    },
    toggleImportanceOf(state, action) {
      const id = action.payload
      const noteToChange = state.find(n => n.id === id)
      const changedNote = {
        ...noteToChange,
        important: !noteToChange.important
      }
      return state.map(note => (note.id !== id ? note : changedNote))
    },
    setNotes(state, action) {      return action.payload    }  }
})

export const { createNote, toggleImportanceOf, setNotes } = noteSlice.actionsexport default noteSlice.reducer

Toteutetaan muistiinpanojen alustus App-komponentiin, eli kuten yleensä dataa palvelimelta haettaessa, käytetään useEffect-hookia:

import { useEffect } from 'react'import { useDispatch } from 'react-redux'
import NoteForm from './components/NoteForm'
import Notes from './components/Notes'
import VisibilityFilter from './components/VisibilityFilter'
import { setNotes } from './reducers/noteReducer'import noteService from './services/notes'
const App = () => {
  const dispatch = useDispatch()
  useEffect(() => {    noteService.getAll().then(notes => dispatch(setNotes(notes)))  }, [dispatch])
  return (
    <div>
      <NoteForm />
      <VisibilityFilter />
      <Notes />
    </div>
  )
}

export default App

Muistiinpanot haetaan palvelimelta siis käyttäen määrittelemäämme getAll()-metodia ja tallennetaan sitten Redux-storeen dispatchaamalla setNotes -action creatorin palauttama action. Toiminnot tehdään useEffect-hookissa eli ne suoritetaan App-komponentin ensimmäisen renderoinnin yhteydessä.

Tutkitaan vielä tarkemmin erästä pientä yksityiskohtaa. Olemme lisänneet dispatch-muuttujan useEffect-hookin riippuvuustaulukkoon. Jos yritämme käyttää tyhjää riippuvuustaulukkoa, ESLint antaa seuraavan varoituksen: React Hook useEffect has a missing dependency: 'dispatch'. Mistä on kyse?

Koodi toimisi loogisesti täysin samoin, vaikka käyttäisimme tyhjää riippuvuustaulukkoa, koska dispatch viittaa samaan funktioon koko ohjelman suorituksen ajan. On kuitenkin hyvän ohjelmointikäytännön mukaista lisätä useEffect-hookin riippuvuuksiksi kaikki sen käyttämät muuttujat ja funktiot, jotka on määritelty kyseisen komponentin sisällä. Näin voidaan välttää yllättäviä bugeja.

Datan lähettäminen palvelimelle

Toteutetaan seuraavaksi toiminnallisuus uuden muistiinpanon lähettämiseksi palvelimelle. Pääsemme samalla harjoittelemaan, miten POST-pyyntö tehdään fetch()-metodia käyttäen.

Laajennetaan tiedostossa src/services/notes.js olevaa palvelimen kanssa kommunikoivaa koodia seuraavasti:

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

const getAll = async () => {
  const response = await fetch(baseUrl)

  if (!response.ok) {
    throw new Error('Failed to fetch notes')
  }

  return await response.json()
}

const createNew = async (content) => {  const response = await fetch(baseUrl, {    method: 'POST',    headers: { 'Content-Type': 'application/json' },    body: JSON.stringify({ content, important: false }),  })    if (!response.ok) {    throw new Error('Failed to create note')  }    return await response.json()}
export default { getAll, createNew }

Tutkitaan createNew-metodin toteutusta tarkemmin. fetch()-funktion ensimmäinen parametri määrittelee URL-osoitteen, johon pyyntö tehdään. Toinen parametri on olio, joka määrittelee muut pyynnön yksityiskohdat, kuten pyynnön tyypin, otsikot ja pyynnön mukana lähetettävän datan. Voimme selkeyttää koodia vielä hieman tallentamalla pyynnön yksityiskohdat määrittelevän olion erilliseen options-apumuuttujaan:

const createNew = async (content) => {
  const options = {    method: 'POST',    headers: { 'Content-Type': 'application/json' },    body: JSON.stringify({ content, important: false }),  }    const response = await fetch(baseUrl, options)
  if (!response.ok) {
    throw new Error('Failed to create note')
  }
  
  return await response.json()
}

Tutkitaan options-oliota tarkemmin:

  • method määrittelee pyynnön tyypin, joka tässä tapauksessa on POST
  • headers määrittelee pyynnön otsikot. Liitämme pyyntöön otsikon 'Content-Type': 'application/json', jotta palvelin tietää, että pyynnön mukana oleva data on JSON-muotoista, ja osaa käsitellä pyynnön oikein
  • body sisältää pyynnön mukana lähetettävän datan. Kentään ei voi suoraan sijoittaa JavaScript-oliota, vaan se tulee ensin muuntaa JSON-merkkijonoksi kutsumalla funktiota JSON.stringify()

Kuten GET-pyynnön kanssa, myös nyt vastauksen statuskoodi tutkitaan virheiden varalta:

if (!response.ok) {
  throw new Error('Failed to create note')
}

Jos pyyntö onnistuu, JSON Server palauttaa juuri luodun muistiinpanon, jolle se on generoinut myös yksilöllisen id:n. Vastauksen sisältämä data tulee kuitenkin vielä muuntaa JSON-muotoon metodilla response.json():

return await response.json()

Muutetaan sitten sovelluksemme NoteForm-komponenttia siten, että uusi muistiinpano lähetetään backendiin. Komponentin metodi addNote muuttuu hiukan:

import { useDispatch } from 'react-redux'
import { createNote } from '../reducers/noteReducer'
import noteService from '../services/notes'
const NoteForm = (props) => {
  const dispatch = useDispatch()
  
  const addNote = async (event) => {    event.preventDefault()
    const content = event.target.note.value
    event.target.note.value = ''
    const newNote = await noteService.createNew(content)    dispatch(createNote(newNote))  }

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

export default NoteForm

Kun uusi muistiinpano luodaan backendiin kutsumalla createNew()-metodia, saadaan paluuarvona muistiinpanoa kuvaava olio, jolle backend on generoinut id:n. Muutetaan siksi tiedostossa notesReducer.js määritelty action creator createNote seuraavaan muotoon:

const noteSlice = createSlice({
  name: 'notes',
  initialState: [],
  reducers: {
    createNote(state, action) {
      state.push(action.payload)    },
    // ..
  },
})

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

Sovelluksen tämänhetkinen koodi on GitHubissa branchissa part6-4.

Asynkroniset actionit ja Redux Thunk

Lähestymistapamme on melko hyvä, mutta siinä mielessä ikävä, että palvelimen kanssa kommunikointi tapahtuu komponentit määrittelevien funktioiden koodissa. Olisi parempi, jos kommunikointi voitaisiin abstrahoida komponenteilta siten, että niiden ei tarvitsisi kuin kutsua sopivaa action creatoria. Esim. App voisi alustaa sovelluksen tilan seuraavasti:

const App = () => {
  const dispatch = useDispatch()

  useEffect(() => {
    dispatch(initializeNotes())
  }, [dispatch]) 
  
  // ...
}

NoteForm puolestaan loisi uuden muistiinpanon seuraavasti:

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

  // ...
}

Molemmat komponentit dispatchaisivat ainoastaan actionin välittämättä siitä, että taustalla tapahtuu todellisuudessa palvelimen kanssa tapahtuvaa kommunikointia. Tämän kaltaisten asynkronisten actioneiden käyttö onnistuu Redux Thunk-kirjaston avulla. Kirjaston käyttö ei vaadi ylimääräistä konfiguraatiota eikä asennusta, kun Redux-store on luotu Redux Toolkitin configureStore-funktiolla.

Redux Thunkin ansiosta on mahdollista määritellä action creatoreja, jotka palauttavat objektin sijaan funktion. Tämän ansiosta on mahdollista toteuttaa asynkronisia action creatoreja, jotka ensin odottavat jonkin asynkronisen toimenpiteen valmistumista ja vasta sen jälkeen dispatchaavat varsinaisen actionin.

Jos action creator palauttaa funktion, Redux välittää palautetulle funktiolle automaattisesti Redux-storen dispatch- ja getState-metodit argumenteiksi. Sen ansiosta voimme määritellä muistiinpanojen alkutilan palvelimelta hakevan action creatorin initializeNotes tiedostossa noteReducer.js seuraavasti:

import { createSlice } from '@reduxjs/toolkit'
import noteService from '../services/notes'
const noteSlice = createSlice({
  name: 'notes',
  initialState: [],
  reducers: {
    createNote(state, action) {
      state.push(action.payload)
    },
    toggleImportanceOf(state, action) {
      const id = action.payload
      const noteToChange = state.find((n) => n.id === id)
      const changedNote = {
        ...noteToChange,
        important: !noteToChange.important,
      }
      return state.map((note) => (note.id !== id ? note : changedNote))
    },
    setNotes(state, action) {
      return action.payload
    },
  },
})

const { setNotes } = noteSlice.actions
export const initializeNotes = () => {  return async (dispatch) => {    const notes = await noteService.getAll()    dispatch(setNotes(notes))  }}
export const { createNote, toggleImportanceOf } = noteSlice.actions
export default noteSlice.reducer

Sisemmässä funktiossaan eli asynkronisessa actionissa operaatio hakee ensin palvelimelta kaikki muistiinpanot ja sen jälkeen dispatchaa muistiinpanot storeen lisäävän actionin. Huomionarvioista on se, että Redux välittää dispatch-metodin viitteen automaattisesti funktion argumentiksi, eli action creator initializeNotes ei tarvitse mitään parametreja.

Action creatoria setNotes ei enää exportata moduulin ulkopuolelle, koska muistiinpanojen alkutila on tarkoitus asettaa jatkossa käytämällä tekemäämme asynkronista action creatoria initialNotes. Hyödynnämme kuitenkin edelleen setNotes -action creatoria moduulin sisällä.

Komponentti App voidaan nyt määritellä seuraavasti:

import { useEffect } from 'react'
import { useDispatch } from 'react-redux'

import NoteForm from './components/NoteForm'
import Notes from './components/Notes'
import VisibilityFilter from './components/VisibilityFilter'
import { initializeNotes } from './reducers/noteReducer'
const App = () => {
  const dispatch = useDispatch()

  useEffect(() => {
    dispatch(initializeNotes())  }, [dispatch])

  return (
    <div>
      <NoteForm />
      <VisibilityFilter />
      <Notes />
    </div>
  )
}

export default App

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

Luodaan seuraavaksi appendNote-niminen asynkroninen action creator:

import { createSlice } from '@reduxjs/toolkit'
import noteService from '../services/notes'

const noteSlice = createSlice({
  name: 'notes',
  initialState: [],
  reducers: {
    createNote(state, action) {
      state.push(action.payload)
    },
    toggleImportanceOf(state, action) {
      const id = action.payload
      const noteToChange = state.find((n) => n.id === id)
      const changedNote = {
        ...noteToChange,
        important: !noteToChange.important,
      }
      return state.map((note) => (note.id !== id ? note : changedNote))
    },
    setNotes(state, action) {
      return action.payload
    },
  },
})

const { createNote, setNotes } = noteSlice.actions
export const initializeNotes = () => {
  return async (dispatch) => {
    const notes = await noteService.getAll()
    dispatch(setNotes(notes))
  }
}

export const appendNote = (content) => {  return async (dispatch) => {    const newNote = await noteService.createNew(content)    dispatch(createNote(newNote))  }}
export const { toggleImportanceOf } = noteSlice.actions
export default noteSlice.reducer

Periaate on jälleen sama. Ensin suoritetaan asynkroninen operaatio, ja sen valmistuttua dispatchataan storen tilaa muuttava action. createNote -action creatoria ei enää exportata tiedoston ulkopuolelle, vaan sitä käytetään ainoastaan sisäisesti appendNote -funktion toteutuksessa.

Komponentti NoteForm yksinkertaistuu seuraavasti:

import { useDispatch } from 'react-redux'
import { appendNote } from '../reducers/noteReducer'
const NoteForm = () => {
  const dispatch = useDispatch()

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

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

Sovelluksen tämänhetkinen koodi on GitHubissa branchissa part6-5.

Redux Toolkit tarjoaa myös hieman kehittyneempiä työkaluja asynkronisen tilanhallinnan helpottamiseksi, esim mm. createAsyncThunk-funktion ja RTK Query ‑API:n. Yksinkertaisissa sovelluksissa näiden tuoma hyöty lienee kuitenkin vähäinen.