Getting Started with rstore in Vue

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

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.

What rstore Does

Before we write code, let's be clear about what rstore handles that you'd otherwise build yourself:

  • Normalized reactive cache: Items are stored by ID and shared across all components that reference them. Update an item in one place, and every component that reads it sees the change.
  • Query composables: query() and liveQuery() return reactive results that work with Vue's setup, including async setup for SSR.
  • Mutations: create(), update(), and delete() modify data and automatically update the cache.
  • Plugin system: Fetching logic is not baked in. You connect rstore to your API (REST, GraphQL, local DB) through plugins.

If you've used Apollo Client or TanStack Query, the mental model is similar, but rstore is designed specifically for Vue's reactivity system.

Defining a Data Model

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.

Setting Up the Store

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');

    

Adding Data Source Hooks

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.

Querying Data

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.

Mutations

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);

    

The Normalized Cache

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.

Plugins

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.

When to Use rstore vs Pinia

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.

Key Takeaways

  • rstore is a reactive data store with a normalized cache, not a low-level state management library
  • Define typed models with hooks for fetching and mutating data
  • query() returns reactive results and works with async setup for SSR
  • Mutations update the normalized cache automatically, so all readers stay in sync
  • The plugin system lets you centralize data fetching logic for all collections
  • rstore handles server state. Pinia handles client state. They complement each other.

Conclusion

We 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.

More certificates.dev articles

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.