Vuex: state management per Vue

Nello scorso articolo ho introdotto alcune caratteristiche del framework, focalizzandomi principalmente sulla core library. In questo nuovo articolo introdurrò Vuex, una libreria ufficiale per lo state management di applicazioni scritte in Vue.

Come si può intuire dal nome, si parla di una libreria che si inspira a Flux, Redux e architetture Elm ma di fatto è un implementazione specifica per Vue e che quindi meglio si adatta al sistema di reattività del framework stesso.

Per capire i concetti e sintassi di questa libreria implementeremo l’applicazione todo-list vista nel precedente articolo.

State Management

I principali aspetti riguardanti lo state management sono:

  • come viene memorizzato lo stato;
  • come vengono tracciati i cambiamenti e come questi possono essere, efficientemente, applicati alla UI.

Come si può capire è un concetto più ampio che non si limita alla sola definizione dei dati che rappresentano lo stato ma bensì anche a fornire un modo coerente e prevedibile di aggiornamento dello stato stesso.

Per soddisfare tutto ciò o parte di questi aspetti si possono adottare diversi approcci e nel dettaglio vedremo quello implementato da Vuex.

Caratteristiche

Il concetto principale è lo store:  in poche parole il contenitore dello stato della propria applicazione; la sua principale caratteristica è di essere reattivo che fa si che i componenti che lo utilizzano reagiscono in modo efficiente a eventuali aggiornamenti ad esso.

I concetti chiave sono:

  • state: rappresenta i veri propri dati (plain-object in sola lettura reso “reattivo”);
  • getters: funzioni pure atte a restituire lo state o un suo derivato;
  • mutations: si compongono da un type e un handler (funzione) e quest’ultimo ha il compito di modificare lo state.
  • actions: funzioni che permettono di modificare lo state richiamando le mutations ma con la differenza che possono introdurre al loro interno operazioni asincrone.

Setup del progetto

Giusto per introdurre un utile tool ufficiale, ho creato l’applicazione mediante vue-cli; questo permette di creare da riga di comando lo scheletro della propria applicazione a partire da dei template già pronti, scaricabili da un repository github o localmente. Nel mio caso ho utilizzato il template webpack-simple che si trova tra quelli disponibili nel repository ufficiale.

npm install -g vue-cli
vue init webpack-simple vuex-todo-list

Il primo comando installerà il tool globalmente mentre il secondo creerà un nuovo progetto con la struttura del template indicato.

Fatto ciò è necessario installare la libreria Vuex, come di seguito:

npm install vuex --save

Breve nota su vue-cli: ad oggi è in fase beta la nuova versione (3) che è stata stravolta per passare da una struttura a template a una a plugins, rendendo la creazione, e anche la stessa evoluzione, di un progetto plug-and-play, senza la dipendenza da un unico template che ne determina la struttura alla sola creazione.

Store

Il nostro store sarà cosi definito:

import Vue from 'vue'
import Vuex from 'vuex'

import state from './state'
import getters from './getters'
import mutations from './mutations'
import actions from './actions'

Vue.use(Vuex)

const store = new Vuex.Store({
    strict: process.env.NODE_ENV !== 'production',
    state,
    getters,
    mutations,
    actions
})

export default store

Come si può vedere si compone da quanto detto in fase introduttiva ovvero la creazione di un nuovo Vuex.Store e si aggancia a Vue con il meccanismo caratteristico di un plugin Vue.use.

const state = {
    todos: [],
    sequence: 1
}

export default state

Lo state è un oggetto che rappresenta i veri e propri dati della nostra applicazione; nello specifico la lista dei todo e un campo che sarà utilizzato per assegnare un progressivo ad ogni singolo todo.

const getters = {
    allTodos: (state) => {
        return state.todos
    },
    completedTodos: (state) => {
        return state.todos.filter((todo) => todo.done)
    },
    pendingTodos: (state) => {
        return state.todos.filter((todo) => !todo.done)
    }
}

export default getters

I getters sono funzioni pure che prendono in input lo state e ne restituiscono un suo derivato; nello specifico, abbiamo la lista completa dei todo, la lista dei solo todo completati e quella dei solo non completati.

import * as types from './mutation-types'

const mutations = {
    [types.ADD_TODO]: (state, text) => {
        state.todos.push({
            id: state.sequence,
            text,
            done: false
        })
        state.sequence += 1
    },
    [types.MARK_AS_DONE]: (state, id) => {
        const index = state.todos.map((todo) => todo.id).indexOf(id)
        state.todos[index].done = true
    }
}

export default mutations

Le mutations sono identificate da un type (stringa) e da un handler che prende in input lo state e un payload; nello specifico, saranno utilizzate per aggiungere un nuovo todo nella lista e segnare un todo come completato.

import * as types from './mutation-types'

const actions = {
    addTodo: (context, text) => {
        context.commit(types.ADD_TODO, text)
    },
    markAsDone: (context, id) => {
        context.commit(types.MARK_AS_DONE, id)
    }
}

export default actions

Le actions sono funzioni che prendono in input il contesto (metodi/proprietà messe a disposizione dallo store) e un payload e mediante la chiamata context.commit viene invocata una specifica mutation al fine di modificare lo state (possono essere invocate una o più mutations e/o altre actions mediante context.dispatch )

Nota: nel nostro caso potevamo fare a meno di tutto ciò e richiamare, come vedremo, dai componenti direttamente le mutations; immaginate però di dover recuperare la lista dei todo mediante api e quindi dover effettuare una chiamata asincrona, insomma questo è il posto più corretto e che rappresenta di fatto la business logic della propria applicazione. Inoltre un action stessa è una funziona asincrona (implementata internamente come Promise) e quindi è possibile gestirne il suo esito (o di un eventuale funzione asincrona in essa ritornata) all’invocazione della dispatch.

Componenti

L’entry point dell’applicazione è rappresentato come segue:

import Vue from 'vue'
import store from './store'

import App from './App'

new Vue({
  el: '#app',
  store,
  render: h => h(App)
})

da come si può notare lo store visto in precedente viene iniettato nell’istanza di Vue.

Nella scrittura dei componenti ho utilizzato la modalità denominata single file component (per dettagli vedere il precedente articolo).

<template>
    <section>
        <div class="block">
            <select class="select" v-model="status">
                <option value="all"> All </option>
                <option value="pending"> Pending </option>
                <option value="completed"> Completed </option>
            </select>
        </div>
        <table class="table is-fullwidth">
            <tbody>
                <tr v-for="(todo, index) in todos" :key="index">
                    <td> {{ todo.text }} </td>
                    <td>
                        <a  href="#"
                            v-show="!todo.done"
                            class="button is-pulled-right" 
                            @click.prevent="markAsDone(todo)"> Done </a>
                    </td>
                </tr>
            </tbody>
        </table>
    </section>
</template>

<script>
export default {
    name: 'TodoList',
    data() {
        return {
            status: 'pending'
        }
    },
    computed: {
        todos() {
            if (this.status === 'all') {
                return this.$store.getters.allTodos
            } else if (this.status === 'pending') {
                return this.$store.getters.pendingTodos
            } else if (this.status === 'completed') {
                return this.$store.getters.completedTodos
            }
        }
    },
    methods: {
        markAsDone(todo) {
            this.$store.dispatch('markAsDone', todo.id)
        }
    }
}
</script>

Il componente TodoList mostra l’elenco dei todo e nel farlo accede allo state mediante i getters definiti nello store che vengono richiamati a seconda del filtro selezionato nell’interfaccia. Oltre a questo, gestisce l’azione di completamento invocando l’azione ‘markAsDone‘ mediante la chiamata dispatch messa a disposizione dallo store (come detto in precedenza qui potevamo direttamente chiamare la mutation attraverso la chiamata commit sempre dallo store).

<template>
    <form @submit.prevent="add">
        <div class="field is-grouped">
            <p class="control is-expanded">
                <input type="text"
                    class="input"
                    v-model="text"
                    placeholder="Add todo..."
                    required />
            </p>
            <p class="control">
                <button type="submit" class="button is-primary"> Add </button>
            </p>
        </div>
    </form>
</template>

<script>
export default {
    name: 'TodoForm',
    data() {
        return {
            text: ''
        }
    },
    methods: {
        add() {
            this.$store.dispatch('addTodo', this.text)
            this.text = ''
        }
    }
}
</script>

Il componente TodoForm mostra il form di inserimento del todo e mediante chiamata dispatch invocherà la action ‘addTodo’ passando come payload il testo appena inserito.

Modules

Una caratteristica interessante sono i modules con i quali è possibile dividere lo store in appunto moduli che possono contenere un proprio state, dei propri getters, delle proprie mutations, actions ed eventualmente altri modules; di fatto viene definito come se fosse uno store a se stante ma questo non deve trarre in inganno perché un modulo è soltanto una divisione dello store ed infatti è possibile accedere e comunicare a e tra diversi moduli.

Particolare attenzione a mutations, getters e actions che di default sono registrati a un namespace globale e quindi tutto ciò può portare a moduli richiamati per omonime mutations e/o actions; per questo è possibile definire un modulo come namespaced e quindi isolarlo dagli altri moduli, ma il discorso fatto prima non cade ed è quindi sempre possibile una comunicazione fra loro.

Vediamo come poter modificare il nostro esempio con quanto introdotto:

import * as types from '../../mutation-types'

const state = {
    todoStatus: 'pending'
}

const getters = {
    todoStatus: (state) => {
        return state.todoStatus
    }
}

const mutations = {
    [types.SET_TODO_STATUS]: (state, filter) => {
        state.todoStatus = filter
    }
}

const actions = {
    setTodoStatus: ({commit}, filter) => {
        commit(types.SET_TODO_STATUS, filter)
    }
}

export default {
    namespaced: true,
    state,
    mutations,
    getters,
    actions
}

Questo è il modulo filters che conterrà il valore della variabile di filtro applicato alla visualizzazione della lista dei todos che negli esempi precedenti veniva memorizzata direttamente nel componente TodoList. Come si può notare un modulo può contenere tutte le caratteristiche di uno store (state, getters, …) e nello specifico è anche attiva la modalità namespaced.

import Vue from 'vue'
import Vuex from 'vuex'

import state from './state'
import getters from './getters'
import mutations from './mutations'
import actions from './actions'

import filters from './modules/filters'

Vue.use(Vuex)

const store = new Vuex.Store({
    strict: process.env.NODE_ENV !== 'production',
    state,
    getters,
    mutations,
    actions,
    modules: {
        filters
    }
})

export default store

Lo store quindi verrà modificato con l’aggiunta del nostro modulo filters creato precedentemente.

A livello di componenti ho aggiunto un nuovo componente denominato TodoFilter:

<template>
    <section>
        <div class="block">
            <select class="select" v-model="status">
                <option value="all"> All </option>
                <option value="pending"> Pending </option>
                <option value="completed"> Completed </option>
            </select>
        </div>
    </section>
</template>

<script>
export default {
    name: 'TodoFilter',
    computed: {
        status: {
            get() {
                return this.$store.getters['filters/todoStatus']
            },
            set(value) {
                this.$store.dispatch('filters/setTodoStatus', value)
            }
        }
    }
}
</script>

Sfruttando la potenza delle computed properties è possibile definire una variabile del componente il cui valore viene recuperato e salvato direttamente nello store. Qui si può notare un primo cambiamento ovvero è necessario specificare il nome del modulo nel richiamare l’action e il getter; tutto questo si rende necessario a causa dell’abilitazione della modalità namespaced in fase di creazione del modulo.

<template>
    <section>
        <table class="table is-fullwidth">
            <tbody>
                <tr v-for="(todo, index) in todos" :key="index">
                    <td> {{ todo.text }} </td>
                    <td> 
                        <a  href="#"
                            v-show="!todo.done"
                            class="button is-pulled-right" 
                            @click.prevent="markAsDone(todo)"> Done </a>
                    </td>
                </tr>
            </tbody>
        </table>
    </section>
</template>

<script>
export default {
    name: 'TodoList',
    computed: {
        todos() {
            const status = this.$store.getters['filters/todoStatus']
            if (status === 'all') {
                return this.$store.getters.allTodos
            } else if (status === 'pending') {
                return this.$store.getters.pendingTodos
            } else if (status === 'completed') {
                return this.$store.getters.completedTodos
            }
        }
    },
    methods: {
        markAsDone(todo) {
            this.$store.dispatch('markAsDone', todo.id)
        }
    }
}
</script>

Il componente TodoList recupererà il valore dello status direttamente dallo store dal modulo filters e reagirà al suo cambiamento ricaricando l’elenco dei todos secondo il nuovo valore selezionato.

A conclusione e a conferma di quanto detto in precedenza cioè che è possibile una comunicazione tra modules, è quindi possibile modificare come segue:

const getters = {
    allTodos: (state) => {
        return state.todos
    },
    completedTodos: (state) => {
        return state.todos.filter((todo) => todo.done)
    },
    pendingTodos: (state) => {
        return state.todos.filter((todo) => !todo.done)
    },
    todos: (state, getters, rootState, rootGetters) => {
        const status = rootGetters['filters/todoStatus']
        if (status === 'all') {
            return getters.allTodos
        } else if (status === 'pending') {
            return getters.pendingTodos
        } else if (status === 'completed') {
            return getters.completedTodos
        }
    }
}

export default getters

Lo store introduce un nuovo getters che restituisce l’elenco dei todos a seconda del valore dello status contenuto nel modulo filters. Come si può notare sono previsti ulteriori parametri per ogni getters e nello specifico tramite rootGetters si accede allo store dal quale a sua volta si può accedere a moduli e/o sotto moduli.

<template>
    <section>
        <table class="table is-fullwidth">
            <tbody>
                <tr v-for="(todo, index) in todos" :key="index">
                    <td> {{ todo.text }} </td>
                    <td> 
                        <a  href="#"
                            v-show="!todo.done"
                            class="button is-pulled-right" 
                            @click.prevent="markAsDone(todo)"> Done </a>
                    </td>
                </tr>
            </tbody>
        </table>
    </section>
</template>

<script>
import { mapGetters } from 'vuex'

export default {
    name: 'TodoList',
    computed: {
        ...mapGetters([
            'todos'
        ])
    },
    methods: {
        markAsDone(todo) {
            this.$store.dispatch('markAsDone', todo.id)
        }
    }
}
</script>

Infine il componente TodoList non dovrà più verificare lo status attuale ma avrà automaticamente la lista corretta da visualizzare mediante il nuovo getters ‘todos’. Ultima nota: Vuex offre degli helpers (es: mapGetters) con i quali è possibile mappare methods e computed properties direttamente con state, getters, mutatations e actions contenuti nello store (anche di eventuali moduli).

Conclusioni

Come avete visto, utilizzare Vuex è abbastanza semplice e intuitivo e porta a diminuire drasticamente se non ad azzerare le dipendenze. senza dimenticare che al crescere della propria applicazione, tutto ciò porta a una migliore manutenibilità e struttura del codice; alcuni concetti come state e getters possono essere visti e considerati rispettivamente come data e computed che rappresentano di fatto lo stato di un singolo componente.

Citando il creatore di Redux “Flux libraries are like glasses: you’ll know when you need them.”, si può intuire che ci sono certamente applicazioni in cui può non convenire utilizzare un pattern di questo tipo e adottare soluzioni come per esempio un bus di eventi globale o una soluzione come quella dell’articolo precedente basata su comunicazioni tra componenti con ascolto di eventi e passaggio di props a partire da un componente di root.

Potete trovare il sorgente di questa applicazione su questo repository Github.

Author :
Web e Mobile developer, da sempre legato al mondo Java ma con un occhio verso il mondo Javascript. Ad oggi ricopro la figura di Full Stack Developer. Questo il mio account GitHub