2025.09
webdev
typescript
vue
pinia

How I manage Pinia stores

Tips and explanations how I tend to manage my state in Pinia stores.

Intro

Recently I've been creating apps that heavily rely on state management.

Most of the time Pinia is so simple and straightforward that you don't really need to overcomplicate things.

Plain is good.

Still, from time to time I've found myself in a situation that the way my stores were structured felt... wrong.

Obligatory disclaimer

For some reason state management is a very heated topic

As with everything in software development - there is no one true way. Things that I will share work for me and my projects. They may not cover your needs, or you may simply not like them. That's ok.

Why is this public?

By design everything you want to become state must be returned from the store definition function.

defineStore('store', () => {
    const somethingPrivate = ref('foo')
    const somethingPublic = computed(() => somethingPrivate.value + 'bar')
  
    // Even if we call it private it is still visible to anyone
    // so just don't call it that, okay?
    return { somethingPrivate, somethingPublic }
})

If we do not return something from the store it will cause problems with devtools and plugins. Oh, and it will break during SSR. So overall a nightmare.

So, just return every state ref() that is part of the store kids.

I know it is not a big deal, if it's in your app that you control you can simply not use somethingPrivate

BUT

It pollutes the store's interface with things that you shouldn't even use. We don't like messy stores over here.

So instead we will overcomplicate things a bit for the sake of simplicity

Just a tiny bit I promise

The "make it double" way

To overcome this you can create something called "private store"

const privateStore = defineStore('_store', () => {
  const somethingPrivate = ref('foo')
  
  return { somethingPrivate }
})

export const publicStore = defineStore('store', () => {
  const { somethingPrivate } = privateStore()
  
  const somethingPublic = computed(() => somethingPrivate.value + 'bar')
  
  return { somethingPublic }
})

So a couple of things here:

  1. Now we have two stores but only one is exported
  2. The private store is prefixed with _ to indicate that it is private
    It doesn't really matter what you name it, that's how I do it.
  3. We use privateStore only inside the publicStore
  4. Now publicStore can decide what it exposes to the public

This way if we do not expose somethingPrivate directly it won't be visible or even mutable from publicStore's properties perspective.

Why I do it?

I think it separates concerns quite nicely. We can store our data in an entirely different way than we expose it.

We can have setters or actions that modify the private state and expose only them. It reduces the possibility of errors caused by mutating the store in a funky way.

It also just feels right to me, you know?

Sometimes it just feels better to do things in a certain way. That's what happened here for me.

storeToRefs is nice but annoying

Let's say we have a store like this:

const useOurStore = defineStore('store', () => {
    const foo = ref('foo')
    
    function barrify() {
        return foo.value + 'bar'
    }
    
    return { foo, barrify }
})

By default useOurStore().ourVariable is not a ref so we loose all the goodies that come with it.

But wait, there is storeToRefs() that does exactly that

const ourStore = useOurStore()
const { foo } = storeToRefs(ourStore)
const { barrify } = ourStore

Okay woah... three lines of code? In this economy??

Why can't we just:

const { foo, barrify } = storeToRefs(useOurStore())

That's because storeToRefs() only returns state properties. So barrify would be undefined.

Okay fair, but I kinda don't wanna do that every time.

How about... I don't know...

const { foo, barrify } = reactiveStore(useOurStore())

Which would return every state property as a ref and every function as is??? Sounds nice, huh?

Let's make it happen

import { storeToRefs } from "pinia";
import type { Store } from "pinia";

export type ReactiveStore<T extends Store> = Omit<T, keyof ReturnType<typeof storeToRefs<T>>> & ReturnType<typeof storeToRefs<T>>;

export function reactiveStore<T extends Store>(store: T): ReactiveStore<T> {
    return {
        ...store,
        ...storeToRefs(store)
    };
}

Ey! chill about the pitchforks for that type. We'll talk about it

The return statement is pretty straightforward. We spread every property from the store (including actions this time) and overwrite state properties with their ref() versions.

So, back to that monstrosity of a type

Let's break it down:

// Generic type that extends Pinia's Store type.
// That allows us to create types based on the store properties.
export type ReactiveStore<T extends Store>
// This part gets us every export from the store except state properties.
Omit<T, keyof ReturnType<typeof storeToRefs<T>>>
// And finally this gets us every state property as a ref
ReturnType<typeof storeToRefs<T>>

So why not just T & ReturnType<typeof storeToRefs<T>>?

// Because this will produce something like this for state property:
string & globalThis.ComputedRef<string>
// instead of a nice and clean
globalThis.ComputedRef<string>

I'd rather have one messy type than a bunch of messy types

Let's use everything in practice

Recently, I've been working on my personal notes app (as one does from time to time). Without going into too much detail, it's a perfect example of how to use everything we've discussed so far.

A bonus thing before we continue

I've stumbled upon a really useful post on r/typescript And its first comment to be specific.

I'll just quote it here in case it gets lost to time:

u/izzlesnizzit on r/typescript  

If order matters, choose array.  
If fast lookup matters, choose object.  
If both matter, and the order doesn't change, you can use ES6 Maps  
If both matter and order changes, you can have a composite return value like
{
  orderedKeys: [...],
  items: {...}
}

Simplfied version of my notes store:

const privateStore = defineStore('_notes', () => {
    const notesMap = ref<Notes>({})
    const notesOrder = ref<string[]>([])

    return { notesMap, notesOrder }
})

export const useNotesStore = defineStore('notes', () => {
    const { notesMap, notesOrder } = reactiveStore(privateStore());

    const notes = computed(() => {
        return notesOrder.value.map(uuid => notesMap.value[uuid]);
    })

    function addNote() {
        const uuid = crypto.randomUUID();
        notesMap.value[uuid] = {
            uuid,
            text: 'New Note'
        }
        notesOrder.value.push(uuid);
    }

    function changeText(uuid: string, text: string) {
        if(notesMap.value[uuid]) {
            notesMap.value[uuid].text = text || 'New Note';
        }
    }

    function moveNote(uuid: string, newIndex: number) {
        const noteIndex = notesOrder.value.indexOf(uuid);
        notesOrder.value.splice(noteIndex, 1);
        notesOrder.value.splice(newIndex, 0, uuid);
    }

    return { notes, addNote, changeText };
})

As you can see, we've applied everything we've discussed so far.

  1. We created a private store that holds our state in different structure that is easier to manage
  2. We created a public store that exposes only what we want
  3. We used reactiveStore() to get nice ref() versions of our state properties alongside actions.

I hope you found something useful in this article. Or even got an entirely new idea to try out.

I should go,

Krystian