Siirry sisältöön

a

Flux-arkkitehtuuri ja Zustand

Olemme noudattaneet sovelluksen tilan hallinnassa Reactin suosittelemaa käytäntöä määritellä useiden komponenttien tarvitsema tila ja sitä käsittelevät funktiot sovelluksen komponenttirakenteen ylimmissä komponenteissa. Usein suurin osa tilaa ja sitä käsittelevistä funktioista on määritelty suoraan sovelluksen juurikomponentissa ja välitetty propsien avulla niitä tarvitseville komponenteille. Tämä toimii johonkin pisteeseen saakka, mutta sovelluksen kasvaessa tilan hallinta muuttuu haasteelliseksi.

Flux-arkkitehtuuri

Facebook kehitti jo Reactin historian varhaisvaiheissa tilan hallinnan ongelmia helpottamaan Flux-arkkitehtuurin. Fluxissa sovelluksen tilan hallinta erotetaan kokonaan Reactin komponenttien ulkopuolisiin varastoihin eli storeihin. Storessa olevaa tilaa ei muuteta suoraan, vaan erikseen tähän tarkoitettujen actionien avulla.

Kun action muuttaa storen tilaa, renderöidään näkymät uudelleen:

Action -> Dispatcher -> Store -> View

Jos sovelluksen käyttö (esim. napin painaminen) aiheuttaa tarpeen tilan muutokseen, tehdään muutos actionin avulla. Tämä taas aiheuttaa uuden näytön renderöitymisen:

Action -> Dispatcher -> Store -> View -> Action -> Dispatcher -> View

Flux tarjoaa siis standardin tavan sille miten ja missä sovelluksen tila pidetään sekä tavalle tehdä tilaan muutoksia.

Redux

Flux-arkkitehtuuria noudattava Redux oli lähes vuosikymmenen hallitseva tilanhallintaratkaisu React-sovelluksissa. Myös tällä kurssilla käytettiin Reduxia kevääseen 2026 asti. Reduxia on alusta asti vaivannut monimutkaisuus ja boilerplate-koodin suuri määrä. Tilanne parani huomattavasti Redux Toolkitin ilmestymisen myötä, mutta tästä huolimatta yhteisö kehitti koko ajan vaihtoehtoisia tilanhallintaratkaisuja, kuten esimerkiksi MobX, Recoil ja Jotai. Näiden suosio on ollut vaihtelevaa.

Mielenkiintoisin, ja suosituin uusista tulokkaista on ehdottomasti Zustand, ja se on myös meidän valintamme tilanhallintaratkaisuksi. Zustand näyttää tavoittaneen suosiossaan jo itsensä Reduxin:

fullstack content

Zustand

Tutustutaan Zustandiin tekemällä jälleen kerran laskurin toteuttava sovellus:

Renderöity kokonaisluku sekä kolme nappia: plus, minus ja zero

Tehdään uusi Vite‑sovellus ja asennetaan siihen Zustand:

npm install zustand

Ensimmäinen versio, missä vasta laskurin kasvatus toimii, sovelluksesta on seuraavassa:

import { create } from 'zustand'

const useCounterStore = create(set => ({
  counter: 0,
  increment: () => set(state => ({ counter: state.counter + 1 })),
}))

const App = () => {
  const counter = useCounterStore(state => state.counter)
  const increment = useCounterStore(state => state.increment)

  return (
    <div>
      <div>{counter}</div>
      <div>
        <button onClick={increment}>plus</button>
        <button>minus</button>
        <button>zero</button>
      </div>
      
    </div>
  )
}

Sovellus aloittaa luomalla storen eli globaalin tilan Zustandin funktiolla create:

import { create } from 'zustand'

const useCounterStore = create(set => ({
  counter: 0,
  increment: () => set(state => ({ counter: state.counter + 1 })),
}))

Funktio saa parametriksi funktion, joka palauttaa sovellukselle määriteltävän tilan. Parametri on siis seuraava:

set => ({
  counter: 0,
  increment: () => set(state => ({ counter: state.counter + 1 })),
})

Tilaan on siis määritelty counter, joka on arvoltaan nolla, sekä increment joka taas on funktio.

Sovelluksen komponentit pääsevät käsiksi tilassa määriteltyihin arvoihin sekä funktioihin Zustandin createn avulla määritellyn funktion useCounterStore avulla. Komponentti App ottaa selektorien avulla tilasta käyttöönsä siellä olevan arvon counter sekä funktion increment:

const App = () => {
  // using selector to pick right part of the store state  const counter = useCounterStore(state => state.counter)  const increment = useCounterStore(state => state.increment)
  return (
    <div>
      <div>{counter}</div>      <div>
        <button onClick={increment}>plus</button>        <button>minus</button>
        <button>zero</button>
      </div>
      
    </div>
  )
}

Storessa oleva laskurin arvo saadaan siis talletettua muuttujaan seuraavasti:

const counter = useCounterStore(state => state.counter)

Käytössä on selektorifunktio, state => state.counter joka määrää mitä storen sisätä palautetaan. Vastaavalla tavalla saadaan muuttujaan increment storessa oleva funktio.

Napin "plus" klikkauksenkäsittelijäksi on annettu tilan funktio increment, joka määriteltiin seuraavasti:

const useCounterStore = create(set => ({
  counter: 0,
  increment: () => set(state => ({ counter: state.counter + 1 })),}))

Otetaan funktiomäärittely vielä erilleen:

() => set(state => ({ counter: state.counter + 1 }))

Kyseessä on siis funktio, joka kutsuu funktiota set antaen parametriksi taas funktion. Tämä parametrina oleva funktio määrittelee miten tila muuttuu:

state => ({ counter: state.counter + 1 })

joka taas on lyhennysmerkintä seuraavalle:

state => {
  return { counter: state.counter + 1 }
}

Funktio palauttaa uuden tilan, jonka se laskee vanhan tilan state perusteella, eli jos vanha tila on esimerkiksi

{
  counter: 1,
  increment: // function definition
}

tulee uudeksi tilaksi

{
  counter: 2,
  increment: // function definition
}

Tilassa on siis koko ajan mukana myös tilaa muuttava funktio increment.

Tilanmuutosfunktio

state => ({ counter: state.counter + 1 })

koskee ainoastaan tilassa olevaan arvoon counter.

Mikään ei estäisi muuttamasta tilanmuutosfunktiossa myös tilassa olevaa funktiota, eli jos määrittelisimme seuraavasti

state => {
  return {
    counter: state.counter + 1 ,
    increment: console.log('increment broken')
  }
}

kasvatusnappi toimisi vain ensimmäisellä kerralla, tämän jälkeen napin painaminen ainoastaan tulostaisi konsoliin.

Kun uudeksi tilaksi asetetaan

state => ({ counter: state.counter + 1 })

päivitetään ainoastaan tilan avaimen counter arvo, eli uusi tila saadaan yhdistämällä vanha tila tilaa muuttavan funktion arvoon. Tämän takia seuraava tilanmuutosfunktio

state => ({})

ei vaikuta tilaan ollenkaan.

Täydennetään vielä sovellus muidenkin nappien osalta:

const useCounterStore = create(set => ({
  counter: 0,
  increment: () => set(state => ({ counter: state.counter + 1 })),
  decrement: () => set(state => ({ counter: state.counter - 1 })),
  zero: () => set(() => ({ counter: 0 })),  
}))

const App = () => {
  const counter = useCounterStore(state => state.counter)
  const increment = useCounterStore(state => state.increment)
  const decrement = useCounterStore(state => state.decrement)
  const zero = useCounterStore(state => state.zero)

  return (
    <div>
      <div>{counter}</div>
      <div>
        <button onClick={increment}>plus</button>
        <button onClick={decrement}>minus</button>
        <button onClick={zero}>zero</button>
      </div>
      
    </div>
  )
}

Mikä ihmeen set ja state

Mistä set tulee? Se on Zustandin create-funktion tarjoama apufunktio, jonka avulla tila päivitetään. create kutsuu sille annettua parametrifunktiota ja välittää sille set-funktion automaattisesti. Sitä ei siis tarvitse itse kutsua tai tuoda mistään, vaan Zustand hoitaa sen.

Mistä taas state tulee? Kun set-funktiolle annetaan parametriksi funktio (eikä suoraan uutta tilaobjektia), Zustand kutsuu tätä funktiota antaen sille argumentiksi storen nykyisen tilan. Näin tilanpäivitysfunktioissa pääsee käyttämään vanhaa tilaa uuden laskemiseen.

Tilan käyttö eri komponenteista

Muokataan sovelluksen rakennetta siten, että storen määrittely siirtyy omaan tiedostoon store.js ja näkymä jakautuu useampaan komponenttiin, jotka on määritelty omina tiedostoina.

Tiedoston store.js sisältö on yllätyksetön

export const useCounterStore = create(set => ({
  counter: 0,
  increment: () => set(state => ({ counter: state.counter + 1 })),
  decrement: () => set(state => ({ counter: state.counter - 1 })),
  zero: () => set(() => ({ counter: 0 })),  
}))

Komponentti App pelkistyy seuraavasti

import Display from './Display'
import Controls from './Controls'

const App = () => {
  return (
    <div>
      <Display />
      <Controls />
    </div>
  )
}

export default App

Huomioinarvoista, tässä on se, että komponentti App ei nyt välitä tilaa lapsikomponenteilleen, Itse asiassa komponentti ei edes millään tavalla koske tilaan, storen määrittely on eriytetty täysin komponentin ulkopuolelle.

Laskurin arvon näyttävä komponentti on yksinkertainen

import { useCounterStore } from './store'

const Display = () => {
  const counter = useCounterStore(state => state.counter)

  return (
    <div>{counter}</div>
  )
}

export default Display

Komponentti siis pääsee laskurin arvoon käsiksi storen määrittelevän funktion useCounterStore kautta. Tämä on monella tapaa kätevää, ei ole esimerkiksi mitään tarvetta siirrellä tilaa komponentille sen propsien kautta.

Napit määrittelevä komponentti näyttää seuraavalta:

import { useCounterStore } from './store'

const Controls = () => {
  const increment = useCounterStore(state => state.increment)
  const decrement = useCounterStore(state => state.decrement)
  const zero = useCounterStore(state => state.zero)

  return (
    <div>
      <button onClick={increment}>plus</button>
      <button onClick={decrement}>minus</button>
      <button onClick={zero}>zero</button>
    </div>
  )
}

export default Controls

Funktio useCounterStore ottaa parametrinaan selektorifunktion, joka määrittää mitä osaa tilasta halutaan käyttää. Esimerkiksi:

  const increment = useCounterStore(state => state.increment)

Tässä selektorifunktio state => state.increment poimii tilasta increment-avaimen arvon, eli funktion, joka kasvattaa laskuria, ja tallentaa sen increment-muuttujaan.

Voisimme myös ottaa käyttöömme koko tilan, seuraavasti:

  const state = useCounterStore()
  // tekee saman asian kuin useCounterStore(state => state) eli valitsee koko tilan

Nyt laskurin arvoon ja funktioinin voisi viitata pistenotaatiolla, eli state.counter ja state.counter.

Herääkin kysymys olisiko mahdollista ottaa useita tilan osia käyttöön destrukturoimalla:

import { useCounterStore } from './store'

const Controls = () => {
  const { increment, decrement, zero } = useCounterStore()
  return (
    <div>
      <button onClick={increment}>plus</button>
      <button onClick={decrement}>minus</button>
      <button onClick={zero}>zero</button>
    </div>
  )
}

export default Controls

Ratkaisu toimii, mutta siinä on eräs merkittävä heikkous. Destrukturointi aiheuttaa sen, että Controls-komponentti renderöidään uudelleen aina kun laskurin arvo muuttuu vaikka komponentti näyttää ainoastaan painikkeet eikä itse arvoa.

Zustandin paras käytäntö onkin valita tilasta täsmälleen vain ne osat, joita kyseisessä komponentissa tarvitaan. Komponentti renderöityy uudelleen ainoastaan silloin, kun sen valitseman tilan osa muuttuu. Kun sen sijaan kirjoitetaan:

  const { increment, decrement, zero } = useCounterStore() 

komponentti ei enää reagoi laskurin arvon muutoksiin, koska se ei ole valinnut sitä tilastaan.

Tilan uudelleenorganisointi

Saamme kuitenkin aikaan varsin nätin ratkaisun uudelleenorganisoimalla tilaa seuraavasti:

export const useCounterStore = create(set => ({
  counter: 0,
  actions: {
    increment: () => set(state => ({ counter: state.counter + 1 })),
    decrement: () => set(state => ({ counter: state.counter - 1 })),
    zero: () => set(() => ({ counter: 0 })),
  }  
}))

Tilaa muuttavat funktiot on nyt koottu oman avaimen actions alle, ja ne voidaan valita kokonaisuudessaan ja destrukturoiden:

const Controls = () => {
  
  const { increment, decrement, zero } = useCounterStore(state => state.actions)

  return (
    <div>
      <button onClick={increment}>plus</button>
      <button onClick={decrement}>minus</button>
      <button onClick={zero}>zero</button>
    </div>
  )
}

Nyt uudelleenrenderöitymistä ei tapahdu, sillä tilasta on valittu ainoastaan funktiot, jotka pysyvät koko tilan elinajan samana.

Joidenkin parhaiden käytänteiden mukaan, koko tilan määrittelevää funktiota ei kannata exportata koko ohjelman käyttöön. Sensijaan kannattaa luoda siitä pienempiä näkymiä, jotka paljastavat vain tarvittavat osat tilasta. Muokataan tilaa state.js seuraavasti:

import { create } from 'zustand'

const useCounterStore = create(set => ({
  counter: 0,
  actions: {
    increment: () => set(state => ({ counter: state.counter + 1 })),
    decrement: () => set(state => ({ counter: state.counter - 1 })),
    zero: () => set(() => ({ counter: 0 })),
  }  
}))

// the hook functions that are used elsewhere in app
export const useCounter = () => useCounterStore(state => state.counter)export const useCounterControls = () => useCounterStore(state => state.actions)

Nyt siis storen määrittelevän moduulin ulkopuolella on käytössä funktiot useCounter, jota kutsumalla saadaan laskurin arvo, sekä useCounterControls jota kutsumalla saadaan laskurin arvoa muuttavat funktiot. Käyttö muuttuu hieman:

import { useCounter } from './store'
const Display = () => {
  const counter = useCounter()
  return (
    <div>{counter}</div>
  )
}
import { useCounterControls } from './store'
const Controls = () => {
  const { increment, decrement, zero } = useCounterControls()
  return (
    <div>
      <button onClick={increment}>plus</button>
      <button onClick={decrement}>minus</button>
      <button onClick={zero}>zero</button>
    </div>
  )
}

Näin käytettäessä tilaa, ei ole enää tarvetta käyttää selektorifunktiota, sillä niiden käyttö on piilotettu uusien apufunktioiden määrittelyn sisälle.

Tarkkasilmäisimmät kiinnittivät huomiota siihen että Zustandiin liittyvät funktiot on nimetty alkamaan sanalla use. Syynä tähän on se, että Zustandin funktion create palauttama funktio, eli esimerkissämme useCounterStore on Reactin custom hook-funktio. Myös omat apufunktiomme useCounter ja useCounterControls ovat käytännössä custom hookeja, koska ne piilottavat sisälleen custom hookin useCounterStore käytön.

Custom hookeihin taas liittyy joukko sääntöjä, esimerkiksi niiden nimeämisen oletetaan aina alkavan sanalla use. Osassa 1 läpikäydyt hookien säännöt koskevat myös custom hookeja!

Zustand-muistiinpanot

Tavoitteenamme on tehdä vanhasta kunnon muistiinpanosovelluksesta Zustandia käyttävä versio.

Sovelluksen ensimmäinen versio on seuraava. Komponentti App:

import { useNotes } from './store'

const App = () => {
  const notes = useNotes()

  return (
    <div>
      <ul>
        {notes.map(note => (
          <li key={note.id}>
            {note.important ? <strong>{note.content}</strong> : note.content}
          </li>
        ))}
      </ul>
    </div>
  )
}
export default App

Store näyttää aluksi seuraavalta:

import { create } from 'zustand'

const useNoteStore = create(set => ({
  notes: [
    {
      id: 1,
      content: 'Zustand is less complex than Redux',
      important: true,
    },
  ],
}))

export const useNotes = () => useNoteStore(state => state.notes)

Toistaiseksi sovelluksessa ei siis ole toiminnallisuutta uusien muistiinpanojen lisäämiseen, myöskään store ei vielä sitä tue. Tila on alustettu siten, että sinne on lisätty jo yksi muistiinpano jotta voimme varmistua, että sovellus onnistuu renderöimään tilan.

Puhtaat funktiot ja muuttumattomat (immutable) oliot

Ensimmäinen yritys muistiinpanon lisäävästsä actionista on seuraava:

note => set(
          state => {
            state.notes.push(note)
            return state
          }
        )

Funktio saa parametrikseen muistiinpanon, ja palauttaa tilan, missä vanhaan tilaan state on lisätty uusi muistiinpano.

Yrityksemme on kuitenkin sääntöjen vastainen. Zustandin dokumentaatio toteaa Like with React's useState, we need to update state immutably, Kuten tiedämme state.notes.push muuttaa tila olion tilaa, eli ratkaisu ei kelpaa.

Oikeaoppinen tapa on käyttää esimerkiksi Array.concat funktiota, joka ei muuta olemassaolevaa tilaa, vaan luo siitä uuden kopion, johon uusi muistiinpano on lisätty:

note => set(
          state => {
            return { notes: state.notes.concat(note) }
          }
        )

Kokonaisuudessaan storen määrittely näyttää nyt seuraavalta

import { create } from 'zustand'

const useNoteStore = create(set => ({
  notes: [],
  actions: {
    add: note => set(
      state => ({ notes: state.notes.concat(note) })
    )
  }
}))

export const useNotes = () => useNoteStore(state => state.notes)
export const useNoteActions = () => useNoteStore(state => state.actions)

Array spread -syntaksi

Toinen usein nähty tapa hoitaa sama asia on käyttää taulukkojen spread -syntaksia:

state => ({ notes: [...state.notes, note] })

Tässä siis muodostetaan taulukko, johon otetaan spread-syntaksilla jokainen taulukon state.notes alkioista sekä lisätään vielä loppuun uusi muistiinpano notes. On makuasia käyttääkö spreadia vai funktiota concat.

Teknisesti ilmaisten Zustandilla muodostettu tila on muuttumaton (engl. immutable), ja tilaa muuttavien action-funktioiden tulee olla puhtaita funktioita.

Puhtaat funktiot ovat sellaisia, että ne eivät aiheuta mitään sivuvaikutuksia ja ne palauttavat aina saman vastauksen samoilla parametreilla kutsuttaessa.

Ei-kontrolloitu lomake

Lisätään sovellukseen mahdollisuus uusien muistiinpanojen tekemiseen:

import { useNotes, useNoteActions } from './store'

const App = () => {
  const notes = useNotes()
  const { add } = useNoteActions()
  const generateId = () => Number((Math.random() * 1000000).toFixed(0))
  const addNote = (e) => {    e.preventDefault()    const content = e.target.note.value    add({ id: generateId(), content, important: false })    e.target.reset()  }
  return (
    <div>
      <form onSubmit={addNote}>        <input name="note" />        <button type="submit">add</button>      </form>      <ul>
        {notes.map(note => (
          <li key={note.id}>
            {note.important ? <strong>{note.content}</strong> : note.content}
          </li>
        ))}
      </ul>
    </div>
  )
}

Toteutus on melko suoraviivainen. Huomionarvoista uuden muistiinpanon lisäämisessä on nyt se, että toisin kuin aiemmat Reactilla toteutetut lomakkeet, emme ole nyt sitoneet lomakkeen kentän arvoa komponentin App tilaan. React kutsuu tällaisia lomakkeita ei-kontrolloiduiksi.

Ei-kontrolloiduilla lomakkeilla on tiettyjä rajoitteita. Ne eivät mahdollista esim. lennossa annettavia validointiviestejä, lomakkeen lähetysnapin disabloimista sisällön perusteella yms. Meidän käyttötapaukseemme ne kuitenkin tällä kertaa sopivat. Voit halutessasi lukea aiheesta enemmän esim. täältä.

Lomake on erittäin yksinkertainen:

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

Huomioinarvoista lomakkeessa on se, että syötekentällä on nimi. Tämän ansiosta käsittelijäfunktio pääsee kentän arvoon käsiksi.

Lisäyksen käsittelijä on sekin suoraviivainen

  const addNote = (e) => {
    e.preventDefault()
    const content = e.target.note.value
    add({ id: generateId(), content, important: false })
    e.target.reset()
  }

Lomakkeen tekstikentästä haetaan sisältö e.target.note.value muuttujaan, jota käytetään parametrina muistiinpanon lisäysfunktion add kutsussa.

Viimeinen rivi eli, eli e.target.reset() tyhjentää lomakkeen.

Sovelluksen tämänhetkinen koodi on kokonaisuudessaan GitHubissa, branchissa part6-1.

Lisää komponentteja ja toiminnallisuutta

Jaetaan sovellus useampaan komponenttiin. Eriytetään uuden muistiinpanon luominen, muistiinpanojen lista sekä yksittäisen muistiinpanon esittäminen omiksi komponenteikseen.

Komponentti App on muutoksen jälkeen yksinkertainen:

const App = () => (
  <div>
    <NoteForm />
    <NoteList />
  </div>
)

Muistiinpanon luominen eli NoteForm ei sisällä mitään dramaattista, ei toisteta sen koodia täällä.

Muistiinpanojen listaamisesta vastaava komponentti NoteList näyttää seuraavalta

import { useNotes } from './store'
import Note from './Note'

const NoteList = () => {
  const notes = useNotes()

  return (
    <ul>
      {notes.map(note => (
        <Note key={note.id} note={note} />
      ))}
    </ul>
  )
}

Komponentti siis hakee storesta muistiinpanojen listan, ja luo jokaista vastaavan Note komponentin, jolle se välittää muistiinpanon tiedot propsina:

const Note = ({ note }) => (
  <li>
    {note.important ? <strong>{note.content}</strong> : note.content}
  </li>
)

Lisätään vielä sovellukseen mahdollisuus muistiinpanon tärkeyden muuttamiseen. Komponentti on muutoksen jälkeen seuraava:

import { useNoteActions } from './store'

const Note = ({ note }) => {
  const { toggleImportance } = useNoteActions()
  return (
    <li>
      {note.important ? <strong>{note.content}</strong> : note.content}
      <button onClick={() => toggleImportance(note.id)}>        {note.important ? 'make not important' : 'make important'}      </button>    </li>
  )
}

Komponentti destrukturoi funktion useNoteActions paluuarvosta tärkeyden muuttavan funktion, jota se kutsuu muutosnappia klikatessa.

Tärkeyden muuttavan funktion toteutus näyttää seuraavalta:

import { create } from 'zustand'

const useNoteStore = create(set => ({
  notes: [],
  actions: {
    add: note => set(
      state => ({ notes: state.notes.concat(note) })
    ),
    toggleImportance: id => set(      state => ({        notes: state.notes.map(note =>          note.id === id ? { ...note, important: !note.important } : note        )      })    )  }
}))

Funktio siis saa parametrikseen muutettavan muistiinpanon id:n. Uusi tila muodostetaan vanhan perusteella funktion map avulla siten, että mukaan otetaan kaikki vanhat muistiinpanot, paitsi muutettavasta muistiinpanosta tehdään versio, jossa sen tärkeys muuttuu päinvastaiseksi:

{ ...note, important: !note.important } 

Sovelluksen tämänhetkinen koodi on kokonaisuudessaan GitHubissa, branchissa part6-2.