Why Local Component State Falls Short
In small Vue projects, the simplest way to manage state is to keep it directly on a component. This works well when you only have one view that needs to display, sort, or filter a batch of data:
setup() {
let books: Work[] = reactive([]);
onMounted(async () => {
// Call the API
const response = await bookService.getScienceBooks();
if (response.status === 200) {
books.splice(0, books.length, ...response.data.works);
}
});
return {
books
};
},
But this approach has limits. The component fetches data on every request, which can be wasteful for data that should persist as users move through the application. Network calls are both slow and unreliable, so re-fetching data on each navigation is hardly the best practice.
There's also the issue of coordinating state between components. Vue's event and prop system handles communication between a parent and its direct children effectively, but common patterns like error notifications and loading indicators get awkward when views are independent of one another. Consider a layout with a top-level component responsible for showing error messages and loading animations:
// App.vue
<template>
<div class="container mx-auto bg-gray-100 p-1">
<router-link to="/"><h1>Bookcase</h1></router-link>
<div class="alert" v-if="error">{{ error }}</div>
<div class="alert bg-gray-200 text-gray-900" v-if="isBusy">
Loading...
</div>
<router-view :key="$route.fullPath"></router-view>
</div>
</template>
While a publish/subscribe pattern might initially come to mind, shared state often turns out to be a cleaner way to handle it.
Approaches to Sharing State in Vue 3
When you need shared state, you generally choose between a few common strategies. Each one comes with a set of trade-offs between ease of use, reactivity, and code structure. Let's break them down. Code for this section is available in the main branch of the example project on GitHub.
Factory Functions for Reusable State
One method is to create a factory function that returns a new state object and a set of action functions. This keeps a clean separation between the state and the logic that mutates it.
Shared Objects for Global State
A simpler alternative is to export a plain shared object using the module system. This works fine if your entire application requires a global, single source of truth that isn't utilized by multiple independent asynchronous processes. However, the lack of encapsulation makes the state harder to track and mutate than it should be.
The Case for Vuex in Larger Applications
Vuex offers a more structured approach with its own central store, actions, mutations, and getters. This design makes state transitions traceable and distinct from direct manipulation of shared objects. For applications with complex requirements involving frequently modified data, Vuex enforces a disciplined architecture that scales much more predictably than manual patterns.
What Vuex 5 May Change
Looking forward, Vuex 5 appears set to simplify things by reducing boilerplate while keeping state shared and functional. It could consolidate APIs to allow more streamlined composition of stores - a significant evolution from the more strict, event-driven process Vuex currently requires. The exact shape of the API will still be determined, but a lighter interface seems clear.
Four Ways To Handle Shared State In Vue 3
Once you move past components that manage their own local data, you need a strategy for state that multiple views can read and write. Vue 3 gives you several viable options. Which one makes sense depends on project size, TypeScript needs, and how strictly you want to control state changes.
The main approaches boil down to factories, shared singletons, and two generations of Vuex. Each differs in complexity, safety, and developer experience.
Factory Functions: Flexible But Easy To Misuse
A factory is simply a function that creates and returns state along with its related logic. You can pick and choose which pieces a component needs:
export default function () {
const books: Work[] = reactive([]);
async function loadBooks(val: string) {
const response = await bookService.getBooks(val, currentPage.value);
if (response.status === 200) {
books.splice(0, books.length, ...response.data.works);
}
}
return {
loadBooks,
books
};
}
You can destructure only the parts you need:
// In Home.vue
const { books, loadBooks } = BookFactory();
If you add an isBusy flag to represent an in-flight network request, you might expose it in one view:
export default function () {
const books: Work[] = reactive([]);
const isBusy = ref(false);
async function loadBooks(val: string) {
isBusy.value = true;
const response = await bookService.getBooks(val, currentPage.value);
if (response.status === 200) {
books.splice(0, books.length, ...response.data.works);
}
}
return {
loadBooks,
books,
isBusy
};
}
Or in another view you might only request the flag itself, without caring about the rest of the factory's implementation:
// App.vue
export default defineComponent({
setup() {
const { isBusy } = BookFactory();
return {
isBusy
}
},
})
The catch: calling the factory always produces a fresh instance. For truly shared state, move the object creation outside the factory function:
const books: Work[] = reactive([]);
const isBusy = ref(false);
async function loadBooks(val: string) {
isBusy.value = true;
const response = await bookService.getBooks(val, currentPage.value);
if (response.status === 200) {
books.splice(0, books.length, ...response.data.works);
}
}
export default function () {
return {
loadBooks,
books,
isBusy
};
}
That turns the factory into a de facto singleton — which can be confusing, because the function name suggests it creates new objects. Also, to keep the singleton behavior meaningful, your reactive objects are declared with const. Reassigning them will cause an error:
// In Home.vue
const { books, loadBooks } = BookFactory();
books = []; // Error, books is defined as const
Instead, update the objects' content, like using books.splice() instead of reassigning books. A more transparent alternative is the shared instance pattern.
Shared Instances: A Straightforward Singleton
If you know the state is a singleton, be explicit about it by exporting a reactive object instance directly:
export default reactive({
books: new Array<Work>(),
isBusy: false,
async loadBooks() {
this.isBusy = true;
const response = await bookService.getBooks(this.currentTopic, this.currentPage);
if (response.status === 200) {
this.books.splice(0, this.books.length, ...response.data.works);
}
this.isBusy = false;
}
});
Components then simply import the object:
// Home.vue
import state from "@/state";
export default defineComponent({
setup() {
// ...
onMounted(async () => {
if (state.books.length === 0) state.loadBooks();
});
return {
state,
bookTopics,
};
},
});
That makes binding to its properties trivial:
<!-- Home.vue -->
<div class="grid grid-cols-4">
<div
v-for="book in state.books"
:key="book.key"
class="border bg-white border-grey-500 m-1 p-1"
>
<router-link :to="{ name: 'book', params: { id: book.key } }">
<BookInfo :book="book" />
</router-link>
</div>
The instance is identical across views, whether the consumers sit in a parent-child relationship or live on different routes:
// App.vue
import state from "@/state";
export default defineComponent({
setup() {
return {
state
};
},
})
<!-- App.vue -->
<div class="container mx-auto bg-gray-100 p-1">
<router-link to="/"><h1>Bookcase</h1></router-link>
<div class="alert bg-gray-200 text-gray-900"
v-if="state.isBusy">Loading...</div>
<router-view :key="$route.fullPath"></router-view>
</div>
Both patterns share a downside: state can be mutated anywhere, leading to accidental side effects. That gets harder to manage in larger codebases.
Vuex 4: Predictability At The Cost Of Boilerplate
Vuex enforces a strict, one-way data flow: views dispatch actions, actions commit mutations, and mutations are the only code allowed to change the state. That tight constraint prevents stray assignments from causing cascading UI bugs.
This flow comes with tradeoffs. You get predictable state transitions, and the Vue DevTools even support time-travel debugging to inspect state history. But you also get a steeper learning curve and more code to write for every change.
Installation can be done directly:
> npm i vuex
Or through the Vue CLI, which scaffolds a starting store for you:
> vue add vuex
At the heart of Vuex is a store created with createStore:
import { createStore } from 'vuex'
export default createStore({
state: {},
mutations: {},
actions: {},
getters: {}
});
The state object is plain data — no ref or reactive wrappers needed since the store itself is a shared singleton:
import { createStore } from 'vuex'
export default createStore({
state: {
books: [],
isBusy: false
},
mutations: {},
actions: {}
});
Actions receive the store instance so they can access state:
actions: {
async loadBooks(store) {
const response = await bookService.getBooks(store.state.currentTopic,
if (response.status === 200) {
// ...
}
}
},
Typically you only destructure the pieces you need from the store:
actions: {
async loadBooks({ state }) {
const response = await bookService.getBooks(state.currentTopic,
if (response.status === 200) {
// ...
}
}
},
Mutations, however, must never be called directly. They always take the state object as their first argument:
mutations: {
setBusy: (state) => state.isBusy = true,
clearBusy: (state) => state.isBusy = false,
setBooks(state, books) {
state.books.splice(0, state.books.length, ...books);
}
},
You trigger a mutation through the store's commit method. In a component, you might destructure it off the store object:
actions: {
async loadBooks({ state, commit }) {
commit("setBusy");
const response = await bookService.getBooks(state.currentTopic,
if (response.status === 200) {
commit("setBooks", response.data);
}
commit("clearBusy");
}
},
Even with a payload like inserting an individual book into a list, you must wrap it in a single argument when committing:
commit("insertBook", { book, place: 4 }); // object, tuple, etc.
The mutation then destructures that payload:
mutations: {
insertBook(state, { book, place }) => // ...
}
There are two ways to make the store available to components. One is registering it with the root application:
// main.ts
import store from './store'
createApp(App)
.use(store)
.use(router)
.mount('#app')
After that, anywhere in the app you can call useStore:
import { useStore } from "vuex";
export default defineComponent({
components: {
BookInfo,
},
setup() {
const store = useStore();
const books = computed(() => store.state.books);
// ...
Alternatively, you can skip the plumbing and import the store object directly:
import store from "@/store";
export default defineComponent({
components: {
BookInfo,
},
setup() {
const books = computed(() => store.state.books);
// ...
To read state reactively in components, wrap it in a computed:
export default defineComponent({
setup() {
const books = computed(() => store.state.books);
return {
books
};
},
});
Calling actions requires the dispatch method, which optionally takes arguments:
export default defineComponent({
setup() {
const books = computed(() => store.state.books);
onMounted(async () => await store.dispatch("loadBooks"));
return {
books
};
},
});
Finally, direct state mutations happen only through commit:
const incrementPage = () =>
store.commit("setPage", store.state.currentPage + 1);
const decrementPage = () =>
store.commit("setPage", store.state.currentPage - 1);
Attempting to assign state manually will raise an error, which is exactly the protection you want:
const incrementPage = () => store.state.currentPage++;
const decrementPage = () => store.state.currentPage--;
Vuex 4 works, but its TypeScript story is weak. The boilerplate for typing state, mutations, and actions properly quickly piles up.
Vuex 5: A Simpler, TypeScript-First Redesign
Vuex 5 is not yet released. It currently exists only as an RFC and its APIs will evolve. The example code for this section is therefore not expected to run.
The redesign's stated goals:
- Remove mutations and let actions mutate state.
- Deliver first-class TypeScript inference.
- Support multiple stores better.
Store creation looks markedly different:
export default createStore({
key: 'bookStore',
state: () => ({
isBusy: false,
books: new Array<Work>()
}),
actions: {
async loadBooks() {
try {
this.isBusy = true;
const response = await bookService.getBooks();
if (response.status === 200) {
this.books = response.data.works;
}
} finally {
this.isBusy = false;
}
}
},
getters: {
findBook(key: string): Work | undefined {
return this.books.find(b => b.key === key);
}
}
});
Three shifts stand out here. The store needs an explicit key to support multiple stores. The state is a factory function, not a plain object. Actions access state through the this pointer, instead of receiving injected arguments. Removing the commit indirection improves type inference since all related code lives together.
Instead of registering your store, you register the Vuex plugin itself:
import { createVuex } from 'vuex'
createApp(App)
.use(createVuex())
.use(router)
.mount('#app')
Then pull in your store definition and create instances as needed:
import bookStore from "@/store";
export default defineComponent({
components: {
BookInfo,
},
setup() {
const store = bookStore(); // Generate the wrapper
// ...
The store factory returns the same instance each invocation. The resulting object exposes state as plain properties and actions as ordinary methods, with full TypeScript awareness. Calling state and actions feels no different than dealing with any reactive object:
onMounted(async () => await store.loadBooks());
const incrementPage = () => store.currentPage++;
const decrementPage = () => store.currentPage--;
For teams that prefer Composition API style, the proposal also allows building stores with that syntax:
export default defineStore("another", () => {
// State
const isBusy = ref(false);
const books = reactive(new Array≷Work>());
// Actions
async function loadBooks() {
try {
this.isBusy = true;
const response = await bookService.getBooks(this.currentTopic, this.currentPage);
if (response.status === 200) {
this.books = response.data.works;
}
} finally {
this.isBusy = false;
}
}
findBook(key: string): Work | undefined {
return this.books.find(b => b.key === key);
}
// Getters
const bookCount = computed(() => this.books.length);
return {
isBusy,
books,
loadBooks,
findBook,
bookCount
}
});
The main regression in this design is losing the immutability of state — nothing stops code from writing to state outside of designated actions. There's an open discussion about re-enabling that constraint during development as in Vuex 4, but no final decision yet. For those who value Vuex's guardrails, this is a loss worth watching as the RFC matures.
Choosing a State Strategy
The way you handle shared state in a Vue 3 application has a direct impact on how maintainable and predictable your codebase becomes. Different scales and team structures call for different approaches, and each pattern you've seen here comes with its own trade-offs between simplicity, structure, and long-term flexibility.
The patterns reviewed cover the practical range: from lightweight composition-based solutions for smaller scoped needs, to the more formalized and opinionated structure of a dedicated store. Each approach works well in the right context, and recognizing when a pattern is getting too complex is often the strongest signal that it's time to move to a more robust solution.
Looking ahead, the direction of Vuex 5 suggests an emphasis on reducing boilerplate and leveraging the Composition API more directly. That evolution should make state management feel less like an external system and more like a native extension of how Vue components already work. For teams currently evaluating their options, it's worth keeping that trajectory in mind — patterns that align with that vision will likely require less rework down the line.



