Managing State in Vue with Vuex
Every app at some point will need to tackle one of the most contentious subjects in front end development: State Management. If you ask ten different developers how to manage state in an app, you’ll most likely get ten different answers, as everyone can have a slightly different approach if they were to build their own system. Thankfully, most frameworks provide some opinionated solutions for managing state in an app. For Vue, developers make use of the library Vuex, which provides common patterns that makes managing state predictable and consistent across the entire app. Let’s look at how we can manage a simple TODO app using Vuex and as an added benefit, we’ll make it type safe using TypeScript.
The Shell
Before we dive into Vuex, let’s look at the shell of our app. What we have is basically a single route app that should load a list of “todos”. Users should be able to tap the “+” button in the header to open a modal and add a new item, or click the item itself to edit an existing todo. We can mark an item as completed in the modal or by swiping the item to to reveal some additional buttons. As far as complexity goes, this is your basic CRUD app. While basic, and a bit contrived, these kinds of apps perform the same type of actions we do in most situations. So this is a good chance to discover some best practices.
Now this setup isn’t overly complex, but it already is showing signs of overly coupled logic. With everything being declared in the component, if we need to change our architecture at all, or the format of our data, we basically have to change it in multiple places.
Making things predictable
To bring some structure to our app, let’s add Vuex.
vue add vuex@next
This will install the deps we need and perform any changes to our file system. With this, we get a new src/store/index.ts
file for us to work in. Now Vuex is based on a few concepts; A store, mutations, and actions.
Store
In Vuex, a Store is a global state that is available throughout our app. This is just a fancy way of saying we have an object that we can mutate and reflect these changes in our UI.
In our app, we can create our store to hold our various “todos”
import { InjectionKey } from 'vue';
import { createStore, useStore as baseUseStore, Store } from 'vuex';
// interfaces for our State and todos
export interface Todo {
id: number;
title: string;
note?: string;
}
export interface State {
todos: Todo[];
}
export const key: InjectionKey<Store<State>> = Symbol();
const state: State = {
todos: [
{ title: 'Learn Vue', note: 'https://v3.vuejs.org/guide/introduction.html', id: 0, },
{ title: 'Learn TypeScript', note: 'https://www.typescriptlang.org', id: 1, },
{ title: 'Learn Vuex', note: 'https://next.vuex.vuejs.org', id: 2 },
],
};
export const store = createStore<State>({ state });
// our own `useStore` composition function for types
export function useStore() {
return baseUseStore(key);
}
With our todos setup and primed with some initial data, we can now think about how we modify that state, which is done through mutations.
Mutations
Mutations, as the name implies, are a way to mutate our state. This is very different compared to something like Redux which uses immutable state, but achieves the same effect. With Mutations, we essentially have a handler that gets called and is passed the current state, along with any payload.
For our use case, we’re going to make sure we can type our Mutations and provide some auto completion in our editors. We’ll start off with an object that will have all our mutations declared for us
export const enum MUTATIONS {
ADD_TODO = 'ADD_TODO',
DEL_TODO = 'DEL_TODO'
};
Next, we’ll actually define our mutations:
import { createStore, useStore as baseUseStore, Store, MutationTree } from 'vuex';
// ...
const mutations: MutationTree<State> = {
[MUTATIONS.ADD_TODO](state, newTodo: Todo){
state.todos.push({...newTodo});
},
[MUTATIONS.DEL_TODO](state, todo: Todo){
state.todos.splice(state.todos.indexOf(todo), 1);
}
}
We have two mutations available; one to add a todo to our store and another to remove a todo. You may notice that we have a type on the todo
, but not on state, why is that? Well thanks to the MutationTree
type, the type information from State
that is passed in will flow throughout our mutations. Now the only thing we need to type is the payload, which can change depending on what mutation we call.
The last thing to note about mutations is that they only care about changing state. So to change state at all with Vuex, you must use Mutations.
Actions
Actions are like Mutations, but can perform asynchronous functions and call other mutations. This is a useful way to separate tasks in your app that depend on external resources and those that can be performed with the data at hand. Like mutations, we’ll split our actions up by a Type and then the actual implementation.
export const enum ACTIONS { ADD_RND_TODO = 'ADD_RND_TODO' };
const actions: ActionTree<State, any> = {
[ACTIONS.ADD_RND_TODO](store) {
fetch('https://fakerapi.it/api/v1/texts?_quantity=1')
.then((res) => res.json())
.then(({ data }) => {
const newTodo: Todo = {
title: data[0].title,
id: Math.random(),
note: data[0].content,
};
store.commit(MUTATIONS.ADD_TODO, newTodo);
});
},
};
Actions receive the context or actual store object as the first argument, followed by any payload that. With our action, we can make a request to some API, resolve that response, and kick off a mutation, all with the function. It doesn’t need to be a single mutation either, we could trigger one, two, or more mutations, or conditional trigger a mutation based on the resutl of a request (a side effect).
Putting it all together
With these pieces together, our overall store should look something like this.
import { InjectionKey } from 'vue';
import { createStore, useStore as baseUseStore, Store, MutationTree, ActionTree, } from 'vuex';
// interfaces for our State and todos
export type Todo = { id: number; title: string; note?: string };
export type State = { todos: Todo[] };
export const key: InjectionKey<Store<State>> = Symbol();
const state: State = {
todos: [
{
title: 'Learn Vue',
note: 'https://v3.vuejs.org/guide/introduction.html',
id: 0,
},
{
title: 'Learn TypeScript',
note: 'https://www.typescriptlang.org',
id: 1,
},
{ title: 'Learn Vuex', note: 'https://next.vuex.vuejs.org', id: 2 },
],
};
/*
* Mutations
* How we mutate our state.
* Mutations must be synchronous
*/
export const enum MUTATIONS {
ADD_TODO = 'ADD_TODO',
DEL_TODO = 'DEL_TODO',
EDIT_TODO = 'EDIT_TODO'
};
const mutations: MutationTree<State> = {
[MUTATIONS.ADD_TODO](state, newTodo: Todo) {
state.todos.push({ ...newTodo });
},
[MUTATIONS.DEL_TODO](state, todo: Todo) {
state.todos.splice(state.todos.indexOf(todo), 1);
},
[MUTATIONS.EDIT_TODO](state, todo: Todo) {
const ogIndex = state.todos.findIndex(t => t.id === todo.id)
state.todos[ogIndex] = todo;
},
};
/*
* Actions
* Perform async tasks, then mutate state
*/
export const enum ACTIONS { ADD_RND_TODO = 'ADD_RND_TODO', };
const actions: ActionTree<State, any> = {
[ACTIONS.ADD_RND_TODO](store) {
fetch('https://fakerapi.it/api/v1/texts?_quantity=1')
.then((res) => res.json())
.then(({ data }) => {
const newTodo: Todo = {
title: data[0].title,
id: Math.random(),
note: data[0].content,
};
store.commit(MUTATIONS.ADD_TODO, newTodo);
});
},
};
export const store = createStore<State>({ state, mutations, actions });
// our own useStore function for types
export function useStore() {
return baseUseStore(key);
}
Parting Thoughts
As I stated early, State Management in an app is a bit of a personal preference. What I’ve shown here is simply one way that I would go about it. I’d encourage you all to find your own approaches, but keep a structure like this in your app for consistency. Cheers!