The Problem: Pinia Gives You Freedom, Not Rules
Pinia is a fantastic state management library for Vue, but it doesn’t enforce any architectural patterns. It gives you complete freedom to structure your stores however you want. This flexibility is powerful, but it comes with a hidden cost: without discipline, your stores can become unpredictable and hard to test.
The core issue? Pinia stores are inherently mutable and framework-coupled. While this makes them convenient for rapid development, it creates three problems:
// Traditional Pinia approach - tightly coupled to Vue
export const useTodosStore = defineStore('todos', () => {
const todos = ref<Todo[]>([])
function addTodo(text: string) {
todos.value.push({ id: Date.now(), text, done: false })
}
return { todos, addTodo }
})
The problem? Components can bypass your API and directly manipulate state:
<script setup lang="ts">
import { useTodosStore } from '@/stores/todos'
const store = useTodosStore()
// Intended way
store.addTodo('Learn Pinia')
// But this also works! Direct state manipulation
store.todos.push({ id: 999, text: 'Hack the state', done: false })
</script>
This leads to unpredictable state changes, makes testing difficult (requires mocking Pinia’s entire runtime), and couples your business logic tightly to Vue’s reactivity system.
The Solution: TEA + Private Store Pattern
What if we could keep Pinia’s excellent developer experience while adding the predictability and testability of functional patterns? Enter The Elm Architecture (TEA) combined with the “private store” technique from Mastering Pinia by Eduardo San Martin Morote (creator of Pinia).
This hybrid approach gives you:
- Pure, testable business logic that’s framework-agnostic
- Controlled state mutations through a single dispatch function
- Seamless Vue integration with Pinia’s reactivity
- Full devtools support for debugging
You’ll use a private internal store for mutable state, expose only selectors and a dispatch function publicly, and keep your update logic pure and framework-agnostic.
Understanding The Elm Architecture
Before we dive into the implementation, let’s understand the core concepts of TEA:
- Model: The state of your application
- Update: Pure functions that transform state based on messages/actions
- View: Rendering UI based on the current model
The key insight is that update functions are pure—given the same state and action, they always return the same new state. This makes them trivial to test without any framework dependencies.
How It Works: Combining TEA with Private State
The pattern uses three key pieces: a private internal store for mutable state, pure update functions for business logic, and a public store that exposes only selectors and dispatch.
The Private Internal Store
First, create a private store that holds the mutable model. This stays in the same file as your public store but is not exported:
// Inside stores/todos.ts - NOT exported!
const useTodosPrivate = defineStore('todos-private', () => {
const model = ref<TodosModel>({
todos: []
})
return { model }
})
The key here: no export
keyword means components can’t access this directly.
Pure Update Function
Next, define your business logic as pure functions:
// stores/todos-update.ts
import type { TodosModel, TodosMessage } from './todos-model'
export function update(
model: TodosModel,
message: TodosMessage
): TodosModel {
switch (message.type) {
case 'ADD_TODO':
return {
...model,
todos: [
...model.todos,
{ id: Date.now(), text: message.text, done: false }
]
}
case 'TOGGLE_TODO':
return {
...model,
todos: model.todos.map(todo =>
todo.id === message.id
? { ...todo, done: !todo.done }
: todo
)
}
default:
return model
}
}
This update function is completely framework-agnostic. You can test it with simple assertions:
import { describe, it, expect } from 'vitest'
import { update } from './todos-update'
describe('update', () => {
it('adds a todo', () => {
const initial = { todos: [] }
const result = update(initial, { type: 'ADD_TODO', text: 'Test' })
expect(result.todos).toHaveLength(1)
expect(result.todos[0].text).toBe('Test')
})
})
Public Store with Selectors + Dispatch
Finally, combine everything in a single file. The private store is defined but not exported:
// stores/todos.ts (this is what components import)
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { update } from './todos-update'
import type { TodosModel, TodosMessage } from './todos-model'
// Private store - not exported!
const useTodosPrivate = defineStore('todos-private', () => {
const model = ref<TodosModel>({
todos: []
})
return { model }
})
// Public store - this is what gets exported
export const useTodosStore = defineStore('todos', () => {
const privateStore = useTodosPrivate()
// Selectors
const todos = computed(() => privateStore.model.todos)
// Dispatch
function dispatch(message: TodosMessage) {
privateStore.model = update(privateStore.model, message)
}
return { todos, dispatch }
})
Usage in Components
Components interact with the public store:
<script setup lang="ts">
import { useTodosStore } from '@/stores/todos'
const store = useTodosStore()
</script>
<template>
<div>
<input @keyup.enter="store.dispatch({ type: 'ADD_TODO', text: $event.target.value })" />
<div v-for="todo in store.todos" :key="todo.id">
<input
type="checkbox"
:checked="todo.done"
@change="store.dispatch({ type: 'TOGGLE_TODO', id: todo.id })"
/>
{{ todo.text }}
</div>
</div>
</template>
Simpler Alternative: Using Vue’s readonly
If you want to prevent direct state mutations without creating a private store, Vue’s readonly
utility provides a simpler approach:
// stores/todos.ts
import { defineStore } from 'pinia'
import { ref, readonly } from 'vue'
import { update } from './todos-update'
import type { TodosModel, TodosMessage } from './todos-model'
export const useTodosStore = defineStore('todos', () => {
const model = ref<TodosModel>({
todos: []
})
// Dispatch
function dispatch(message: TodosMessage) {
model.value = update(model.value, message)
}
// Only expose readonly state
return {
todos: readonly(model),
dispatch
}
})
With readonly
, any attempt to mutate the state from a component will fail:
<script setup lang="ts">
const store = useTodosStore()
// ✓ Works - using dispatch
store.dispatch({ type: 'ADD_TODO', text: 'Learn Vue' })
// ✓ Works - accessing readonly state
const todos = store.model.todos
// ✗ TypeScript error - readonly prevents mutation
store.model.todos.push({ id: 1, text: 'Hack', done: false })
</script>
Benefits of This Approach
- Pure business logic: The
update
function has zero dependencies on Vue or Pinia - Easy testing: Test your update function with simple unit tests
- Framework flexibility: Could swap Vue for React without changing update logic
- Type safety: TypeScript ensures message types are correct
- Devtools support: Still works with Pinia devtools since we’re using real stores
- Encapsulation: Private store is an implementation detail
Conclusion
By combining The Elm Architecture with Pinia’s private store pattern, we achieve:
- Pure, testable business logic
- Clear separation of concerns
- Framework-agnostic state management
- Full Pinia devtools integration
- Type-safe message dispatching
This pattern scales from simple forms to complex domain logic while keeping your code maintainable and your tests simple.
Credit: This post synthesizes ideas from The Elm Architecture and Eduardo San Martin Morote’s “private store” pattern from Mastering Pinia.