a Flux-arkkitehtuuri ja Reduxb Monta reduseria
    d 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-dev

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

    "scripts": {
      "server": "json-server -p3001 --watch 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 baseUrl = 'http://localhost:3001/notes'
    
    const getAll = async () => {
      const response = await axios.get(baseUrl)
      return response.data
    }
    
    export default { getAll }

    Asennetaan myös axios projektiin:

    npm install axios

    Muutetaan noteReducer:issa tapahtuvaa muistiinpanojen tilan alustusta siten, että oletusarvoisesti muistiinpanoja ei ole:

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

    Lisätään lisäksi uusi action appendNote muistiinpano-objektin lisäämistä varten:

    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 
          )     
        },
        appendNote(state, action) {      state.push(action.payload)    }  },
    })
    
    export const { createNote, toggleImportanceOf, appendNote } = noteSlice.actions
    export default noteSlice.reducer

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

    // ...
    import noteService from './services/notes'import noteReducer, { apppendNote } from './reducers/noteReducer'
    const store = configureStore({
      reducer: {
        notes: noteReducer,
        filter: filterReducer,
      }
    })
    
    noteService.getAll().then(notes =>  notes.forEach(note => {    store.dispatch(appendNote(note))  }))
    // ...

    Monen actionin dispatchaaminen vaikuttaa hieman epäkäytännölliseltä. Lisätään action creator setNotes, jonka avulla muistiinpanojen taulukon voi suoraan korvata. Saamme createSlice-funktion avulla haluamamme action creatorin, kun määrittelemme setNotes-actionin:

    // ...
    
    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 
          )     
        },
        appendNote(state, action) {
          state.push(action.payload)
        },
        setNotes(state, action) {      return action.payload    }  },
    })
    
    export const { createNote, toggleImportanceOf, appendNote, setNotes } = noteSlice.actions
    export default noteSlice.reducer

    Nyt index.js yksinkertaistuu:

    // ...
    import noteService from './services/notes'
    import noteReducer, { setNotes } from './reducers/noteReducer'
    const store = configureStore({
      reducer: {
        notes: noteReducer,
        filter: filterReducer,
      }
    })
    
    noteService.getAll().then(notes =>
      store.dispatch(setNotes(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:

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

    Hookin useEffect käyttö aiheuttaa ESLint-varoituksen:

    Virheilmoitus React Hook useEffect has missing dependency: 'dispatch'

    Pääsemme varoituksesta eroon seuraavasti:

    const App = () => {
      const dispatch = useDispatch()
      useEffect(() => {
        noteService
          .getAll().then(notes => dispatch(setNotes(notes)))
      }, [dispatch])
      // ...
    }

    Nyt komponentin App sisällä määritelty muuttuja dispatch eli käytännössä Redux-storen dispatch-funktio on lisätty useEffectille parametrina annettuun taulukkoon. Jos dispatch-muuttujan sisältö muuttuisi ohjelman suoritusaikana, suoritettaisiin efekti uudelleen. Näin ei kuitenkaan ole, eli varoitus on tässä tilanteessa oikeastaan aiheeton.

    Toinen tapa päästä eroon varoituksesta olisi disabloida se kyseisen rivin kohdalta:

    const App = () => {
      const dispatch = useDispatch()
      useEffect(() => {
        noteService
          .getAll().then(notes => dispatch(setNotes(notes)))   
      },[]) // eslint-disable-line react-hooks/exhaustive-deps  
      // ...
    }

    Yleisesti ottaen ESLint-virheiden disabloiminen ei ole hyvä idea, joten vaikka kyseisen ESLint-säännön tarpeellisuus onkin aiheuttanut kiistelyä, pitäydytään ylemmässä ratkaisussa.

    Lisää hookien riippuvuuksien määrittelyn tarpeesta on Reactin dokumentaatiossa.

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

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

    Komponentin NewNote metodi addNote muuttuu hiukan:

    import { useDispatch } from 'react-redux'
    import { createNote } from '../reducers/noteReducer'
    import noteService from '../services/notes'
    const NewNote = (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 NewNote

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

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

    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 NewNote = () => {
      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, kun Redux-store on luotu Redux Toolkitin configureStore-funktiolla.

    Asennetaan kirjasto:

    npm install redux-thunk

    Redux Thunkin ansiosta on mahdollista määritellä action creatoreja, jotka palauttavat objektin sijaan funktion. Tämän funktion parametreina ovat Redux-storen dispatch- ja getState-metodi. 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.

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

    // ...
    import noteService from '../services/notes'
    const noteSlice = createSlice(/* ... */)
    
    export const { createNote, toggleImportanceOf, setNotes, appendNote } = noteSlice.actions
    
    export const initializeNotes = () => {  return async dispatch => {    const notes = await noteService.getAll()    dispatch(setNotes(notes))  }}
    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, setNotes.

    Komponentti App voidaan nyt määritellä seuraavasti:

    const App = () => {
      const dispatch = useDispatch()
    
      useEffect(() => {    dispatch(initializeNotes())   }, [dispatch]) 
      return (
        <div>
          <NewNote />
          <VisibilityFilter />
          <Notes />
        </div>
      )
    }

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

    Korvataan seuraavaksi createSlice-funktion avulla toteutettu createNote -action creator saman nimisellä asynkronisella action creatorilla:

    // ...
    import noteService from '../services/notes'
    
    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       }      return state.map(note =>        note.id !== id ? note : changedNote       )         },    appendNote(state, action) {      state.push(action.payload)    },    setNotes(state, action) {      return action.payload    }  },
    })
    
    export const { toggleImportanceOf, appendNote, setNotes } = noteSlice.actions
    export const initializeNotes = () => {
      return async dispatch => {
        const notes = await noteService.getAll()
        dispatch(setNotes(notes))
      }
    }
    
    export const createNote = content => {  return async dispatch => {    const newNote = await noteService.createNew(content)    dispatch(appendNote(newNote))  }}
    export default noteSlice.reducer

    Periaate on jälleen sama. Ensin suoritetaan asynkroninen operaatio, ja sen valmistuttua dispatchataan storen tilaa muuttava action. Redux Toolkit tarjoaa monia työkaluja asynkronisen tilanhallinnan helpottamiseksi. Tähän käyttötarkoitukseen soveltuvat mm. createAsyncThunk-funktio ja RTK Query -API.

    Komponentti NewNote muuttuu seuraavasti:

    const NewNote = () => {
      const dispatch = useDispatch()
      
      const addNote = async (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>
      )
    }

    Siistitään lopuksi vielä hieman index.js-tiedostoa siirtämällä Redux-storen luontiin liittyvä koodi erilliseen store.js-tiedostoon:

    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 index.js-tiedosto näyttää seuraavalta:

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

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