State Management with Composition API

Written by Filip Rakowski

Some time ago I wrote an article about state management patterns in Vue. You may or may not know but I’ve been working with Composition API intensively for the past few months while building a new version of Vue Storefront and I thought it might be a good idea to share some of my learnings. The topic I found really interesting is how using Composition API has changed the way I’m managing my application state - both local and global. If you’re curious how different it is from the current mainstream approaches make yourself a coffee and feed your curiosity because today I will share with you how to use Composition API as a replacement for known state management patterns and libraries 🙂

Advanced state management patterns? Vuex? Forget about it!

One of the first things I noticed from using Composition API (and other Vue 3 APIs) was that it simplifies a lot of things.

When they introduced Vue.observable in Vue 2.6 this new API made it so much easier to have a shared application state. Before 2.6 no one ever imagined a real-life Vue application without state management library like Vuex. It turned out that a simple function that returns reactive objects can be a great alternative to a complex tool that we considered vital for a very long time.

The Composition API goes one step further and offers a new way of thinking about the global and local state. Because it doesn’t require a hosting component to add more advanced state management logic like watchers or computed properties it allows us to completely tie the state with a specific piece of business logic. Because of that we can literally build our whole application from fully independent, pluggable micro-apps with their own private state.

To show you how simple the state management with Composition API is I will be using only 2 hooks for all examples in this article:

  • ref which creates a reactive object from a primitive or object
const isLoading = ref(true)
  • computed which creates a reactive object that is synchronized with other reactive properties. Just like computed in the Options API
const product = ref(product)
const productName = computed(() => product.name)

Now let’s see how much I can do just with that 🙂

Build your own Vuex

Before we unleash the full potential of ref and computed lets start with something “simple” and see how good these 2 properties can replicate Vuex functionality.

A simple Vuex store contains 4 pieces:

  • state
  • mutations
  • actions
  • getters

The state is the most straightforward part to replicate. We just need to create a reactive property with ref

const state = ref({ post: {} })

Now let’s create a mutation so we can have control over the state changes. Instead of using a string like we do in Vuex I will write a function. That way we can benefit from autocompletion and tree shaking (in opposite to traditional Vuex mutation)

function setPost(post) { 
  state.value.post = post
}

We can create action in the exactly same way - just remember that they can be asynchronous:

async function loadPost(id) {
  const post = await fetchPost(id)
  setPost(post)
}

For a getter we need to track changes of certain subproperties of a state object. computed function will be perfect for that!

    const getPost = computed(() => state.value.post)

Instead of making the state object public we will export a getter to access it (getPost) and action to fetch new posts (loadPost ), which uses the (setPost) mutation to update the state. This way we are not allowing direct mutation of the state object and can control how and when it’s changed.

Voila! We made our own Vuex-like state management with just two functions from Composition API! . Our whole state management logic will look like this:

const state = ref({ post: {} })

function setPost(post) { 
  state.value.post = post
}

async function loadPost(id) {
  const post = await fetchPost(id)
  setPost(post)
}

const getPost = computed(() => state.value.post)

export {
  loadPost
  getPost
} 

You must admit that it’s much easier to understand than Vuex, isn’t it? These few lines of code will be enough to manage the state in most of the simple Vue applications. Whats great about this solution is its flexibility. As our application will grow over time and requirements will change you can extend above code with additional features.

For example you can save state history with watch function (which works exactly like watch property from Options API):

const history = []
history.push(state) // push initial state

watch(state, (newState, oldState) => {
  history.push(newState)
})

TIP: We can achieve the same result without doing initial push using watchEffect function that works like a watch from Options API with immediate flag set to true.

In Vue Storefront we used the same solution (but with reactive instead of ref) to manage our UI state:

import { reactive, computed } from '@vue/composition-api';

const state = reactive({
  isCartSidebarOpen: false,
  isLoginModalOpen: false
});

const isCartSidebarOpen = computed(() => state.isCartSidebarOpen);
const toggleCartSidebar = () => {
  state.isCartSidebarOpen = !state.isCartSidebarOpen;
};

const isLoginModalOpen = computed(() => state.isLoginModalOpen);
const toggleLoginModal = () => {
  state.isLoginModalOpen = !state.isLoginModalOpen;
};

const uiState = {
  isCartSidebarOpen,
  isLoginModalOpen,
  toggleCartSidebar,
  toggleLoginModal
};

export default uiState;

Okay. So we know that we can do the same things Vuex does with Composition API but in fact it also allows things that weren’t possible before (or at least they required much more work).

Keeping your state local

One of the best things about Composition API is the fact that it allows to write Vue logic outside of Vue components. Because of that you can slice your code into reusable, independent and self-contained pieces and hide their business logic behind a nice API.

For example when we have a code that is responsible for fetching products and managing its loading state instead of repeating it in every component we can put it into a function that is returning reactive properties:

export default function useProduct() {
  const loading = ref(false)
  const products = ref([])

  async function search (params) {
    loading.value = true
    products.value = await fetchProduct(params)
    loading.value = false
  }
  return {
    loading: computed(() => loading.value)
    products: computed(() => products.value)
    search
  }
}
Learn Vue.js With Vue School

Did you noticed that instead of directly returning products and loading objects we are returning computed properties? That way we can make sure that no one will mutate these objects outside of useProduct.

TIP: You can achieve the same effect with readonly property from Composition API

Now when we want to fetch products in a component we can use our useProduct function like this:

<template>
  <div>
    <span v-if="loading">Loading product</span>
    <span v-else>Loaded {{ product.name }}</span>
  </div>
</template>

<script>
import { useProduct } from './useProduct'

export default {
  setup (props, context) {
    const id = context.root.$route.params.id
    const { products, loading, search } = useProduct()

    search({ id })

    return {
      product: computed(() => products.value[0]),
      loading
    }
  }
}
</script>

Dividing your application into independent parts that communicate with each other only with strictly defined APIs that (ideally) are not influenced by their implementation details is one of the most effective ways to keep your code organized and maintainable. It turns out that the same thing we can do with business logic we can do with our state.

Let’s go back to our useProduct function. What is its state? Obviously products and loading properties but have you noticed something really cool about them?

The useProduct state is tightly coupled with the business logic that is managing it, and the state is local only to this function. It’s not in the app, even as empty object until it’s needed. It’s also only mutable only from the “inside”.

Everything that is related to this particular piece of business logic is exposed to the application only via useProduct function. Keeping your functionalities as independent composition functions (or composables, which I like to call them) makes them extremely easy to change, or even completely remove without a risk of breaking other parts of your app or leaving some unused pieces of code like empty products object in a store even if we don’t need it anymore. Things like this like to accumulate over time and make our codebase messy.

const globalState = ref({
  products: {}, // easy to forget if we remove useProduct
  categories: {},
  user: {},
  // ... other state properties
})

The cool thing with Composition functions when it comes to state management, is that it allows us to easily choose if the state should be global or local. That freedom reduces some of the complexity and dependency to a root store object compared to solutions like Vuex.

This approach also eliminates few issues we could normally face with Vuex modules. If we would have a centralized state for posts it would probably be an object or array. Whenever we perform search method we will push to the state.posts object or add a subproperty with post id. This could make accessing certain posts harder as we always need to know their id (which could be problematic when we have a possibility to fetch them based on other properties like sku , name or slug). We also don’t know when a certain property is no longer needed and can be removed to free up some memory.

We don’t have any of these problems in our useProduct function. Every time it’s invoked it creates it’ own, local state with products and loading properties so it can be easily used multiple times. For example we could use it twice - first to fetch a certain product and then it’s related products from the same category:

async setup (props, context) {
  const id = context.root.$route.params.id
  const { products, search } = useProduct()
  const { products: relatedProducts, search: relatedSearch } = useProduct()

  await search({ id }) // fetch main product
  await relatedSearch({ categoryId: products.value[0].catId }) // fetch some otheproducts from this category

  return {
    product: computed(() => products.value[0]),
    relatedProducts
  }
}

Sharing state between composition functions

The ability to create state properties on demand is without a doubt really helpful but there are some occasions when we would like to have only a single instance of certain state property used across many Composition functions.

In Vue Storefront we have a useCart property that is (surprisingly) responsible for interactions with a shopping cart. You would expect from such functionality to always refer to the same cart object no matter where it’s called.

If we write following code in SomeComponent.vue:

<template>
  <div>
    Items in cart<b>{{ cart.items.length }}</b>
  </div>
</template>

<script>
export default {
  setup () {
    const { cart } = useCart()
    return { cart }
  }
</script>

Then add product to cart in OtherComponent.vue like this:

<template>
  <div>
    <button @click="addToCart(product)">Add to cart</button>
  </div>
</template>

<script>
export default {
  setup () {
    const { products, search } = useProduct()
    const { cart, addToCart } = useCart()

    search({ id: '123' })

    return { 
      cart, 
      addToCart.
      product: computed(() products.value[0])
     }
  }
</script>

We want both carts to refer to the same object so when we click “Add to cart” button in OtherComponent the result is immediately visible in SomeComponent.

We could achieve that behavior in two ways - one would be using a cart object from external store similar to the one I showed at the beginning of the article.

// store.js
const state = ref({ cart: {} })
const setCart = (cart) => { state.value.cart = cart }

export { setCart }
// useCart.js
import { setCart, cart } from './store.js'

export function useCart () {
  // use setCart and cart here
}

Even though we solved the problem, from the architectural perspective there are two downsides of this approach:

  1. Our useCart method has external dependency to store.js which makes it less self-contained and reusable.
  2. The cart property in the state object will exist independently of useCart function so it’s easy to overlook it when removing/changing this function

So how we can improve our solution and solve these problems?

All we need to do, is slightly improve the pattern used in useProduct :

export default function useProduct() {
  const loading = ref(false)
  const products = ref([])

  async function search (params) {
    loading.value = true
    products.value = await fetchProduct(params)
    loading.value = false
  }
  return {
    loading: computed(() => loading.value)
    products: computed(() => products.value)
    search
  }
}

The only thing that is wrong with this solution to fulfill our new requirements is the fact that we’re creating new state on each function call. To keep the same state between every useCart instance we just need to lift this state up outside the function so it’s created only once:

const cart = ref({})

function useCart () {
  // super complicated cart logic
  return {
    cart: computed(() => cart.value)
  }
}

And voila - this is how you create a shared state with Composition API! This solution is extremely simple just like all the previous examples. I hope at this point you love Composition API as much as I do.

Summary

Composition API is not only a new, revolutionary way of sharing reusable code between components but also a great alternative to popular state management libraries like Vuex. This new API can not only simplify your code but also improving your project architecture by contributing into its modularity. It would be interesting to see how Composition API along with tools like Pinia will influence the way we’re managing our state in the future.

Learn Vue.js With Vue School

Leave a Reply

Your email address will not be published. Required fields are marked *

Up Next:

9 reasons to use Gridsome for your next Vue application

9 reasons to use Gridsome for your next Vue application