Back

Integrate Vuex and TypeScript

Integrate Vuex and TypeScript

Vuex is a well-known state management library for Vue, and TypeScript adds data typing to your code to detect and avoid errors, so using both together is quite logical, and this article will show you how to do so.

Vuex is the official state management library made for Vue.js. As an application expands and the number of components increases, handling shared state becomes increasingly challenging. In response to this complexity, Vuex was introduced. It offers a unified approach to managing and updating the state, ensuring consistent and traceable changes.

The creation of Vuex was influenced by the state management patterns and practices from other ecosystems, like Flux from the React community, but it was built specifically to integrate seamlessly with Vue.

TypeScript essentially offers a set of beneficial tools on top of JavaScript. It is a strongly typed superset of JavaScript developed by Microsoft. TypeScript introduces static typing to JavaScript, which means you can specify that a variable should only hold a certain primitive type such as string, boolean, number, etc. If you assign an unspecified type, the TypeScript compiler should throw an error. It also allows the definition of more complex types, such as interfaces and enums.

There is also an important advantage of compile-time type checking, meaning more errors get caught during compile time rather than runtime, which also means fewer bugs in production. Most JavaScript libraries are also supported and compatible with TypeScript, including enhancing the capabilities of Integrated Development Environments (IDEs) and code editors, offering them information from its static type system.

TypeScript also provides other rich features, such as autocompletion in an IDE, as well as type information, expected arguments, return types, etc., when hovering over a variable or function.

An IDE integrated with TypeScript has the added advantage of refactoring. For instance, when a variable name gets changed, the new name gets updated across the codebase due to TypeScript type checking.

TypeScript improves the developer experience, and Vuex particularly benefits by helping to shape and structure the states using defined types, thereby improving the overall state management experience.

Setting up the environment

To integrate Vuex with TypeScript, you need to install Vue if you haven’t already, and then create a new Vue project using the following command:

# Install Vue CLI globally
npm install -g @vue/cli


# Create a new project
vue create my-vue-ts-project

You will be prompted to select features needed for your Vue project. Select the “Manually Select features” option, then select Vuex and TypeScript. This automatically bootstraps your application to use TypeScript and initializes a Vuex store for you on the fly.

After proceeding with the installation, navigate to your project with:

# Install Vue CLI globally
cd my-vue-ts-project

You can open the newly created folder in any IDE of your choice.

Typescript Basics

Before proceeding to use TypeScript with Vue, understanding some basic concepts of TypeScript is essential. TypeScript shares a similar syntax with base JavaScript but adds extra features like static typing. This means the type of variables are defined on their initialization. This helps to prevent errors while you code. An explanation of some basic concepts is given below:

Custom Types

TypeScript enables you to define custom types that you can use in your application. This ensures your objects are strictly typed to any custom type you create. For example:

type Person = {
  name: string;
  age: number;
};

const personA: Person = {};
// Type '{}' is missing the following properties from type 'Person': name, age

Here you created a custom type Person and discovered that assigning a variable with type Person caused an error because an empty object does not have properties name and age. The correct code is shown below:

type Person = {
  name: string;
  age: number;
};

const personA: Person = {
  name: "John",
  age: 20,
};

console.log(personA.name, personA.age); // John, 20

TypeScript does not throw any errors if the variable with type Person has both properties name and age.

Interfaces

Interfaces are similar to types, but a key difference is that an interface can be used to define classes while types can not. An example using a TypeScript interface is shown below:

interface Person {
  name: string;
  age: number;
  getName(): string;
}

class Student implements Person {
  name: string;
  age: number;

  constructor(name: string, age: number) {
    this.name = name;
    this.age = age;
  }

  getName() {
    return this.name;
  }

  getAge() {
    return this.age;
  }
}
const personA: Student = new Student("Nana", 20);
console.log(personA.getName(), personA.getAge());

In this example, the interface Person defines the class Student. Here, you created an instance of the Student class and used its methods to print the name and age properties.

TypeScript Generics

Generics allow you to write reusable code that can be applied to different types that have the same shape. An example is shown below:

interface Shape {
  length: number;
  width: number;
}

class Rectangle implements Shape {
  length: number;
  width: number;
  constructor(length: number, width: number) {
    this.length = length;
    this.width = width;
  }
}

class Square implements Shape {
  length: number;
  width: number;
  constructor(length: number) {
    this.length = length;
    this.width = length;
  }
}

function getArea<T extends Shape>(shape: T): number {
  return shape.length * shape.width;
}

const rectangle: Shape = new Rectangle(10, 5);
const rectangleArea = getArea(rectangle);

const square: Shape = new Square(7);
const squareArea = getArea(square);

console.log(rectangleArea, squareArea); // 50, 49

In the above code, an interface Shape is defined. A generic function getArea was used to calculate the area of any type of Shape. Two separate classes, Rectangle and Square, were created, and they implemented the Shape interface (they are types of Shape). Therefore, the areas of Rectangle and Square instances can be calculated from a single getArea generic function.

Now that you have learned a few basic concepts of TypeScript, you will get started in applying these concepts to building a Vue application with Vuex state management.

Getting Started

Vue-CLI automatically creates a store for you (if you selected Vuex as an additional feature when adding the project). Otherwise, create a store in the src directory and add an index.ts file. Install Vuex with npm i vuex as well. Replace the content of the index.ts with the following code:

import { createStore } from "vuex";
export interface State {
  count: number;
}

export default createStore<State>({
  state: { count: 0 },
  getters: {},
  mutations: {},
  actions: {},
  modules: {},
});

The above code creates an interface called State. This defines the shape of the state object we use in the createStore function. The createStore function in Vuex represents the global state and how you can access it throughout the application. Notice the generic createStore<State> allowed you to define the shape of your state. Removing count: 0 will throw an error because the state object will not match the State interface.

To use the store through Options API, go to main.ts and add the following code:

import { createApp } from "vue";
import App from "./App.vue";
import store, { State } from "./store";
import { Store } from "vuex";

declare module "@vue/runtime-core" {
  interface ComponentCustomProperties {
    $store: Store<State>;
  }
}
createApp(App).use(store).mount("#app");

The declare module redefines the ComponentCustomProperties of the Vue runtime. This is necessary to access the $store property in Vue components.

Replace the HelloWorld.vue and App.vue components with the following code:

HelloWorld.vue

<template>
  <div class="hello">
    <p>count: {{ count }}</p>
  </div>
</template>

<script lang="ts">
import { defineComponent } from "vue";

export default defineComponent({
  computed: {
    count(): number {
      return this.$store.state.count;
    },
  },
});
</script>

App.vue

<template>
  <HelloWorld />
</template>

<script lang="ts">
import HelloWorld from "./components/HelloWorld.vue";
import { defineComponent } from "vue";

export default defineComponent({
  components: { HelloWorld },
});
</script>

Run the server with npm run serve, and the count property from the state (which is currently 0) is displayed.

Vuex Mutations

Mutations change the value of data stored in a Vuex state. Mutations are a set of functions that have access to the state data and can change it. Notice in the store/index.ts, you have a mutations object, which is currently empty.

To use mutations, adjust the store/index.ts code to the following:

import { createStore } from "vuex";

export interface State {
  count: number;
}

export default createStore<State>({
  state: { count: 0 },
  getters: {},
  mutations: {
    increment(state: State) {
      state.count++;
    },
  },
  actions: {},
  modules: {},
});

The code above adds an increment mutation and has the State interface as a parameter. Calling the mutation updates the count property of the state. To use it in the HelloWorld.vue component, replace the code with the following:

<template>
  <div class="hello">
    <p>count: {{ count }}</p>
    <button @click="increment">Increase me</button>
  </div>
</template>

<script lang="ts">
import { defineComponent } from "vue";

export default defineComponent({
  computed: {
    count(): number {
      return this.$store.state.count;
    },
  },

  methods: {
    increment() {
      this.$store.commit("increment");
    },
  },
});
</script>

The increment method of the HelloWorld.vue component commits the increment mutation of the Vuex store anytime it is called. You attached this method to the click event of the button in the template. Anytime the button is clicked, the value of the count property in the store is updated.

Vuex Actions

Vuex actions are a set of methods that enable you to update the values of a Vuex store asynchronously. Vuex mutations are synchronous by design, and it is not advisable for functions in Vuex mutations to be asynchronous. To create a Vuex action, input the following code in your store/index.ts:

import { createStore } from "vuex";

export interface State {
  count: number;
}

export default createStore<State>({
  state: { count: 0 },
  getters: {},
  mutations: {
    increment(state: State) {
      state.count++;
    },
  },
  actions: {
    incrementAsync({ commit }) {
      setTimeout(() => commit("increment"), 1000);
    },
  },
  modules: {},
});

This adds an incrementAsync function to the actions object. It uses setTimeout to call the increment action after one second. The { commit } deconstructed the store parameter supplied to Vuex actions. This allowed a shorter way to commit to the state.

To use the action, replace the HelloWorld.vue component with the following:

<template>
  <div class="hello">
    <p>count: {{ count }}</p>
    <button @click="increment">Increase me</button>
  </div>
</template>

<script lang="ts">
import { defineComponent } from "vue";

export default defineComponent({
  computed: {
    count(): number {
      return this.$store.state.count;
    },
  },

  methods: {
    increment() {
      this.$store.dispatch("incrementAsync");
    },
  },
});
</script>

You replaced the increment function to use the Vuex action instead of committing to the state directly. You will notice that clicking the button updates the count in the state after 1 second.

Vuex Getters

Vuex getters allow us to compute a derived state from the original state. They are read-only helper functions that allow us to derive more information about our original state. To use Vuex getters, add the following code to store/index.ts:

import { GetterTree, createStore } from "vuex";

export interface Getters extends GetterTree<State, State> {
  doubleCount(state: State): number;
  isEven(state: State): boolean;
}

const getters: Getters = {};

// Type '{}' is missing the following properties from type 'Getters': doubleCount, isEven

The code above defines an interface for your getters. It takes advantage of TypeScript’s strong typing to make sure your getters are defined correctly. There will be an error because the getters object has not been completely implemented to match the getters interface. Complete the code with the following:

import { GetterTree, createStore } from "vuex";

export interface State {
  count: number;
}

export interface Getters extends GetterTree<State, State> {
  doubleCount(state: State): number;
  isEven(state: State): boolean;
}

const getters: Getters = {
  doubleCount(state: State) {
    return state.count * 2;
  },
  isEven(state: State) {
    return state.count % 2 == 0;
  },
};

export default createStore({
  state: { count: 0 },
  getters,
  mutations: {
    increment(state: State) {
      state.count++;
    },
  },
  actions: {
    incrementAsync({ commit }) {
      setTimeout(() => commit("increment"), 1000);
    },
  },
  modules: {},
});

The code implements the getters object and sets it as the Vuex getters in the createStore getters. Proceed to use it in the HelloWorld.vue component with the code shown below:

<template>
  <div class="hello">
    <p>count: {{ count }}</p>
    <p>is even: {{ isEven }}</p>
    <p>double of count: {{ double }}</p>
    <button @click="increment">Increase me</button>
  </div>
</template>

<script lang="ts">
import { defineComponent } from "vue";

export default defineComponent({
  computed: {
    count(): number {
      return this.$store.state.count;
    },
    isEven(): boolean {
      return this.$store.getters.isEven;
    },
    double(): number {
      return this.$store.getters.doubleCount;
    },
  },

  methods: {
    increment() {
      this.$store.commit("increment");
    },
  },
});
</script>

This adds extra computed methods that return the getter functions. isEven determines if the count state is an even number, and the doubleCount computes double the value of count.

Vuex Modules

Modules allow the separation of parts of the state and allow different logic to be segmented. It also prevents the state object from becoming large and hard to maintain. To use Vuex modules, proceed with the following example:

Consider a scenario in which you want to build a minimal social media application. To manage the state of the users, posts, and comments, you could have a Vuex configuration like the following:

import { createStore } from "vuex";

interface User {
  name: string;
}

interface Post {
  id: string;
  title: string;
  content: string;
}

interface Comment {
  postId: string;
  comment: string;
}
export interface State {
  user: User | null;
  posts: Post[];
  comments: Comment[];
}

export default createStore<State>({
  state: { user: null, posts: [], comments: [] },
  getters: {},
  mutations: {
    setUser(state: State, user: User) {
      state.user = user;
    },
    addPost(state: State, post: Post) {
      state.posts.push(post);
    },
    addComment(state: State, comment: Comment) {
      state.comments.push(comment);
    },
  },
  actions: {
    login({ commit }, user) {
      // Simulate user login
      commit("setUser", user);
    },
    createPost({ commit }, post) {
      // Simulate creating a post
      commit("addPost", post);
    },
    createComment({ commit }, comment) {
      // Simulate creating a comment
      commit("addComment", comment);
    },
  },
});

The state, actions, and mutations already look bulky even without real implementations. Vuex modules help solve this issue. A refactored code using Vuex modules is shown below:

import { Module, createStore } from "vuex";

interface User {
  name: string;
}

interface Post {
  id: string;
  title: string;
  content: string;
}

interface Comment {
  postId: string;
  comment: string;
}

export interface State {
  user: User | null;
  posts: Post[];
  comments: Comment[];
}

export interface UserModuleState {
  user: User | null;
}

export interface PostModuleState {
  posts: Post[];
}

export interface CommentModuleState {
  comments: Comment[];
}

const userModule: Module<UserModuleState, State> = {
  state: () => ({ user: null }),
  mutations: {
    setUser(state: UserModuleState, user: User) {
      state.user = user;
    },
  },
  actions: {
    login({ commit }, user) {
      // Simulate user login
      commit("setUser", user);
    },
  },
};

const postModule: Module<PostModuleState, State> = {
  state: () => ({ posts: [] }),
  mutations: {
    addPost(state: PostModuleState, post: Post) {
      state.posts.push(post);
    },
  },
  actions: {
    createPost({ commit }, post) {
      // Simulate creating a post
      commit("addPost", post);
    },
  },
};

const commentModule: Module<CommentModuleState, State> = {
  state: () => ({ comments: [] }),
  mutations: {
    addComment(state: CommentModuleState, comment: Comment) {
      state.comments.push(comment);
    },
  },
  actions: {
    createComment({ commit }, comment) {
      // Simulate creating a comment
      commit("addComment", comment);
    },
  },
};

export default createStore<State>({
  modules: {
    userModule,
    postModule,
    commentModule,
  },
});

You would observe that the logic for user, post, and comments have been separated into different Modules. Each with its state, actions, and mutations.

It is recommended to store each module in its own separate file to promote better separation of concerns and smaller, closely related compact code for each module.

Vuex modules can also contain inner modules, and a lot can be explored with this powerful feature in the Official Vuex Documentation

Common patterns used in Vuex

Explore some best practices and practical strategies for enhancing your TypeScript code. These tips will guide you into more maintainable TypeScript development.

Helper functions

The main store does not have to contain the functions for your actions and mutations. Helper functions for actions, mutation, or getters can be separated into different modules and imported from there.

Vuex Mappers

Instead of adding methods in a component for each action or mutation, Vuex provides helper functions that map actions, mutations, or getters directly to a component’s methods or computed. In the previous examples, we called the dispatch or commit methods of the store in our component’s methods or computed object.

import { createStore } from "vuex";

export interface State {
  count: 0;
}

export default createStore<State>({
  state: { count: 0 },
  mutations: {
    increment(state: State) {
      state.count++;
    },
  },
});

This code is the previous example of setting up a Vuex store, but you will use Vuex helpers called mapMutations and mapState in the HelloWorld.vue component, as shown below:

<template>
  <div class="hello">
    <p>count: {{ count }}</p>
    <button @click="increment">Increase me</button>
  </div>
</template>

<script lang="ts">
import { defineComponent } from "vue";
import { mapMutations, mapState } from "vuex";

export default defineComponent({
  computed: {
    ...mapState(["count"]),
  },
  methods: {
    ...mapMutations(["increment"]),
  },
});
</script>

Instead of creating a computed property to access the state with this.$store.state, you used a Vuex helper called mapState to map it directly in the computed object. You specified the name of the state property you wanted to access (count) as a string in a list, which was added to the mapState function as an argument.

Similarly, you did the same for the increment mutation function with Vuex mapMutations.

Potential Pitfalls and solutions

TypeScript ensures better code practice. You might run into issues like TypeErrors where a value you want to use does not match a type from a function you need. A quick solution would be to specify your type as any, which will allow any type to be used. Be careful not to use it too much; rather, ensure clear interface definitions.

Conclusion

In this article, you explored various ways of integrating TypeScript with Vuex and observed the benefits of TypeScript’s strongly typed system and how it helps prevent errors before they occur. You also got familiar with what a Vuex store is, along with states, mutations, actions, and getters.

Finally, you learned how to split your state management system if the need arises with Vuex modules.

This article serves as a platform for you to build cleaner and more robust applications with Vuex. Using TypeScript serves as a powerful tool to eradicate errors before they become a major problem.

You are encouraged to explore more about Vuex in their Official Documentation alongside TypeScript to leverage its benefit by building more projects as you go along.

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers. Check our GitHub repo and join the thousands of developers in our community.

OpenReplay