
A walkthrough of rstore, the reactive data store for Vue with normalized caching, typed queries, and a plugin system.
Reza Baar
June 24, 2026
Rstore is a reactive data store for Vue built by Guillaume Chau (Vue core team). It sits at a higher level than Pinia. Where Pinia gives you low-level reactive state, rstore gives you a normalized cache, typed query and mutation APIs, and a plugin system for connecting to any data source. In this post, we'll set it up, define a data model, query and mutate data, and see how it compares to doing the same work manually.
You can find the docs at rstore.dev.
Before we write code, let's be clear about what rstore handles that you'd otherwise build yourself:
query() and liveQuery() return reactive results that work with Vue's setup, including async setup for SSR.create(), update(), and delete() modify data and automatically update the cache.If you've used Apollo Client or TanStack Query, the mental model is similar, but rstore is designed specifically for Vue's reactivity system.
rstore organizes data around collections. Each collection has a typed item shape and hooks for fetching and mutating:
// src/rstore/todos.ts
import { defineItemType } from '@rstore/vue';
export interface Todo {
id: string;
title: string;
completed: boolean;
createdAt: Date;
}
export const todoModel = defineItemType<Todo>().model({
name: 'todos',
} as const);
The as const assertion on the name is important. It lets TypeScript narrow the collection name for autocomplete.
Create the store and register your models:
// src/rstore/index.ts
import { createRStore } from '@rstore/vue';
import { todoModel } from './todos';
export const models = [todoModel] as const;
export const rstore = createRStore({
models,
});
Install it in your app:
// src/main.ts
import { createApp } from 'vue';
import App from './App.vue';
import { rstore } from './rstore';
const app = createApp(App);
app.use(rstore);
app.mount('#app');
By default, rstore doesn't know how to fetch data. You tell it how through collection-level hooks:
// src/rstore/todos.ts
import { defineItemType } from '@rstore/vue';
export interface Todo {
id: string;
title: string;
completed: boolean;
createdAt: Date;
}
export const todoModel = defineItemType<Todo>().model({
name: 'todos',
hooks: {
fetchFirst: ({ key }) => fetch(`/api/todos/${key}`).then((r) => r.json()),
fetchMany: () => fetch('/api/todos').then((r) => r.json()),
create: ({ item }) =>
fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(item),
}).then((r) => r.json()),
update: ({ key, item }) =>
fetch(`/api/todos/${key}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(item),
}).then((r) => r.json()),
delete: ({ key }) =>
fetch(`/api/todos/${key}`, { method: 'DELETE' }),
},
} as const);
Note: These hooks are plain functions that return promises. You can use fetch, $fetch (in Nuxt), Axios, or anything else.
Now let's use it in a component. The query composable fetches data and returns a reactive result:
<!-- components/TodoList.vue -->
<script setup lang="ts">
import { useStore } from '@rstore/vue';
const store = useStore();
const { data: todos, loading, error } = await store.todos.query(
(q) => q.many()
);
</script>
<template>
<div>
<p v-if="loading">Loading...</p>
<p v-else-if="error">{{ error.message }}</p>
<ul v-else>
<li v-for="todo in todos" :key="todo.id">
<span :class="{ done: todo.completed }">{{ todo.title }}</span>
</li>
</ul>
</div>
</template>
q.many() fetches all items in the collection using the fetchMany hook. The await makes this work with async setup and SSR.
For fetching a single item by key:
<script setup lang="ts">
const store = useStore();
const props = defineProps<{ todoId: string }>();
const { data: todo } = await store.todos.query(
(q) => q.first(props.todoId)
);
</script>
q.first(key) uses the fetchFirst hook and stores the result in the normalized cache by its id.
Creating, updating, and deleting items is done through the collection's mutation methods. These call the corresponding hooks and update the cache automatically.
<!-- components/AddTodo.vue -->
<script setup lang="ts">
import { useStore } from '@rstore/vue';
const store = useStore();
const title = ref('');
async function addTodo() {
if (!title.value.trim()) return;
await store.todos.create({
id: crypto.randomUUID(),
title: title.value,
completed: false,
createdAt: new Date(),
});
title.value = '';
}
</script>
<template>
<form @submit.prevent="addTodo">
<input v-model="title" placeholder="New todo" />
<button type="submit">Add</button>
</form>
</template>
After create resolves, the new item is in the normalized cache. Any component querying store.todos sees it immediately without refetching.
Updating and deleting work the same way:
// Toggle completed
await store.todos.update(todo.id, {
completed: !todo.completed,
});
// Delete
await store.todos.delete(todo.id);
This is the key difference from manual state management. When you fetch a list of todos and then fetch a single todo by ID, rstore stores them in the same normalized cache keyed by id. If the same todo appears in both results, there's only one copy.
When you update that todo (from any component), every reactive reader that references it sees the new value. You don't need to manually invalidate a list query after updating a single item.
Compare this to doing it yourself with Pinia:
// With Pinia, you'd manage this manually
const useTodoStore = defineStore('todos', () => {
const items = ref<Todo[]>([]);
const currentTodo = ref<Todo | null>(null);
async function fetchTodos() {
items.value = await $fetch('/api/todos');
}
async function updateTodo(id: string, data: Partial<Todo>) {
const updated = await $fetch(`/api/todos/${id}`, {
method: 'PATCH',
body: data,
});
// Manually update the list AND the current item
const index = items.value.findIndex((t) => t.id === id);
if (index > -1) items.value[index] = updated;
if (currentTodo.value?.id === id) currentTodo.value = updated;
}
return { items, currentTodo, fetchTodos, updateTodo };
});
That manual sync between items and currentTodo is exactly what rstore's normalized cache eliminates.
For larger apps, you might not want to define fetch hooks on every model. rstore's plugin system lets you define data fetching logic once. You could build a REST plugin that generates endpoints from collection names:
// src/rstore/plugins/rest.ts
import { definePlugin } from '@rstore/vue';
export default definePlugin({
name: 'rest-api',
setup({ hook }) {
hook('fetchFirst', async ({ collection, key }) => {
return fetch(`/api/${collection.name}/${key}`).then((r) => r.json());
});
hook('fetchMany', async ({ collection }) => {
return fetch(`/api/${collection.name}`).then((r) => r.json());
});
hook('create', async ({ collection, item }) => {
return fetch(`/api/${collection.name}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(item),
}).then((r) => r.json());
});
},
});
Now your models don't need individual hooks. The plugin handles all collections that follow the REST convention.
They solve different problems and can coexist in the same app.
Use Pinia for client state: UI state (sidebar open/closed), user preferences, form drafts, anything that doesn't come from a server.
Use rstore for server state: data fetched from APIs that needs caching, deduplication, and normalized storage. Especially useful when the same entity appears in multiple views (a user in a list and on a detail page) and needs to stay in sync.
If your app mostly reads and writes to an API, rstore removes a lot of the manual plumbing. If your app is mostly local state with occasional API calls, Pinia is simpler.
query() returns reactive results and works with async setup for SSRWe set up rstore, defined a typed data model, queried and mutated data, and saw how the normalized cache keeps everything in sync without manual state management. If you're spending a lot of time wiring up fetch logic, cache invalidation, and keeping list and detail views consistent, rstore handles that for you. I hope this post has been helpful.
Get the latest news and updates on developer certifications. Content is updated regularly, so please make sure to bookmark this page or sign up to get the latest content directly in your inbox.

Getting Started with rstore in Vue
A walkthrough of rstore, the reactive data store for Vue with normalized caching, typed queries, and a plugin system.
Reza Baar
Jun 24, 2026

Promise.withResolvers(): The Deferred Pattern Built-In
Promise.withResolvers() replaces the manual deferred pattern in JavaScript. One destructuring, no executor, no let. ES2024, supported in all modern runtimes.
Martin Ferret
Jun 23, 2026

Error Handling in Next.js with catchError
Learn why react-error-boundary falls short in the Next.js App Router and how catchError from Next.js 16.2 fixes both framework error propagation and server data refetching with a single function call.
Aurora Scharff
Jun 18, 2026