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 radiobuttoneiden avulla:

01e

Aloitetaan todella suoraviivaisella toteutuksella:

import React from 'react'
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, 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.filter
    default:
      return state
  }
}

Filtterin arvon asettavat actionit ovat siis muotoa

{
  type: 'SET_FILTER',
  filter: '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',
    filter,
  }
}

export default filterReducer

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

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

import React from 'react'
import ReactDOM from 'react-dom'
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.render(
  <Provider store={store}>
    <App />
  </Provider>,
  <div />,
  document.getElementById('root')
)

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

Konsoliin tulostuu storen tila:

4e

eli store on juuri siinä muodossa missä 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 reduces from many simple reducers'))

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

5e

Jo tässä vaiheessa kannattaa laittaa mieleen eräs tärkeä detalji. Jos lisäämme molempien reducerien alkuun konsoliin tulostuksen:

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

Näyttää konsolin perusteella siltä, että jokainen action kahdentuu:

6

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 reduceri, on kuitenkin tilanteita, joissa useampi reduceri 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 muotoon

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

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

7ea

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 sijoitettavaksi src/components/VisibilityFilter.js komponentiksi:

import React from 'react'
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, radiobuttonin klikkaaminen muuttaa storen kentän filter tilaa.

Komponentti App yksinkertaisuu nyt seuraavasti:

import React from 'react'
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ä komponentin 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, vaikka oletusarvosesti filtterin arvo on ALL, eli näytetään kaikki muistiinpanot, ei vastaava radiobutton ole valittuna. Ongelma on luonnollisestikin mahdollista korjata, mutta koska kyseessä on ikävä, mutta harmiton feature, jätämme korjauksen myöhemmäksi.

Redux DevTools

Chromeen on asennettavissa Redux DevTools, jonka avulla Redux-storen tilaa ja sitä muuttavia actioneja on mahdollisuus seurata selaimen konsolista.

Selaimen lisäosan lisäksi debugatessa tarvitaan kirjastoa redux-devtools-extension. Asennetaan se komennolla

npm install --save-dev redux-devtools-extension

Storen luomistapaa täytyy hieman muuttaa, että kirjasto saadaan käyttöön:

// ...
import { createStore, combineReducers } from 'redux'
import { composeWithDevTools } from 'redux-devtools-extension'
import noteReducer from './reducers/noteReducer'
import filterReducer from './reducers/filterReducer'

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

const store = createStore(
  reducer,
  composeWithDevTools())

export default store

Kun nyt avaat konsolin, välilehti redux näyttää seuraavalta:

11ea

Kunkin actionin storen tilaan aiheuttamaa muutosta on helppo tarkastella

12ea

Konsolin avulla on myös mahdollista dispatchata actioneja storeen

13ea

Sovelluksen tämänhetkinen koodi on githubissa branchissa part6-2.