Programación usando VueJS (Vuex). Parte 9

Empezando con Vuex

Vuex es una librería para la gestión de estados en Vue.js. 

Sirve como un almacén general para toda la aplicación, y tiene mecanismos que garantizan que el estado solo se puede mutar de manera controlada.

Debemos saber que este almacenamiento funciona como un singleton, ya que solo nos permite tener una instancia donde controlamos todo.

Con este patrón de diseño conseguimos que cualquier cambio se vea reflejado en todos los componentes que accedan a ese estado.

Ahora que sabemos que es Vuex vamos a instalarlo, y una vez instalado empezaremos a definir ciertos conceptos para entender como funciona.

Para realizar la instalación abrimos una consola de comandos y ejecutamos el siguiente comando: npm i -S vuex

Cuando termine la instalación, crearemos dentro de la carpeta src un archivo llamado store.js.

Este archivo contendrá toda la lógica necesaria para gestionar ese estado.

Crearemos un fichero muy simple para empezar a conocer como funciona Vuex.

store.js

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

Vue.use(Vuex)

const store = new Vuex.Store({
  state: {
    totalSearches: 0
  },

  mutations: {
    addSearch (state) {
      state.totalSearches++
    }
  }
})

export default store

Cuando tengamos creado el fichero store.js, entonces realizaremos la importación dentro de main.js para usarlo en nuestro proyecto.

main.js

import Vue from 'vue'
// Importamos Vue router
import VueRouter from 'vue-router'
import App from '@/App.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import { fas } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import FilmLoading from '@/components/global/filmLoading.vue'
import FilmModal from '@/components/global/filmModal.vue'
import EventBus from '@/plugin/eventBus'
import Filters from '@/filters/filters'
import DirectiveWithoutImage from '@/directives/without-image'
import store from './store'

// Importamos las rutas
import routes from '@/routes'

/*
 * Importar componente de forma global
 * Vue.component(tag-html, componente)
 */

Vue.component('film-loading', FilmLoading)
Vue.component('film-modal', FilmModal)

/*
 * Vue.use nos sirve para usar plugins, librerias externas, etc...
 */
Vue.use(EventBus)
Vue.use(VueRouter)
Vue.use(Filters)
Vue.use(DirectiveWithoutImage)

// Declaramos una instancia de Vue-router con las rutas
const router = new VueRouter({
  routes,
  mode: 'history'
})

library.add(fas)
Vue.component('font-awesome-icon', FontAwesomeIcon)

// Pasamos Vuerouter a la instancia global
new Vue({
  el: '#app',
  render: h => h(App),
  router,
  store
})

Justo en este punto tenemos creado e importado el fichero store.js para usarlo en nuestra aplicación.

A continuación, gracias a las devtools de Vue podemos ver ese estado.

Ahora ya podemos comenzar a definir los conceptos que nos servirán para crear nuestro store.

State

Vuex usa un árbol de estado único, es decir, este objeto contiene todo el estado de la aplicación y sirve como único origen de los datos.

Este objeto seria equivalente a la función data(), que es donde se definen las propiedades.

Como habíamos indicado antes, al ser una instancia única, cualquier componente que acceda a esta propiedad y modifique su valor, automáticamente actualizara el valor en todos los sitios donde se use.

En nuestro store.js hemos creado el objeto state con una propiedad llamada totalSearches, que usaremos para saber cuantas veces se hace una búsqueda de cualquier tipo.

Podemos ver que hemos creado otro objeto llamado mutations, y este contendrá los métodos encargados de modificar el state.

A continuación, los cambios en el código para usar el store.

App.vue

...
<header>
  <div v-text="header.title" class="title"></div>
  <div v-text="header.subtitle" class="subtitle"></div>
  <nav>
    <router-link to="/">Home</router-link>
    <router-link to="contact">Contacto</router-link>
  </nav>
  <div>Cantidad de búsquedas en la aplicación: {{countSearch}}</div>
</header>
...
...
computed: {
  countSearch() {
    return this.$store.state.totalSearches;
  }
}
...

Como el state es global y cualquier componente puede modificar su estado, deberíamos usar las computed properties  para su definición en el componente y así nos aseguramos que siempre este actualizado.

Viendo el código de App.vue podemos observar que para acceder al estado usamos el nuevo objeto $store como ocurría con el $router.

En la siguiente imagen veremos que se muestra el contenido que hemos definido en el state.

Ahora que sabemos como funciona el state, vamos a mejorarlo un poco con ayuda de un helper que ofrece vuex.

Cuando queremos definir muchas propiedades debemos crearlas una a una, y sinceramente es un trabajo repetitivo y puede llevar a errores.

Para ayudarnos con esta tarea podemos hacer uso del helper mapState, ya que nos genera las funciones computadas automáticamente.

Lo primero que debemos hacer es importar el mapState de Vuex y cambiaremos el contenido de computed properties.

App.vue

...
<div>Cantidad de búsquedas en la aplicación: {{totalSearches}}</div>
...
import { mapState } from 'vuex'
export default {
...
computed: mapState(['totalSearches'])
...

Esta característica resulta interesante cuando tenemos que definir múltiples propiedades ya que nos ahorra mucho código, pero nos encontramos con otro problema.

Al definir de esta forma el mapState en el objeto computed, no podríamos añadir otras propiedades computadas.

Esto es debido a que mapState nos devuelve un objeto, y como habíamos dicho antes no podríamos añadir mas propiedades computadas.

Para solucionarlo vamos a usar una de las nuevas características que ofrece Javascript, el spread operator.

El spread operator nos permite generar una lista de valores a partir de un array, pudiendo de esta forma añadir otras propiedades computadas al componente.

App.vue

...
<div>{{msnS}} {{totalSearches}}</div>
...
data () {
  return {
    header: {
      title: 'OMDb API',
      subtitle: 'The Open Movie & TV Show Database'
    },
    msnSearching: 'Cantidad de búsquedas en la aplicación:'
  }
},
created() {
  this.$router.push({ name: 'home'})
},
computed: {
  ...mapState(['totalSearches']),
  msnTitle() {
    return this.msnSearching
  },
  msnSWelcome() {
    return 'Saludos'
  }
}
...

Ahora ya sabemos que es el state en vuex, así que podemos continuar con el siguiente concepto.

Que son las mutations

Las mutations nos permite modificar los valores del state.

No debemos modificar el estado de vuex directamente ya que con las mutations se puede registrar todos los cambios que se realizaron sobre el estado.

Otra cosa que debemos tener en cuenta sobre las mutations, es que estas son síncronas.

Como en el código anterior ya habíamos definido mutations, ahora veremos como se usan.

home.vue

...
searchFilm () {
  if (this.search.length > 0) {
    this.$store.commit('addSearch');
    this.showSearchInternet = false
---

Para acceder a mutations usaremos el objeto $store, usando .commit(‘nombre mutations’).

En el vídeo anterior vemos como desde el componente home.vue hemos modificando el valor del state. 

A continuación los cambios realizados en el state se propagaran, actualizando de esta forma los valores donde se encuentre definido el state.

Bien, ¿y si necesito que ese contador pueda incrementar usando un valor dinámico?.

Para eso las mutations nos ofrece la posibilidad de usar otro parámetro llamado payload.

Ese payload en la mayoría de las ocasiones será un objeto, pero no es obligatorio ya que podríamos enviar cualquier otro valor.

home.vue

...
 searchFilm () {
    if (this.search.length > 0) {
      this.$store.commit('addSearch', {
        value: 2
      });
      this.showSearchInternet = false
...

store.js

mutations: {
   addSearch (state, data = {}) {
     state.totalSearches += data.value || 1
   }
 }

Como sucedía con el state, las mutations también tienen su helper llamado mapMutations.

Lo importamos de la misma forma que el mapState, lo definimos dentro de methods usando el spread operator y finalmente lo usaríamos como un método mas de nuestro componente.

home.vue

...
import { mapMutations } from 'vuex'
...
methods: {
  ...mapMutations(['addSearch']),
...
searchFilm () {
  if (this.search.length > 0) {
    // Mutations
    this.addSearch({value: 5});
...

Ahora que sabemos como se almacenan y modifican las propiedades del state, a continuación veremos como leer esos datos.

Que son los getters

Los getters los podemos definir como la forma de acceder a las propiedades del state de una forma personalizada.

Cuando decimos que podemos acceder de forma personalizada, significa que deseamos tener algo mas que el valor de la propiedad en el state.

La definición de los getters se realiza de la misma forma que las mutations.

A continuación vamos a crear algunos ejemplos para ver los diferentes usos.

store.js

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

Vue.use(Vuex)

const store = new Vuex.Store({
  state: {
    totalSearches: 0,
    exampleProp: [
      { id: 1, title: 'Indiana Jones', score: 8 },
      { id: 2, title: 'The goonies', score: 6 },
      { id: 3, title: 'Harry Potter', score: 7 },
      { id: 4, title: 'Star Wars', score: 5 },
      { id: 5, title: 'Lord of the rings', score: 9 }
    ],
    filterScore: 0
  },

  mutations: {
    addSearch (state, data = {}) {
      state.totalSearches += data.value || 1
    },
    changeFilterScore (state, data = {}) {
      state.filterScore = data.value || 0
    }
  },

  getters: {
    getTotalSearches (state) {
      return state.totalSearches
    },
    getExampleProp (state) {
      return state.exampleProp
    },
    getFilterScore (state) {
      return state.filterScore
    },
    getFilterMovies (state) {
      return state.exampleProp.filter(data => data.score >= state.filterScore)
    }
  }
})

export default store

home.vue

<template>
  <main v-cloak>
    <section class="container">
      <div class="box-search">
        <input type="search" v-model="search" @keyup.enter="searchFilm()">
        <a v-show="checkSearch" @click="searchFilm()"><font-awesome-icon icon="search" /></a>
        <div class="pagination" v-show="showPagination">
          <span class="reg-prev" @click="previous()">
            <font-awesome-icon icon="angle-left" />
          </span>
          <span class="total-pages" v-text="printPages"></span>
          <span class="reg-next" @click="next()">
            <font-awesome-icon icon="angle-right" />
          </span>
        </div>
      </div>
      <div class="search-internet" v-show="showSearchInternet">
        <iframe :src="queryComplete" frameborder="0"></iframe>
      </div>
      <div class="box-result">
        <film-card v-show="showFilm" :data-film="dataFilm" :no-image="noImage" @search-in-wikipedia="searchInfoWikipedia">
          <!-- slot con nombre -->
          <h1 slot="busqueda">
            Buscando.... {{search}}
          </h1>
        </film-card>
        <film-card-detail :data-detail-film="dataDetailFilm" :show-detail-film="showDetailFilm"></film-card-detail>
        <div v-show="showGetters">
          <h1>Getters</h1>
          <h4>Acceso a state sin usar getters</h4>
          <hr>
          <ul>
            <li>totalSearches:{{this.$store.state.totalSearches}}</li>
            <li>exampleProp:{{this.$store.state.exampleProp}}</li>
            <li>filterScore:{{this.$store.state.filterScore}}</li>
          </ul>
          <h4>Acceso a state usando getters</h4>
          <hr>
          <ul>
            <li>totalSearches:{{this.$store.getters.getTotalSearches}}</li>
            <li>exampleProp:{{this.$store.getters.getExampleProp}}</li>
            <li>filterScore:{{this.$store.getters.getFilterScore}}</li>
          </ul>
          <hr>
          <h4>Acceso a state usando getters personalizados</h4>
          <hr>
          <ul>
            <li>Score<input type="range" min="0" max="10" value="0" @change="newFilterScore">{{this.$store.getters.getFilterScore}}</li>
            <li>totalSearches:{{this.$store.getters.getTotalSearches}}</li>
            <li>exampleProp:{{this.$store.getters.getFilterMovies}}</li>
            <li>filterScore:{{this.$store.getters.getFilterScore}}</li>
          </ul>
          <hr>
        </div>
      </div>
      <film-modal type-error="error" :msn-modal="msnError" :show-modal="showError"></film-modal>
      <film-loading :show-loading="showloading"></film-loading>
    </section>
  </main>
</template>

<script>
import API from '@/servicios.js'
import { mapMutations } from 'vuex'
/*
 * Importando un componente de forma local.
 * Solo lo usamos donde se importa
 */
import FilmCard from '@/components/local/filmCard.vue'
import FilmCardDetail from '@/components/local/filmCardDetail.vue'
export default {
  components: {
    FilmCard,
    FilmCardDetail
  },
  name: 'app',
  data () {
    return {
      showButton: false,
      search: '',
      dataFilm: '',
      showError: false,
      msnError: '',
      dataDetailFilm: '',
      showPagination: false,
      pagination: {
        pagActual: 1,
        totalRecords: 0,
        totalPages: 0
      },
      showloading: false,
      noImage: './dist/no-image.png',
      searchInternet: 'https://es.wikipedia.org/wiki/',
      showSearchInternet: false,
      query: '',
      showGetters: true
    }
  },
  created() {
    this.$bus.$on('show-loading', value => this.showloading = value)
    this.$bus.$on('set-data-film', value => this.dataFilm = value)
    this.$bus.$on('show-error', value => this.showError = value)
    this.$bus.$on('msn-error', value => this.msnError = value)
    this.$bus.$on('data-detail-film', value => this.dataDetailFilm = value)
    this.$bus.$on('show-pagination', value => this.showPagination = value)
  },
  methods: {
    ...mapMutations(['addSearch','changeFilterScore']),
    newFilterScore (e) {
      const value = parseInt(e.currentTarget.value)
      this.changeFilterScore({ value })
    },
    searchInfoWikipedia (title) {
      this.query = `${this.searchInternet}${title}`
      this.showSearchInternet = true
    },
    searchFilm () {
      if (this.search.length > 0) {
        // Mutations
        this.addSearch({value: 5});
        this.showSearchInternet = false
        this.showloading = true
        const params = {
          title: this.search,
          page: this.pagination.pagActual
        }
        API.getFilmData(params)
          .then(data => {
            this.showloading = false
            this.dataDetailFilm = ''
            if (data.Response === 'True') {
              this.showError = false
              this.msnError = ''
              this.dataFilm = data.Search
              this.pagination.totalRecords = data.totalResults
              this.pagination.totalPages = (data.totalResults / 10) % 1 === 0 ? parseInt((data.totalResults / 10)) : parseInt((data.totalResults / 10)) + 1
              this.showPagination = true
            } else {
              this.dataFilm = ''
              this.showError = true
              this.msnError = data.Error
              this.showPagination = false
            }
          })
          .catch(err => {
            this.showloading = false
            console.log(`Error App.vue ${err}`)
          })
      } else {
        this.dataFilm = ''
        this.showError = true
        this.msnError = 'It is necessary to indicate a title'
        this.showPagination = false
      }
    },
    previous () {
      this.pagination.pagActual = --this.pagination.pagActual < 1 ? 1 : this.pagination.pagActual--
      this.searchFilm()
    },
    next () {
      this.pagination.pagActual = ++this.pagination.pagActual > this.pagination.totalPages ? this.pagination.totalPages : this.pagination.pagActual++
      this.searchFilm()
    }
  },
  computed: {
    queryComplete () {
      return this.query
    },
    checkSearch () {
      return this.search.length === 0 ? false : true
    },
    showFilm () {
      return this.dataFilm === '' ? false : true
    },
    showDetailFilm () {
      return this.dataDetailFilm === '' ? false : true
    },
    printPages () {
      return `${this.pagination.pagActual} de ${this.pagination.totalPages}`
    }
  }
}
</script>

<style scoped>
  ul {
      margin: 0;
      padding: 0;
      text-align: left;
  }
  ul li {
    padding: 5px 0;
  }
</style>

En el código anterior hemos comprobado como podemos acceder a los valores del state usando directamente las propiedades, llamando a los getters y finalmente los getters con un resultado personalizado.

Para finalizar, los getters también tienen su helper llamado mapGetters y funciona de la misma forma.

Se define el mapGetters con los métodos que vayamos a usar, y se añade al objeto computed, para que se actualicen en el momento que cambien sus valores.

home.vue

...
<div v-show="showGetters">
  <h1>Getters</h1>
  <h4>Acceso a state sin usar getters</h4>
  <hr>
  <ul>
    <li>totalSearches:{{this.$store.state.totalSearches}}</li>
    <li>exampleProp:{{this.$store.state.exampleProp}}</li>
    <li>filterScore:{{this.$store.state.filterScore}}</li>
  </ul>
  <h4>Acceso a state usando getters</h4>
  <hr>
  <ul>
    <li>totalSearches:{{this.getTotalSearches}}</li>
    <li>exampleProp:{{this.getExampleProp}}</li>
    <li>filterScore:{{this.getFilterScore}}</li>
  </ul>
  <hr>
  <h4>Acceso a state usando getters personalizados</h4>
  <hr>
  <ul>
    <li>Score<input type="range" min="0" max="10" value="0" @change="newFilterScore">{{this.getFilterScore}}</li>
    <li>totalSearches:{{this.getTotalSearches}}</li>
    <li>exampleProp:{{this.getFilterMovies}}</li>
    <li>filterScore:{{this.getFilterScore}}</li>
  </ul>
  <hr>
  </div>
</div>
...
import { mapMutations, mapGetters } from 'vuex'
...
computed: {
  ...mapGetters(['getTotalSearches', 'getExampleProp', 'getFilterScore', 'getFilterMovies']),
...

Para terminar veremos la última característica que nos ofrece Vuex, las actions.

Que son las actions

Las actions son parecidas a las mutations, salvo estas diferencias:

  • Las actions no puede modificar el estado, lo hacen llamando a las mutations.
  • Las actions pueden tener operaciones asíncronas.

Para entenderlo, crearemos un ejemplo muy sencillo usando una actions.

Se define como el state, getters mutations, mediante un objeto llamado actions.

store.js

...
actions: {
    addSearchAsync (context, data = {}) {
      return new Promise((resolve, reject) => {
        setTimeout(() => {
          const value = Math.round(Math.random() * 100)
          const response = {
            statusCode: value < 50 ? 200 : 400,
            value
          }
          value < 50 ? resolve(response) : reject(response)
        }, 3000)
      })
    }
  }
...

En el ejemplo anterior estamos definiendo una actions y dentro ejecutara una acción asíncrona.

Cuando termine de realizar la acción, llamara al mutations para modificar el state.

home.vue

...
methods: {
    ...mapMutations(['addSearch','changeFilterScore']),
    actionAsync() {
      this.$store.dispatch('addSearchAsync', {
        value: 30
      }).then(res => {
        this.addSearch({value: res.value});
      }).catch(err => {
        console.log(err)
      })
    },
 ...

Como hemos podido comprobar actions mutations son muy parecidas, y si, las actions también tiene su helper llamado mapActions.

home.vue

  ...
  methods: {
    ...mapMutations(['addSearch','changeFilterScore']),
    ...mapActions(['addSearchAsync']),
    actionAsync() {
      this.addSearchAsync({
        value: 30
      }).then(res => {
        this.addSearch({value: res.value});
      }).catch(err => {
        console.log(err)
      })
    },
...

Bien, con las actions terminamos esta entrada y ahora sabemos lo que es Vuex, entendemos como funciona y aprendimos a integrarlo en nuestra aplicación.

El código completo de la aplicación lo puedes descargar de github y puedes probar la aplicación desde aquí.