Siirry sisältöön

c

Redux-sovelluksen kommunikointi palvelimen kanssa

JSON Serverin käyttöönotto

Laajennetaan sovellusta siten, että muistiinpanot talletetaan backendiin. Käytetään osasta 2 tuttua JSON Serveriä.

Tallennetaan projektin juureen 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 -p 3001 db.json",
  // ...
}

Käynnistetään JSON Server komennolla npm run server.

Fetch API

Ohjelmistokehityksessä joudutaan usein pohtimaan, kannattaako jokin toiminnallisuus toteuttaa käyttämällä ulkoista kirjastoa vai onko parempi hyödyntää ympäristön tarjoamia natiiveja ratkaisuja. Molemmilla lähestymistavoilla on omat etunsa ja haasteensa.

Käytimme HTTP-pyyntöjen tekemiseen kurssin aiemmissa osissa Axios-kirjastoa. Tutustutaan nyt vaihtoehtoiseen tapaan tehdä HTTP-pyyntöjä natiivia Fetch APIa hyödyntäen.

On tyypillistä, että ulkoinen kirjasto kuten Axios on toteutettu hyödyntäen muita ulkoisia kirjastoja. Esimerkiksi jos Axioksen asentaa projektiin komennolla npm install axios, konsoliin tulostuu:

$ npm install axios

added 23 packages, and audited 302 packages in 1s

71 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities

Komento asentaisi projektiin siis Axios-kirjaston lisäksi yli 20 muuta npm-pakettia, jotka Axios tarvitsisi toimiakseen.

Fetch API tarjoaa samankaltaisen tavan tehdä HTTP-pyyntöjä kuin Axios, mutta Fetch APIn käyttäminen ei vaadi ulkoisten kirjastojen asentamista. Sovelluksen ylläpito helpottuu, kun päivitettäviä kirjastoja on vähemmän, ja myös tietoturva paranee, koska sovelluksen mahdollinen hyökkäyspinta-ala pienenee. Sovellusten tietoturvaa ja ylläpitoa sivutaan kurssin osassa 7.

Pyyntöjen tekeminen tapahtuu käytännössä käyttämällä fetch()-funktiota. Käytettävässä syntaksissa on jonkin verran eroja verrattuna Axiokseen. Huomaamme myös pian, että Axios on huolehtinut joistakin asioista puolestamme ja helpottanut elämäämme. Käytämme nyt kuitenkin Fetch APIa, koska se on laajasti käytetty natiiviratkaisu, joka jokaisen Full Stack -kehittäjän on syytä tuntea.

Datan hakeminen palvelimelta

Tehdään backendistä dataa hakeva metodi tiedostoon src/services/notes.js:

const baseUrl = 'http://localhost:3001/notes'

const getAll = async () => {
  const response = await fetch(baseUrl)

  if (!response.ok) {
    throw new Error('Failed to fetch notes')
  }

  const data = await response.json()
  return data
}

export default { getAll }

Tutkitaan getAll-metodin toteutusta tarkemmin. Muistiinpanot haetaan backendistä nyt kutsumalla fetch()-funktiota, jolle on annettu argumentiksi backendin URL-osoite. Pyynnön tyyppiä ei ole erikseen määritelty, joten fetch toteuttaa oletusarvoisen toiminnon eli GET-pyynnön.

Kun vastaus on saapunut, tarkistetaan pyynnön onnistuminen vastauksen kentästä response.ok ja heitetään tarvittaessa virhe:

if (!response.ok) {
  throw new Error('Failed to fetch notes')
}

Attribuutti response.ok saa arvon true, jos pyyntö on onnistunut eli jos vastauksen statuskoodi on välillä 200-299. Kaikilla muilla statuskoodeilla, esimerkiksi 404 tai 500, se saa arvon false.

Huomaa, että fetch ei automaattisesti heitä virhettä, vaikka vastauksen statuskoodi olisi esimerkiksi 404. Virheenkäsittely tulee toteuttaa manuaalisesti, kuten olemme nyt tehneet.

Jos pyyntö on onnistunut, vastauksen sisältämä data muunnetaan JSON-muotoon:

const data = await response.json()

fetch ei siis automaattisesti muunna vastauksen mukana mahdollisesti olevaa dataa JSON-muotoon, vaan muunnos tulee tehdä manuaalisesti. On myös hyvä huomata, että response.json() on asynkroninen metodi, eli sen kanssa tulee käyttää await-avainsanaa.

Suoraviivaistetaan koodia vielä hieman palauttamalla suoraan metodin response.json() palauttama data:

const getAll = async () => {
  const response = await fetch(baseUrl)

  if (!response.ok) {
    throw new Error('Failed to fetch notes')
  }

  return await response.json()}

Storen alustaminen palvelimelta haetulla datalla

Muutetaan nyt sovellustamme siten, että sovelluksen tila alustetaan palvelimelta haetuilla muistiinpanoilla.

Muutetaan tiedostossa noteReducer.js tapahtuvaa muistiinpanojen tilan alustusta siten, että oletusarvoisesti muistiinpanoja ei ole:

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

Lisätään action creator setNotes, jonka avulla muistiinpanojen taulukon voi suoraan korvata. Saamme createSlice-funktion avulla luotua haluamamme action creatorin seuraavasti:

// ...

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))
    },
    setNotes(state, action) {      return action.payload    }  }
})

export const { createNote, toggleImportanceOf, setNotes } = noteSlice.actionsexport default noteSlice.reducer

Toteutetaan muistiinpanojen alustus App-komponentiin, eli kuten yleensä dataa palvelimelta haettaessa, käytetään useEffect-hookia:

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

export default App

Muistiinpanot haetaan palvelimelta siis käyttäen määrittelemäämme getAll()-metodia ja tallennetaan sitten Redux-storeen dispatchaamalla setNotes -action creatorin palauttama action. Toiminnot tehdään useEffect-hookissa eli ne suoritetaan App-komponentin ensimmäisen renderoinnin yhteydessä.

Tutkitaan vielä tarkemmin erästä pientä yksityiskohtaa. Olemme lisänneet dispatch-muuttujan useEffect-hookin riippuvuustaulukkoon. Jos yritämme käyttää tyhjää riippuvuustaulukkoa, ESLint antaa seuraavan varoituksen: React Hook useEffect has a missing dependency: 'dispatch'. Mistä on kyse?

Koodi toimisi loogisesti täysin samoin, vaikka käyttäisimme tyhjää riippuvuustaulukkoa, koska dispatch viittaa samaan funktioon koko ohjelman suorituksen ajan. On kuitenkin hyvän ohjelmointikäytännön mukaista lisätä useEffect-hookin riippuvuuksiksi kaikki sen käyttämät muuttujat ja funktiot, jotka on määritelty kyseisen komponentin sisällä. Näin voidaan välttää yllättäviä bugeja.

Datan lähettäminen palvelimelle

Toteutetaan seuraavaksi toiminnallisuus uuden muistiinpanon lähettämiseksi palvelimelle. Pääsemme samalla harjoittelemaan, miten POST-pyyntö tehdään fetch()-metodia käyttäen.

Laajennetaan tiedostossa src/services/notes.js olevaa palvelimen kanssa kommunikoivaa koodia seuraavasti:

const baseUrl = 'http://localhost:3001/notes'

const getAll = async () => {
  const response = await fetch(baseUrl)

  if (!response.ok) {
    throw new Error('Failed to fetch notes')
  }

  return await response.json()
}

const createNew = async (content) => {  const response = await fetch(baseUrl, {    method: 'POST',    headers: { 'Content-Type': 'application/json' },    body: JSON.stringify({ content, important: false }),  })    if (!response.ok) {    throw new Error('Failed to create note')  }    return await response.json()}
export default { getAll, createNew }

Tutkitaan createNew-metodin toteutusta tarkemmin. fetch()-funktion ensimmäinen parametri määrittelee URL-osoitteen, johon pyyntö tehdään. Toinen parametri on olio, joka määrittelee muut pyynnön yksityiskohdat, kuten pyynnön tyypin, otsikot ja pyynnön mukana lähetettävän datan. Voimme selkeyttää koodia vielä hieman tallentamalla pyynnön yksityiskohdat määrittelevän olion erilliseen options-apumuuttujaan:

const createNew = async (content) => {
  const options = {    method: 'POST',    headers: { 'Content-Type': 'application/json' },    body: JSON.stringify({ content, important: false }),  }    const response = await fetch(baseUrl, options)
  if (!response.ok) {
    throw new Error('Failed to create note')
  }
  
  return await response.json()
}

Tutkitaan options-oliota tarkemmin:

  • method määrittelee pyynnön tyypin, joka tässä tapauksessa on POST
  • headers määrittelee pyynnön otsikot. Liitämme pyyntöön otsikon 'Content-Type': 'application/json', jotta palvelin tietää, että pyynnön mukana oleva data on JSON-muotoista, ja osaa käsitellä pyynnön oikein
  • body sisältää pyynnön mukana lähetettävän datan. Kentään ei voi suoraan sijoittaa JavaScript-oliota, vaan se tulee ensin muuntaa JSON-merkkijonoksi kutsumalla funktiota JSON.stringify()

Kuten GET-pyynnön kanssa, myös nyt vastauksen statuskoodi tutkitaan virheiden varalta:

if (!response.ok) {
  throw new Error('Failed to create note')
}

Jos pyyntö onnistuu, JSON Server palauttaa juuri luodun muistiinpanon, jolle se on generoinut myös yksilöllisen id:n. Vastauksen sisältämä data tulee kuitenkin vielä muuntaa JSON-muotoon metodilla response.json():

return await response.json()

Muutetaan sitten sovelluksemme NoteForm-komponenttia siten, että uusi muistiinpano lähetetään backendiin. Komponentin metodi addNote muuttuu hiukan:

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

Kun uusi muistiinpano luodaan backendiin kutsumalla createNew()-metodia, saadaan paluuarvona muistiinpanoa kuvaava olio, jolle backend on generoinut id:n. Muutetaan siksi 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 NoteForm = () => {
  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 eikä asennusta, kun Redux-store on luotu Redux Toolkitin configureStore-funktiolla.

Redux Thunkin ansiosta on mahdollista määritellä action creatoreja, jotka palauttavat objektin sijaan funktion. 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.

Jos action creator palauttaa funktion, Redux välittää palautetulle funktiolle automaattisesti Redux-storen dispatch- ja getState-metodit argumenteiksi. Sen ansiosta voimme määritellä muistiinpanojen alkutilan palvelimelta hakevan action creatorin initializeNotes tiedostossa noteReducer.js seuraavasti:

import { createSlice } from '@reduxjs/toolkit'
import noteService from '../services/notes'
const noteSlice = createSlice({
  name: 'notes',
  initialState: [],
  reducers: {
    createNote(state, action) {
      state.push(action.payload)
    },
    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))
    },
    setNotes(state, action) {
      return action.payload
    },
  },
})

const { setNotes } = noteSlice.actions
export const initializeNotes = () => {  return async (dispatch) => {    const notes = await noteService.getAll()    dispatch(setNotes(notes))  }}
export const { createNote, toggleImportanceOf } = noteSlice.actions
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. Huomionarvioista on se, että Redux välittää dispatch-metodin viitteen automaattisesti funktion argumentiksi, eli action creator initializeNotes ei tarvitse mitään parametreja.

Action creatoria setNotes ei enää exportata moduulin ulkopuolelle, koska muistiinpanojen alkutila on tarkoitus asettaa jatkossa käytämällä tekemäämme asynkronista action creatoria initialNotes. Hyödynnämme kuitenkin edelleen setNotes -action creatoria moduulin sisällä.

Komponentti App voidaan nyt määritellä seuraavasti:

import { useEffect } from 'react'
import { useDispatch } from 'react-redux'

import NoteForm from './components/NoteForm'
import Notes from './components/Notes'
import VisibilityFilter from './components/VisibilityFilter'
import { initializeNotes } from './reducers/noteReducer'
const App = () => {
  const dispatch = useDispatch()

  useEffect(() => {
    dispatch(initializeNotes())  }, [dispatch])

  return (
    <div>
      <NoteForm />
      <VisibilityFilter />
      <Notes />
    </div>
  )
}

export default App

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

Luodaan seuraavaksi appendNote-niminen asynkroninen action creator:

import { createSlice } from '@reduxjs/toolkit'
import noteService from '../services/notes'

const noteSlice = createSlice({
  name: 'notes',
  initialState: [],
  reducers: {
    createNote(state, action) {
      state.push(action.payload)
    },
    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))
    },
    setNotes(state, action) {
      return action.payload
    },
  },
})

const { createNote, setNotes } = noteSlice.actions
export const initializeNotes = () => {
  return async (dispatch) => {
    const notes = await noteService.getAll()
    dispatch(setNotes(notes))
  }
}

export const appendNote = (content) => {  return async (dispatch) => {    const newNote = await noteService.createNew(content)    dispatch(noteSlice.actions.createNote(newNote))  }}
export const { toggleImportanceOf } = noteSlice.actions
export default noteSlice.reducer

Periaate on jälleen sama. Ensin suoritetaan asynkroninen operaatio, ja sen valmistuttua dispatchataan storen tilaa muuttava action. createNote -action creatoria ei enää exportata tiedoston ulkopuolelle, vaan sitä käytetään ainoastaan sisäisesti appendNote -funktion toteutuksessa.

Komponentti NoteForm yksinkertaistuu seuraavasti:

import { useDispatch } from 'react-redux'
import { appendNote } from '../reducers/noteReducer'
const NoteForm = () => {
  const dispatch = useDispatch()

  const addNote = async (event) => {
    event.preventDefault()
    const content = event.target.note.value
    event.target.note.value = ''
    dispatch(appendNote(content))  }

  return (
    <form onSubmit={addNote}>
      <input name="note" />
      <button type="submit">add</button>
    </form>
  )
}

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.