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.

Datan hakeminen palvelimelta

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, { appendNote } 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:

fullstack content

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.

Datan lähettäminen palvelimelle

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 tiedostossa notesReducer.js määritelty action creator createNote seuraavaan muotoon:

const noteSlice = createSlice({
  name: 'notes',
  initialState: [],
  reducers: {
    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-4.

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:

import { initializeNotes } from './reducers/noteReducer'
// ...

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
    }
    // createNote definition removed from here!
  },
})

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.

Komponentti NewNote yksinkertaistuu 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-5.

Redux Toolkit tarjoaa myös hieman kehittyneempiä työkaluja asynkronisen tilanhallinnan helpottamiseksi, esim mm. createAsyncThunk-funktion ja RTK Query -API:n. Yksinkertaisissa sovelluksissa näiden tuoma hyöty lienee kuitenkin vähäinen.