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.
Discover how at OpenReplay.com.
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.