State management in Vue.js SPAs

Sebastian D.
Digital Frontiers — Das Blog

--

Properly handling client-side state is one of the more challenging aspects of developing single page applications. The Vue.js ecosystem offers different approaches to handling state in your application. In this post I’ll give a brief overview and comparison of three different methods and advice on how to employ them. Sufficiently large applications have a tendency to accrue large amounts of state that dictate the behaviour and appearance of the application. In SPAs this state needs to be managed client-side such that the front-end behaves and renders correctly and the state is properly synchronised to the server.

The three techniques we’re discussing are all part of the Vue ecosystem:

  • Prop drilling + Event Propagation
  • Provide / Inject
  • State management libraries (Pinia)

For the purposes of this discussion we have two types of state. Let’s call them local and global state. Local state is specific to each component and isn’t shared across multiple components. It can be modified without affecting siblings or parents. Therefore, it doesn’t need to be managed centrally.

Global state is shared by multiple components and modifying it has to cause updates to occur across all components that depend on it. For instance, a user’s profile picture has to be rendered in multiple components, with all instances using the same URL. When the user changes the picture, all components need to receive this updated URL.

Therefore, when working with global state we need to ensure consistency by enforcing a single source of truth; one central storage location that provides an interface to access and modify the state. It also needs to integrate with Vue’s Reactivity system, so updates actually propagate to all components.

Examples

My colleague Kersten Kriegbaum and I wrote a small To-Do app for the purpose of demonstrating the different state-management techniques in Vue.js. I’ll use it as the source of the examples in this article. You can find the source code here.

This is what the example app looks like. As you can tell I’m no UI Designer. What’s important is having a visual representation of where each part of the application state is being shown and where the buttons reside.

Here’s how the app’s component tree is structured. The components have the following functionalities.

Navigation:

  • show name of list and number of unfinished items
  • select the displayed list (activeList)
  • add a new list

List:

  • render entries using Entry component
  • add new entry via button

Entry:

  • change its own title text and completed state

As you can see, the “deepest” component Entry needs to mutate its own state by marking itself as completed and even be able to delete itself from a list. Navigation needs to access the completed state and create new lists. This is on purpose, to demonstrate our three different techniques based on state that resides in the root component App. Let’s look at how this would work in Vue.js by default.

Prop Drilling + Component Events

Prop drilling describes the passing down (“drilling”) of properties from parent components to child components through multiple levels. The top-most component is responsible for providing the state required by all children in its sub-tree. The children then pass along all or parts of the state according to each subsequent child’s dependencies. This isn’t an issue when you’re passing down an object and most components need parts of it to render the data. It is the default way to pass data in Vue.

When it comes to modifying the state passed to a component, there is nothing stopping you from simply modifying the objects directly. As JavaScript is pass-by-reference, Vue will pass your objects as props (read: references to the original object) and any child could modify them. This is considered an anti-pattern in Vue and should be avoided, as it can lead to unexpected side-effects and makes your application harder to debug, because it’s not exactly clear what modified your object.

Similar to the concept of pure functions in Functional Programming, we’re essentially trying to “return” new objects as opposed to modifying the parameters. That’s why we should be using Vue’s Component Events.

Component Events are the default way of changing parent state, or rather, notifying the parent component of state change. This can be done using $emit or the emit function returned by defineEmits(). As with prop-drilling passing down state, when we modify state using component events, each parent component in the chain needs to handle the event and re-emit if necessary, until the event reaches the component responsible for managing the state of its sub-tree. In our case this is the App component. When the App component’s state is updated due to an event, Vue’s Reactivity updates all the children as needed.

Example: Completing a To-Do

You can find the entire example code here

Let’s have a look how the state change works when an entry is marked as completed. Some lines are omitted for the sake of brevity.

First, we create a local ref() of both the title and the completed properties of the entry component prop, so that we can work on a local copy without modifying the original object. We use this new completed variable in the checkbox input for the v-model attribute. Our local variable will be updated when the underlying HTML element is updated. When then watch() the change and emit an entryUpdated Component Event with a new object containing the updated value.

Crucially, the List component now has to re-emit the entryUpdated Component Event to pass it up the tree to App, which is actually handling the change.

When the event reaches App we handle it in the above method. Because activeList is an element of lists and both are reactive, the number of uncompleted items in Navigation is updated accordingly.

Testability

Unit testing components that utilise props and component events is quite straightforward and Vue’s own test utils provide an out of the box way to do it. Here’s an example using the Entry component.

We use the test utils method mount and pass our component under test and its required props. Then we find the checkbox inside that determines if the entry is completed or not and set it to true. The wrapper provides a method emitted() which returns an object containing all events emitted by the component in this test. We simply check for the event we’d expect and assert the completed property to be true.

When should you use prop-drilling and component events?

The major issue with prop-drilling and component events is verbosity. When global state is required by deeply nested components or events have to be re-emitted several times before reaching the correct parent component it can get quite verbose in larger applications.

This being said, it’s the default for a reason. I recommend you start out with prop-drilling and component events, then observe how your code changes over time. If it becomes unreasonably verbose or hard to reason about for you and your team, you can consider refactoring the bits that handle global state and using one of the other techniques like Provide/Inject or the Pinia library. It’s not an either or. You can use regular prop drilling where it’s readable and only use Pinia, for instance, where the different event handlers clutter your code too much, or the nesting is unreasonably deep.

Provide/Inject

Provide/Inject is a Vue built-in mechanism to avoid the drawbacks of prop drilling and component events in cases of very deep nesting. A parent component can serve as a dependency provider and any of its children can inject those provided dependencies. This way data can be provided to deeply nested child components without the need to pass it through each and every component in between. It also allows injecting methods for modifying the state without losing reactivity.

Example: Completing a To-Do

You can find the whole provide/inject code here

Let’s look at the same use-case as before, marking an item as completed.

In App we have the same method for updating an entry as before, only this time it’s not an event handler. We pass it to the provide method alongside a string key that we can then use to inject the method somewhere else.

We can now inject our update method using the key we defined previously.¹ When the callback inside watch is executed, we simply call the updateEntry method and provide it a new entry object with the updated completed property. Inside App the state changes and since the state is reactive, the number of uncompleted items in Navigation decreases.

Because no component events are emitted, the List and App components don’t need to handle and/or re-emit events. We’ve effectively saved a few lines of code at the cost of explicitness. By that I mean, the fact that the Entry component depends on this updateEntry method is an implementation detail and no longer defined in its public interface.

¹Note: In reality we’re using Injection Keys to add typing to the injected functions. In larger apps it would also help keep your keys organised.

Testability

Testing gets a bit more complex when using Provide/Inject. Set-up before testing requires the creation of wrappers and mocks in order for the component to work properly inside the test. Depending on how strongly your component relies on injection this could get quite complex. This is another reason to use injection only when it’s necessary.

When should you use Provide/Inject?

There is no hard and fast rule like “always use Provide/Inject after X levels of nesting”. To a large extent this is a matter of personal preference. While prop drilling and event handling can be quite verbose, I think overly relying on Provide/Inject can also make it hard to reason about your code, because many dependencies are “hidden” inside the component.

Essentially, my advice is to strategically and sparingly employ Provide/Inject. Imagine all of your data being provided “somewhere” and injected somewhere else. You might be able to navigate that code with good IDE support², but it most definitely makes understanding your data flow difficult, skimming code harder, and testing more complex than it needs to be. You have to look into each component to know which provides to mock for the specific test instead of simply passing an object literal into the component aided by code completion. There’s a healthy medium between prop-drilling and event handling spaghetti and provide/inject magic.

²At the time of writing, using “go to definition” on injected methods didn’t work. A workaround is listing the injection key’s references instead, or doing a full text search for the key.

Store — Pinia

Pinia is a state-management library that is part of the Vue.js ecosystem, but it doesn’t ship with Vue by default. It allows you to manage data in your app by organizing it in a central place called a store. This way, all components can access and update the data using a consistent API without data handling issues that typically arise in large applications.

Pinia works with Vue’s reactivity system, so updates to the state inside the store propagate correctly to all components that also use the store. Inside a store methods for accessing and mutating the state within are exposed. When a back-end is used, this is also the right place to sync the data with the back-end. All components can access the state within the store and modify it by simply importing that specific store. Multiple stores can be created to separate concerns or structure your code. Due to Pinia being part of the Vue ecosystem it is also supported by Vue Dev Tools. This allows you to inspect and observe the data while developing.

Example: Completing a To-Do

You can find the full Pinia example code here

Again, let’s look at how to mark an entry as completed, this time using a store.

First we define a store in a separate file store.ts using the aptly named defineStore. Crucially, we have to give it some state to manage in state. We give it all lists and the active list, which is the one being displayed. In order to modify state, Pinia allows us to define actions, an object containing methods that mutate state. The updateEntry method is essentially the same as before.

In Entry we can now simply call useListStore() to get a reference to the store. Then we can use the updateEntry method it provides and use it the same way inside watch as we did for the other examples.

Testability

As with Provide/Inject testing requires some additional work. Since stores get complex, you should consider unit testing the store itself, especially if you’re doing requests to the back-end in there. Unit testing a simple store is quite straight-forward. However, testing a store with plugins requires you to test it within a Vue App. In order to unit test components, a testing instance of pinia needs to be created and depending on your store you might need to mock a lot of interactions with the store. It’s another bit of complexity to keep in mind.

When should I use Pinia?

When your application has the need to manage larger amounts of global state, I’d recommend Pinia over Provide/Inject. This is because of two main advantages. Firstly, the typescript support is much better since Pinia is fully typed. It doesn’t require the use of injection keys like provide/inject does. You get the same compiler support as you would for any typed object in your code. This makes writing and refactoring easier, and gives you compiler errors when something’s gone wrong. Secondly, your global state isn’t tied to a parent component that needs to manage it, while still being centrally located and easily accessible.

Conclusion

While prop-drilling and component events are a sane default for any new or small or medium-sized Vue.js project, it can make your codebase verbose and hard to read at scale, if you have a decent amount of global state to manage. Determining if and when you need to adopt an additional technique like provide/inject or Pinia is up to you.

Pinia is certainly designed to handle the needs of larger scale applications with its modular design that allows separation of concerns and its proper typescript support. If your application has a lot of global state and it’s only going to grow, you should probably use Pinia. However, if you simply want to clean up a few spots in your code that do a lot of event re-emitting and prop-drilling, then Provide/Inject is probably enough.

I hope, this article gave you enough information to decide which state-management technique you want to employ.

--

--