a Flux-arkkitehtuuri ja Reduxb Monta reduseria, connect

    c

    Redux-sovelluksen kommunikointi palvelimen kanssa

    Laajennetaan sovellusta siten, että muistiinpanot talletetaan backendiin. Käytetään osasta 2 tuttua json-serveriä.

    Tallennetaan projektin juuren 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

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

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

    Käynnistetään json-server komennolla npm run server.

    Tehdään sitten tuttuun tapaan axiosia hyödyntävä backendistä dataa hakeva metodi tiedostoon services/notes.js

    import axios from 'axios'
    
    const url = 'http://localhost:3001/notes'
    
    const getAll = async () => {
      const response = await axios.get(url)
      return response.data
    }
    
    export default { getAll }

    Asennetaan myös axios projektiin

    npm install axios --save

    Muutetaan nodeReducer:issa tapahtuva muistiinpanojen tilan alustusta, siten että oletusarvoisesti muistiinpanoja ei ole:

    const noteReducer = (state = [], action) => {
      // ...
    };

    Nopea tapa saada storen tila alustettua palvelimella olevan datan perusteella on hakea muistiinpanot tiedostossa index.js ja dispatchata niille yksitellen action NEW_NOTE:

    // ...
    import noteService from './services/notes'
    const reducer = combineReducers({
      notes: noteReducer,
      filter: filterReducer,
    });
    
    const store = createStore(reducer);
    
    noteService.getAll().then(notes =>  notes.forEach(note => {    store.dispatch({ type: 'NEW_NOTE', data: note })  }))
    // ...

    Lisätään reduceriin tuki actionille INIT_NOTES, jonka avulla alustus voidaan tehdä dispatchaamalla yksittäinen action. Luodaan myös sitä varten oma action creator -funktio initializeNotes:

    // ...
    const noteReducer = (state = [], action) => {
      console.log('ACTION:', action)
      switch (action.type) {
        case 'NEW_NOTE':
          return [...state, action.data]
        case 'INIT_NOTES':      return action.data    // ...
      }
    }
    
    export const initializeNotes = (notes) => {
      return {
        type: 'INIT_NOTES',
        data: notes,
      }
    }
    
    // ...

    index.js yksinkertaistuu:

    import noteReducer, { initializeNotes } from './reducers/noteReducer'
    // ...
    
    noteService.getAll().then(notes =>
      store.dispatch(initializeNotes(notes))
    )

    HUOM: miksi emme käyttäneet koodissa promisejen ja then-metodilla rekisteröidyn tapahtumankäsittelijän sijaan awaitia?

    await toimii ainoastaan async-funktioiden sisällä, ja index.js:ssä oleva koodi ei ole funktiossa, joten päädyimme tilanteen yksinkertaisuuden takia tällä kertaa jättämään async:in käyttämättä.

    Päätetään kuitenkin siirtää muistiinpanojen alustus App-komponentiin, eli kuten yleensä dataa palvelimelta haettaessa, käytetään effect hookia.

    Jotta saamme action creatorin initializeNotes käyttöön komponentissa App tarvitsemme jälleen connect-metodin apua:

    import React, { useEffect } from 'react'import { connect } from 'react-redux'import NewNote from './components/NewNote'
    import Notes from './components/Notes'
    import VisibilityFilter from './components/VisibilityFilter'
    import noteService from './services/notes'
    import { initializeNotes } from './reducers/noteReducer'
    
    const App = (props) => {
      useEffect(() => {    noteService      .getAll().then(notes => props.initializeNotes(notes))  },[])
      return (
        <div>
          <NewNote />
          <VisibilityFilter />
          <Notes />
        </div>
      )
    }
    
    export default connect(null, { initializeNotes })(App)

    Näin funktio initializeNotes tulee komponentin App propsiksi props.initializeNotes ja sen kutsumiseen ei tarvita dispatch-metodia koska connect hoitaa asian puolestamme.

    Voimme toimia samoin myös uuden muistiinpanon luomisen suhteen. Laajennetaan palvelimen kanssa kommunikoivaa koodia:

    const url = 'http://localhost:3001/notes'
    
    const getAll = async () => {
      const response = await axios.get(url)
      return response.data
    }
    
    const createNew = async (content) => {  const object = { content, important: false }  const response = await axios.post(url, object)  return response.data}
    export default {
      getAll,
      createNew,
    }

    Komponentin NewNote metodi addNote muuttuu hiukan:

    import React from 'react'
    import { connect } from 'react-redux'
    import { createNote } from '../reducers/noteReducer'
    import noteService from '../services/notes'
    const NewNote = (props) => {
      const addNote = async (event) => {
        event.preventDefault()
        const content = event.target.note.value    event.target.note.value = ''    const newNote = await noteService.createNew(content)    props.createNote(newNote)  }
    
      return (
        // ...
      )
    }
    
    export default connect(null, { createNote } )(NewNote)

    Koska backend generoi muistiinpanoille id:t, muutetaan action creator createNote muotoon

    export const createNote = (data) => {
      return {
        type: 'NEW_NOTE',
        data,
      }
    }

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

    Asynkroniset actionit ja redux thunk

    Lähestymistapamme on ok, mutta siinä mielessä ikävä, että palvelimen kanssa kommunikointi tapahtuu komponenttien funktioissa. Olisi parempi, jos kommunikointi voitaisiin abstrahoida komponenteilta siten, että niiden ei tarvitsisi kuin kutsua sopivaa action creatoria, esim. App alustaisi sovelluksen tilan seuraavasti:

    const App = (props) => {
    
      useEffect(() => {
        props.initializeNotes(notes)
      },[])
      // ...
    }

    ja NoteForm loisi uuden muistiinpanon seuraavasti:

    const NewNote = (props) => {
      const addNote = (event) => {
        event.preventDefault()
        const content = event.target.note.value
        props.createNote(content)
        event.target.note.value = ''
      }

    Molemmat komponentit käyttäisivät ainoastaan propsina saamaansa funktiota, välittämättä siitä että taustalla tapahtuu todellisuudessa palvelimen kanssa tapahtuvaa kommunikointia.

    Asennetaan nyt redux-thunk-kirjasto, joka mahdollistaa asynkronisten actionien luomisen. Asennus tapahtuu komennolla:

    npm install --save redux-thunk

    redux-thunk-kirjasto on ns. redux-middleware joka täytyy ottaa käyttöön storen alustuksen yhteydessä. Eriytetään samalla storen määrittely omaan tiedostoon src/store.js:

    import { createStore, combineReducers, applyMiddleware } from 'redux'
    import thunk from 'redux-thunk';
    
    import noteReducer from './reducers/noteReducer'
    import filterReducer from './reducers/filterReducer'
    
    const reducer = combineReducers({
      notes: noteReducer,
      filter: filterReducer,
    })
    
    const store = createStore(reducer, applyMiddleware(thunk))
    
    export default store

    Tiedosto src/index.js on muutoksen jälkeen seuraava

    import React from 'react'
    import ReactDOM from 'react-dom'
    import { Provider } from 'react-redux'
    import App from './App'
    import store from './store'
    
    ReactDOM.render(
      <Provider store={store}>
        <App />
      </Provider>,
    document.getElementById('root'))

    redux-thunkin ansiosta on mahdollista määritellä action creatoreja siten, että ne palauttavat funktion, jonka parametrina on redux-storen dispatch-metodi. Tämän ansiosta on mahdollista tehdä asynkronisia action creatoreja, jotka ensin odottavat jonkin toimenpiteen valmistumista ja vasta sen jälkeen dispatchaavat varsinaisen actionin.

    Voimme nyt määritellä muistiinpanojen alkutilan palvelimelta hakevan action creatorin initializeNotes seuraavasti:

    export const initializeNotes = () => {
      return async dispatch => {
        const notes = await noteService.getAll()
        dispatch({
          type: 'INIT_NOTES',
          data: notes,
        })
      }
    }

    Sisemmässä funktiossaan, eli asynkronisessa actionissa operaatio hakee ensin palvelimelta kaikki muistiinpanot ja sen jälkeen dispatchaa muistiinpanot storeen lisäävän actionin.

    Komponentti App voidaan nyt määritellä seuraavasti:

    const App = (props) => {
    
      useEffect(() => {
        props.initializeNotes()
      },[])
    
      return (
        <div>
          <NewNote />
          <VisibilityFilter />
          <Notes />
        </div>
      )
    }
    
    export default connect(
      null, { initializeNotes }
    )(App)

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

    Uuden muistiinpanon lisäävä action creator createNote on seuraavassa

    export const createNote = content => {
      return async dispatch => {
        const newNote = await noteService.createNew(content)
        dispatch({
          type: 'NEW_NOTE',
          data: newNote,
        })
      }
    }

    Periaate on jälleen sama, ensin suoritetaan asynkroninen operaatio, ja sen valmistuttua dispatchataan storen tilaa muuttava action.

    Komponentti NewNote muuttuu seuraavasti:

    const NewNote = (props) => {
      const addNote = async (event) => {
        event.preventDefault()
        const content = event.target.note.value
        event.target.note.value = ''
        props.createNote(content)
      }
    
      return (
        <form onSubmit={addNote}>
          <input name="note" />
          <button type="submit">lisää</button>
        </form>
      )
    }
    
    export default connect(
      null, { createNote }
    )(NewNote)

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

    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 redux-devtools-extension

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

    // ...
    import { createStore, combineReducers, applyMiddleware } from 'redux'
    import thunk from 'redux-thunk'
    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(    applyMiddleware(thunk)  ))
    
    export default store

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

    fullstack content

    Konsolin avulla on myös mahdollista dispatchata actioneja storeen

    fullstack content

    Redux ja komponenttien tila

    Kurssi on ehtinyt pitkälle, ja olemme vihdoin päässeet siihen pisteeseen missä käytämme Reactia "oikein", eli React keskittyy pelkästään näkymien muodostamiseen ja sovelluksen tila sekä sovelluslogiikka on eristetty kokonaan React-komponenttien ulkopuolelle, Reduxiin ja action reducereihin.

    Entä useState-hookilla saatava komponenttien oma tila, onko sillä roolia jos sovellus käyttää Reduxia tai muuta komponenttien ulkoista tilanhallintaratkaisua? Jos sovelluksessa on monimutkaisempia lomakkeita, saattaa niiden lokaali tila olla edelleen järkevä toteuttaa funktiolla useState saatavan tilan avulla. Lomakkeidenkin tilan voi toki tallettaa myös reduxiin, mutta jos lomakkeen tila on oleellinen ainoastaan lomakkeen täyttövaiheessa (esim. syötteen muodon validoinnin kannalta), voi olla viisaampi jättää tilan hallinta suoraan lomakkeesta huolehtivan komponentin vastuulle.