b

Monta reduseria

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:

fullstack content

Aloitetaan todella suoraviivaisella toteutuksella:

import NewNote from './components/NewNote'
import Notes from './components/Notes'

const App = () => {
  const filterSelected = (value) => {    console.log(value)  }
  return (
    <div>
      <NewNote />
      <div>        all          <input type="radio" name="filter"          onChange={() => filterSelected('ALL')} />        important    <input type="radio" name="filter"          onChange={() => filterSelected('IMPORTANT')} />        nonimportant <input type="radio" name="filter"          onChange={() => filterSelected('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:

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

Filtterin arvon asettavat actionit ovat siis muotoa:

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

Määritellään samalla myös sopiva action creator -funktio. Sijoitetaan koodi moduuliin src/reducers/filterReducer.js:

const filterReducer = (state = 'ALL', action) => {
  // ...
}

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

export default filterReducer

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

Määritellään yhdistetty reducer tiedostossa index.js:

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

import noteReducer from './reducers/noteReducer'
import filterReducer from './reducers/filterReducer'
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>
)*/

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.

Konsoliin tulostuu storen tila:

fullstack content

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 tiedostoon index.js:

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

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

fullstack content

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

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

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

fullstack content

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, eli palautetaan tiedostossa index.js suoritettava renderöinti seuravaan muotoon:

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

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

fullstack content

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.js sijoitettavaksi komponentiksi:

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

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

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

export default VisibilityFilter

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

Komponentti App yksinkertaisuu nyt seuraavasti:

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

const App = () => {
  return (
    <div>
      <NewNote />
      <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

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 index.js-tiedosto, jossa nykyinen Redux-store luodaan. Käytetään storen luonnissa Reduxin createStore-funktion sijaan Redux Toolkitin configureStore-funktiota:

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

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

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

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

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.

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

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 testiämme Redux Toolkitin nimeämiskäytäntöjen takia:

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

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(s => s.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
    })
  })
})

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 tulostaa Redux storen tilan 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 
      )     
    }
  },
})

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 saa tulostettua ihmisluettavassa muodossa, esim. muuttamalla se merkkijonoksi ja takaisin JavaScript-olioksi seuraavasti:

console.log(JSON.parse(JSON.stringify(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:

fullstack content

Kunkin actionin storen tilaan aiheuttamaa muutosta on helppo tarkastella:

fullstack content

Konsolin avulla on myös mahdollista dispatchata actioneja storeen:

fullstack content

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