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 🙂
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 objectconst isLoading = ref(true)
computed
which creates a reactive object that is synchronized with other reactive properties. Just like computed
in the Options APIconst product = ref(product)
const productName = computed(() => product.name)
Now let’s see how much I can do just with that 🙂
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:
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).
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
}
}
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
}
}
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 cart
s 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:
useCart
method has external dependency to store.js
which makes it less self-contained and reusable.cart
property in the state
object will exist independently of useCart
function so it’s easy to overlook it when removing/changing this functionSo 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.
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.
Our goal is to be the number one source of Vue.js knowledge for all skill levels. We offer the knowledge of our industry leaders through awesome video courses for a ridiculously low price.
More than 200.000 users have already joined us. You are welcome too!
© All rights reserved. Made with ❤️ by BitterBrains, Inc.