Pinia 4.0.0 was published on 14 July with @pinia/nuxt 1.0 and @pinia/testing 2.0, and the current patch is 4.0.3 from 12 August. The release notes describe "only technically breaking changes": Pinia is now ESM only, with no CommonJS build, and @vue/devtools-api, upgraded to version 8, must be installed alongside it. Errors and development warnings were rebuilt on a small library called Nostics. Your defineStore calls stay as they are.
Each step below gives the command and what happens if you skip it, so you can tell an upgrade problem from a bug in your own code. A Vite app tested with Vitest comes first, then a Nuxt 4 app.
Before you start
Get a green baseline, so that every failure after the upgrade belongs to the upgrade:
npm ls vue pinia @pinia/nuxt @pinia/testing
npx vitest --run
Pinia 3.0.4 is the last 3.x release. Apart from the devtools package in step 1, Pinia 4's peer dependencies ask for Vue 3.5.11 or newer and, if you use TypeScript, TypeScript 5.6 or newer. A project created in the last year meets both.
Step 1: Install Pinia 4 and the devtools API
In Pinia 3, @vue/devtools-api version 7 was an ordinary dependency that arrived on its own. In Pinia 4 it is a peer dependency, version ^8.1.5, marked as required. A peer dependency is one the package expects your project to provide, so your package.json has to list it.
npm install pinia@^4.0.3 @vue/devtools-api@^8.2.1
If you leave out the second package, what happens depends on the package manager. npm 7 and later install a missing required peer automatically, so the app may still run, but nothing in your package.json records it. Yarn does not install peers for you, and npm with its legacy-peer-deps setting on ignores them, so the package is simply missing.
In that case Pinia cannot load at all. dist/pinia.js imports setupDevtoolsPlugin from @vue/devtools-api at the top of the file, so the dev server and every test that imports a store fail because that import cannot be resolved. List the package yourself and the question never comes up.
Step 2: Find code that loads Pinia with require()
Pinia 3 shipped CommonJS builds (pinia.cjs and pinia.prod.cjs) next to the ES module. Pinia 4 has a single entry point, dist/pinia.js, and it is an ES module. Code that loads Pinia with require() is the code to check:
git grep -nE "require\(['\"]pinia"
For Node scripts this is usually fine. Node 20.19 and 22.12 and later can require() an ES module that has no top-level await, and Pinia has none. Vite 8 already needs one of those Node versions, so a Vite 8 project is covered.
Jest is the place it breaks. Jest runs tests as CommonJS by default and does not transform node_modules, so a test that imports a store fails with SyntaxError: Cannot use import statement outside a module, pointing into pinia/dist/pinia.js. Moving those tests to Vitest, which runs ES modules natively, is the clean fix. If you have to stay on Jest for now, Jest must transform Pinia and nostics, the ES-module-only library behind Pinia 4's messages. Once step 3 moves @pinia/testing to 2.0, add it too: it dropped its CommonJS build as well. List them as exceptions in transformIgnorePatterns, and make sure your transform pattern matches .mjs, the extension nostics and @pinia/testing use.
Step 3: Upgrade the testing package
npm install -D @pinia/testing@^2.0.1
Skipping this one gives no error. @pinia/testing 1.0.3 declares pinia >=3.0.4 as its peer range, so it installs next to Pinia 4 without complaint. Upgrade anyway: 2.0 is the release built with Pinia 4, it requires pinia 4.0.2 or newer, and its errors are Nostics diagnostics with a code and a fix, like the core's. The API did not change. A component that shows the cart count, using the cart store from step 4:
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useCartStore } from '@/stores/cart'
const { count } = storeToRefs(useCartStore())
</script>
<template>
<span>{{ count }} items</span>
</template>
Its test with a mocked store looks exactly as it did on Pinia 3:
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import { expect, it, vi } from 'vitest'
import CartBadge from '../CartBadge.vue'
it('shows the number of items', () => {
const wrapper = mount(CartBadge, {
global: {
plugins: [createTestingPinia({
createSpy: vi.fn,
initialState: { cart: { items: ['Milk', 'Eggs'] } },
})],
},
})
expect(wrapper.text()).toContain('2')
})
Keep createSpy: vi.fn if Vitest's globals are off, which is the default. Without it, the test fails with PINIA_TESTING_C0001: You must configure the "createSpy" option. The error's fix property suggests vi.fn. Pass a called spy, vi.fn(), by mistake and you get PINIA_TESTING_C0002: Invalid "createSpy" option. Its fix says to pass the function itself.
Run the whole suite now and fix what fails. The next two steps cover changes a test usually misses: a value storeToRefs() now skips without an error, and a warning that only appears in the console.
Step 4: Check storeToRefs on plain null values
storeToRefs() turns a store's state and getters into refs you can destructure without losing reactivity. In Pinia 3 it checked each property with value.effect, so a property whose value was null or undefined, rather than a ref holding null, made it throw: TypeError: Cannot read properties of null (reading 'effect'). Pinia 4 skips nullish values. The crash is gone, but the property is still not a ref:
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
export const useCartStore = defineStore('cart', () => {
const items = ref<string[]>([])
const couponCode: string | null = null
const count = computed(() => items.value.length)
return { items, couponCode, count }
})
storeToRefs(useCartStore()) returns items and count and leaves out couponCode, so const { couponCode } = storeToRefs(cart) gives undefined. If you once avoided storeToRefs() because of that TypeError, look at the value it tripped on. Anything the template should react to belongs in a ref:
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
export const useCartStore = defineStore('cart', () => {
const items = ref<string[]>([])
const couponCode = ref<string | null>(null)
const count = computed(() => items.value.length)
return { items, couponCode, count }
})
Step 5: Fix the new warning about plugin values
A Pinia plugin is a function passed to pinia.use() whose returned object is added to every store. In development, Pinia 4 checks each property of that object. If one holds a plain object or null, the console shows a diagnostic like this one:
[PINIA_R1006] Property "api" of store "cart" is not reactive (not a ref, reactive object, or shallowRef), so storeToRefs() ignores it.
├▶ fix: If it should be reactive state, wrap it with ref(), reactive(), or shallowRef(). If it is an intentional non-reactive property, wrap it with markRaw() so storeToRefs() skips it explicitly.
╰▶ see: https://pinia.vuejs.org/core-concepts/plugins.html#Adding-new-external-properties
That format is Nostics at work: a code to search for, the reason, the fix and a docs link. Do what the fix line says: mark things that are not state, such as an API client or a router, with markRaw(), and wrap state in a ref:
import { markRaw, shallowRef } from 'vue'
export function apiPlugin() {
return {
api: markRaw({ baseURL: '/api' }),
lastError: shallowRef<Error | null>(null),
}
}
Neither property triggers the warning. markRaw() marks the api object as one Vue never makes reactive, and shallowRef() makes lastError a ref that storeToRefs() returns with the store's own refs.
Third-party plugins follow their own maintainers' ranges. pinia-plugin-persistedstate 4.7.1, its current release, declares pinia >=3.0.0 and @pinia/nuxt >=0.10.0 as peers, so its published range includes Pinia 4 and @pinia/nuxt 1.0. It attaches its $persist and $hydrate functions directly to the store rather than returning objects, so it does not trigger this diagnostic. For any other plugin, run npm view <package> peerDependencies and read the range before you upgrade.
Upgrade a Nuxt app
A Nuxt project gets the same packages plus the module, all in one command:
npm install pinia@^4.0.3 @pinia/nuxt@^1.0.2 @vue/devtools-api@^8.2.1
npx nuxt prepare
Leave @pinia/nuxt out of that command and npm refuses the install with an ERESOLVE error whose key line reads peer pinia@"^3.0.4" from @pinia/nuxt@0.11.3: the old module only accepts Pinia 3. Do not force past it with --legacy-peer-deps; the peer range is the module's authors telling you what they tested.
Nothing else in the project changes. modules: ['@pinia/nuxt'] stays in nuxt.config.ts, stores in app/stores/ are still auto-imported, and so are defineStore, storeToRefs and acceptHMRUpdate. nuxt prepare regenerates the types so your editor picks up the new versions. Then run the tests, start npx nuxt dev, and open a page that uses each store while watching the console for PINIA_ codes. Steps 2, 4 and 5 apply to a Nuxt app as written above, and so does step 3 if its tests use @pinia/testing.