# State management

We saw in the previous section how parent and child components communicate. However, as applications grow and become more complex, components that are far away from each other in the component tree may have to manipulate the same data. It then becomes very tedious to make them share common data references. That's why there are more or less complex state management solutions.

# Why a state management solution ?

Problem with shared props on multiple views

  • because propagating some data ad props of a parent component to the grandchildren and great-grandchildren components quickly becomes tedious
  • to be able to share information between different component trees
  • to delegate data management to a service reachable from all components
  • to be able to persist the data automatically (in localStorage for example)
  • to log application states, or rollback to a previous state with a Cancel feature
  • to help debug, log, monitor or send error reports

# Data shared by reference

The simplest solution to the state management problem is to store the data within one or more objects declared in their own module. All components that want to manipulate this data can then import the object and modify its content while working on the same reference.

/** stores/state.js **/
export const state = {
  user: null,
  loggedIn: false
};
/** LoginForm.vue **/
import state from "stores/state.js"

export default {
  data: { state },
  methods: {
      login(){
          this.state.user = "John Smith"
          this.state.loggedIn = true
      }
  }
}

TIP

Note that we have intentionally declared data as an object and not a function, so that instances of the component use the same shared data reference.

However, we can also mix local data and shared data:

data(){
  return {
    sharedState: state,
    privateState: { ... }
  }
}

This solution can do the job in many cases, but quickly shows its limitations when debugging with many different components interacting with the same data at the same time. Indeed, mutations of state objects are not logged anywhere, so it is difficult to find the origin of a bug.

# Store and controled mutations

A slightly more advanced pattern is to declare a store object that encapsulates the state object and serves as a control interface. The state object is not directly reachable from the outside by reference, but the store provides methods to interact with: typically a getter / setter. We can then add in these methods other features for debugging, monitoring, performance measurement etc.

/** stores/store.js **/
import { reactive } from 'vue'

const state = reactive({ message: "hello" }); // no export for state

export const store = {
  get(prop) {
    if (DEBUG_MODE) console.log("[store] get", prop);
    return state[prop];
  },
  set(prop, value) {
    if (DEBUG_MODE) console.log("[store] set", prop);
    state[prop] = value;
  }
};
<!-- MyComponent.vue -->
<script>
import store from "@/stores/store.js";

export default {
  data() {
    return {
      privateState: {},
      store
    };
  },
  computed: {
    message() {
      return this.store.get("message");
    }
  },
  methods: {
    exit() {
      this.store.set("message", "bye!");
    }
  }
};
</script>

# Pinia

Once you have a store pattern, it is tempting to enrich it with many features and take advantage of the centralization of state mutations. This has been a field of research for many development teams, especially outside Vue ecosystem, for example the work on the Flux architecture done by the React team.

The Vue community went through the same process and came to Vuex (opens new window), a proposal for an official state management solution for Vue. After Vue 3 release and the introduction of the Composition API, a new library called Pinia (opens new window) replaced VueX as the official recommendation. That's what we are going to use here.

Pinia is the culmination of this centralized store pattern. It is the official state management solution provided by the Vue team. Pinia will not necessarily find its place in all Vue projects, but it is a very efficient tool in large applications that handle a lot of data.

Pinia works with the following concepts:

  • the state, which holds the data of your store
  • the actions, which are used to mutate the store, equivalent to methods in components
  • the getters, which are helpers to access to your store data, equivalent to computed

The mutated state updates reactively all the views that use it, regardless of their depth in the component tree.

# Practical work: Implement a Pinia store

  1. Install pinia and pinia-plugin-persistedstate dependencies that will be used to persist store state.
npm install pinia pinia-plugin-persistedstate
  1. Create a Pinia store for the session by creating a src/stores/session.js file with following content:
import { defineStore } from "pinia";

export const useSession = defineStore('session', {
  persist: true,
  state: () => {
    return {
      user: null,
      loggedIn: false
    }
  },
  actions: {
    login({ user }) {
      this.loggedIn = true
      this.user = user
    }
  }
})
  1. Declare the store in your application by completing the main.js file like this:







 


import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)

createApp(App)
  .use(pinia)
  .mount('#app')
  1. In the application code, retrieve the loggedIn variable from the store with useSession()

TIP

The mapState and mapActions are helpers provided with Pinia that can be used to shorten the code by easily linking store data and actions to computed and methods.

import { useSession } from "@/stores/session"
import { mapState, mapActions } from "pinia";

export default {
  computed: {
    // bind this.loggedIn to useSession().loggedIn
    ...mapState(useSession, ["loggedIn"])
  },
  methods: {
    // bind this.login to useSession().login  
    ...mapActions(useSession, ["login"])
  }
}
  1. In LoginForm.vue, on form submit, check if the user has entered the email address test@test.com and the password test1234. If so, trigger the login action for the user test.

TIP

Invoking an action from a component is done by calling it as a method of the store:

const session = useSession()
session.login({ user: { firstname: "John", lastname: "Smith" } });
  1. Login with the identifiers mentioned above, check that the navigation to the search form is working properly, then refresh the page.

Question: Why don't we return to the login form after refreshing the page ?

  1. If the user has entered wrong credentials in the login form, display an error message below the login button. To help you, you can declare an additional error string in the component's data.

  2. Bonus: Code a logout action and add a logout button in App component that invokes this action. Display the name of the logged in user next to this button.