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:
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 main.jsx:
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:
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 main.jsx:
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:
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:
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 main.jsx suoritettava renderöinti seuravaan muotoon:
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:
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 main.jsx-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
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
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:
Kunkin actionin storen tilaan aiheuttamaa muutosta on helppo tarkastella:
Konsolin avulla on myös mahdollista dispatchata actioneja storeen:
Sovelluksen tämänhetkinen koodi on GitHubissa branchissa part6-3.