doc/development/fe_guide/pinia.md
Pinia is a tool for managing client-side state for Vue applications. Refer to the official documentation on how to use Pinia.
You should always prefer using shared Pinia instance from ~/pinia/instance.
This allows you to easily add more stores to components without worrying about multiple Pinia instances.
import { pinia } from '~/pinia/instance';
new Vue({ pinia, render(h) { return h(MyComponent); } });
Prefer creating small stores that focus on a single task only. This is contrary to the Vuex approach which encourages you to create bigger stores.
Treat Pinia stores like cohesive components rather than giant state façades (Vuex modules).
flowchart TD
A[Store]
A --> B[State]
A --> C[Actions]
A --> D[Mutations]
A --> E[Getters]
B --> F[items]
B --> G[isLoadingItems]
B --> H[itemWithActiveForm]
B --> I[isSubmittingForm]
flowchart TD
A[Items Store]
A --> B[State]
A --> C[Actions]
A --> D[Getters]
B --> E[items]
B --> F[isLoading]
H[Form Store]
H --> I[State]
H --> J[Actions]
H --> K[Getters]
I --> L[activeItem]
I --> M[isSubmitting]
Place state, actions, and getters in a single file.
Do not create 'barrel' store index files which import everything from actions.js, state.js and getters.js.
If your store file gets too big it's time to consider splitting that store into multiple stores.
Pinia offers two types of store definitions: option and setup. Prefer the option type when creating new stores. This promotes consistency and will simplify the migration path from Vuex.
Prefer using global Pinia stores for global reactive state.
// bad ❌
import { isNarrowScreenMediaQuery } from '~/lib/utils/css_utils';
new Vue({
data() {
return {
isNarrow: false,
};
},
mounted() {
const query = isNarrowScreenMediaQuery();
this.isNarrow = query.matches;
query.addEventListener('change', (event) => {
this.isNarrow = event.matches;
});
},
render() {
if (this.isNarrow) return null;
//
},
});
// good ✅
import { pinia } from '~/pinia/instance';
import { useViewport } from '~/pinia/global_stores/viewport';
new Vue({
pinia,
...mapState(useViewport, ['isNarrowScreen']),
render() {
if (this.isNarrowScreen) return null;
//
},
});
Pinia offers an HMR option that you have to manually attach in your code. The experience Pinia offers with this method is subpar and this should be avoided.
Follow the official testing documentation.
Official documentation suggests using setActivePinia(createPinia()) to test the Pinia.
Our recommendation is to leverage createTestingPinia with unstubbed actions.
It acts the same as setActivePinia(createPinia()) but also allows us to spy on any action by default.
Always use createTestingPinia with stubActions: false when unit testing the store.
A basic test could look like this:
import { createTestingPinia } from '@pinia/testing';
import { useMyStore } from '~/my_store.js';
describe('MyStore', () => {
beforeEach(() => {
createTestingPinia({ stubActions: false });
});
it('does something', () => {
useMyStore().someAction();
expect(useMyStore().someState).toBe(true);
});
});
Any given test should only check for any of these three things:
Never try to use the same Pinia instance in more than one test case. Always create a fresh Pinia instance because it is what actually holds your state.
Follow the official testing documentation.
Pinia requires special handling to support Vue 3 compat mode:
PiniaVuePlugin on the Vue instanceshallowMount/mount from Vue Test UtilsA full setup looks like this:
import Vue from 'vue';
import { createTestingPinia } from '@pinia/testing';
import { PiniaVuePlugin } from 'pinia';
import { shallowMount } from '@vue/test-utils';
import { useMyStore } from '~/my_store.js';
import MyComponent from '~/my_component.vue';
Vue.use(PiniaVuePlugin);
describe('MyComponent', () => {
let pinia;
let wrapper;
const createComponent = () => {
wrapper = shallowMount(MyComponent, { pinia });
}
beforeEach(() => {
pinia = createTestingPinia();
// store is created before component is rendered
useMyStore();
});
it('does something', () => {
createComponent();
// all actions are stubbed by default
expect(useMyStore().someAction).toHaveBeenCalledWith({ arg: 'foo' });
expect(useMyStore().someAction).toHaveBeenCalledTimes(1);
});
});
In most cases you won't need to set stubActions: false when testing components.
Instead, the store itself should be properly tested and the component tests should check that the actions were called with correct arguments.
Pinia doesn't allow to unstub actions once they've been stubbed.
That means you can not use them to set the initial state if you didn't set stubActions: false.
In that case it is allowed to set the state directly:
describe('MyComponent', () => {
let pinia;
let wrapper;
const createComponent = () => {
wrapper = shallowMount(MyComponent, { pinia });
}
beforeEach(() => {
// all the actions are stubbed, we can't use them to change the state anymore
pinia = createTestingPinia();
// store is created before component is rendered
useMyStore();
});
it('does something', () => {
// state is set directly instead of using an action
useMyStore().someState = { value: 1 };
createComponent();
// ...
});
});
GitLab is actively migrating from Vuex, you can contribute and follow this progress in epic 18476.
Before migrating decide what your primary state manager should be first. Proceed with this guide if Pinia was your choice.
Migration to Pinia could be completed in two ways: a single step migration and a multi-step one.
Follow single step migration if your store meets these criteria:
In any other case prefer the multi-step migration.
Follow the official Vuex migration guide.
mapActions, mapState with Pinia counterpartsmapMutations with Pinia's mapActionsmapGetters with Pinia's mapStateIf your diff starts to exceed reviewable size prefer the multi-step migration.
Learn about the official Vuex migration guide.
A walkthrough is available in the two part video series:
Follow these steps to iterate over the migration process and split the work onto smaller merge requests:
Identify the store you are going to migrate.
Start with the file that defines your store via new Vuex.Store() and go from there.
Include all the modules that are used inside this store.
Create a migration issue, assign a migration DRI(s) and list all the store modules you're going to migrate. Track your migration progress in that issue. If necessary, split the migration into multiple issues.
Create a new CODEOWNERS (.gitlab/CODEOWNERS) rule for the store files you're migrating, include all the Vuex module dependencies and store specs.
If you are migrating only a single store module then you would need to include only state.js (or your index.js),
actions.js, mutations.js and getters.js and their respective spec files.
Assign at least two individuals responsible for reviewing changes made to the Vuex store. Always sync your changes from Vuex store to Pinia. This is very important so you don't introduce regressions with the Pinia store.
Copy existing store as-is to a new location (you can call it stores/legacy_store for example). Preserve the file structure.
Do this for every store module you're going to migrate. Split this into multiple merge requests if necessary.
Create an index file (index.js) with a store definition (defineStore) and define your state in there.
Copy the state definition from state.js. Do not import actions, mutations and getters yet.
Use code mods to migrate the store files.
Import migrated modules in your new store's definition (index.js).
If you have circular dependencies in your stores consider using tryStore plugin.
Refactor components to use the new store. Split this into as many merge requests as necessary. Always update the specs with the components.
Remove the Vuex store.
Remove CODEOWNERS rule.
Close the migration issue.
You can use the merge requests migration breakdown as a reference:
Once your store is migrated consider refactoring it to follow our best practices. Split big stores into smaller ones.
Refactor tryStore uses.
You can use ast-grep codemods to simplify migration from Vuex to Pinia.
scripts/frontend/codemods/vuex-to-pinia/migrate.sh path/to/your/storeThe codemods will migrate actions.js, mutations.js and getters.js located in your store folder.
Manually scan these files after running the codemods to ensure they are properly migrated.
Vuex specs can not be automatically migrated, migrate them by hand.
Vuex module calls are replaced using Pinia conventions:
| Vuex | Pinia |
|---|---|
dispatch('anotherModule/action', ...args, { root: true }) | useAnotherModule().action(...args) |
dispatch('action', ...args, { root: true }) | useRootStore().action(...args) |
rootGetters['anotherModule/getter'] | useAnotherModule().getter |
rootGetters.getter | useRootStore().getter |
rootState.anotherModule.state | useAnotherModule().state |
If you have not yet migrated a dependent module (useAnotherModule and useRootStore in the examples above) you can create a temporary dummy store.
Use the guidance below to migrate Vuex modules.
It is not trivial to iteratively migrate stores with nested modules that have dependencies between them. In such cases prefer migrating nested modules first:
It is imperative that you don't create circular dependencies in your Pinia stores. Unfortunately Vuex design allows to create interdependent modules that we have to refactor later.
An example circular dependency in store design:
graph TD
A[Store Alpha] --> Foo(Action Foo)
B[Store Beta] --> Bar(Action Bar)
A -- calls --> Bar
B -- calls --> Foo
To mitigate this issue consider using tryStore plugin for Pinia during migration from Vuex:
// store_alpha/actions.js
function callOtherStore() {
// bad ❌, circular dependency created
useBetaStore().bar();
}
// store_beta/actions.js
function callOtherStore() {
// bad ❌, circular dependency created
useAlphaStore().bar();
}
// store_alpha/actions.js
function callOtherStore() {
// OK ✅, circular dependency avoided
this.tryStore('betaStore').bar();
}
// store_beta/actions.js
function callOtherStore() {
// OK ✅, circular dependency avoided
this.tryStore('alphaStore').bar();
}
This will look up the store by its name using Pinia instance and prevent the circular dependency issue.
Store name is defined when calling defineStore('storeName', ...).
You must initialize both stores prior to component mounting when using tryStore:
// stores are created in advance
useAlphaStore();
useBetaStore();
new Vue({ pinia, render(h) { return h(MyComponent); } });
The tryStore helper function can only be used during migration. Never use this in proper Pinia stores.
tryStoreAfter you finished the migration it is very important to redesign the stores so there are no more circular dependencies.
The easiest way to solve this would be to create a top level store that would orchestrate other stores.
graph TD
A[Store Alpha] --> Foo(Action Foo)
A -- calls --> Bar
B[Store Beta] --> Bar(Action Bar)
B -- calls --> Foo
graph TD
C[Store Gamma]
A[Store Alpha] --- Bar(Action Bar)
B[Store Beta] --- Foo(Action Foo)
C -- calls --> Bar
C -- calls --> Foo
This syncWithVuex plugin syncs your state from Vuex to Pinia and vice versa.
This allows you to iteratively migrate components by having both stores in your app during migration.
Usage example:
// Vuex store @ ./store.js
import Vuex from 'vuex';
import createOldStore from './stores/old_store';
export default new Vuex.Store({
modules: {
oldStore: createOldStore(),
},
});
// Pinia store
import { defineStore } from 'pinia';
import oldVuexStore from './store'
export const useMigratedStore = defineStore('migratedStore', {
syncWith: {
store: oldVuexStore,
name: 'oldStore', // use legacy store name if it is defined inside Vuex `modules`
namespaced: true, // set to 'true' if Vuex module is namespaced
},
// the state here gets sync with Vuex, any changes to migratedStore also propagate to the Vuex store
state() {
// ...
},
// ...
});
A Vuex store definition can be shared in multiple Vuex store instances.
In that case we can not rely on the store config alone to sync our Pinia store with the Vuex store.
We need to point our Pinia store to the actual Vuex store instance using syncWith helper function.
// this overrides the existing `syncWith` config
useMigratedStore().syncWith({ store: anotherOldStore });
// `useMigratedStore` state now is synced only with `anotherOldStore`
new Vue({ pinia, render(h) { return h(MyComponent) } });
testActionSome Vuex tests might use testAction helper to test that certain actions or mutations have been called.
We can migrate these specs using createTestPiniaAction helper from helpers/pinia_helpers in Jest.
describe('SomeStore', () => {
it('runs actions', () => {
return testAction(
store.actionToBeCalled, // action to be called immediately
{ someArg: 1 }, // action call arguments
{ someState: 1 }, // initial store state
[{ type: 'MUTATION_NAME', payload: '123' }], // mutation calls to expect
[{ type: 'actionName' }], // action calls to expect
);
});
});
import { createTestPiniaAction } from 'helpers/pinia_helpers';
describe('SomeStore', () => {
let store;
let testAction;
beforeEach(() => {
store = useMyStore();
testAction = createTestPiniaAction(store);
});
it('runs actions', () => {
return testAction(
store.actionToBeCalled,
{ someArg: 1 },
{ someState: 1 },
[{ type: store.MUTATION_NAME, payload: '123' }], // explicit reference to migrated mutation
[{ type: store.actionName }], // explicit reference to migrated action
);
});
});
Avoid using testAction in your proper Pinia tests: this should only be used during migration.
Always prefer testing each action call explicitly.
Pinia allows to define custom getters in Vue 3. Since we're using Vue 2 this is not possible.
To work around this you can use createCustomGetters helper from helpers/pinia_helpers.
describe('SomeStore', () => {
it('runs actions', () => {
const dispatch = jest.fn();
const getters = { someGetter: 1 };
someAction({ dispatch, getters });
expect(dispatch).toHaveBeenCalledWith('anotherAction', 1);
});
});
import { createCustomGetters } from 'helpers/pinia_helpers';
describe('SomeStore', () => {
let store;
let getters;
beforeEach(() => {
getters = {};
createTestingPinia({
stubActions: false,
plugins: [
createCustomGetters(() => ({
myStore: getters, // each store used in tests should be also declared here
})),
],
});
store = useMyStore();
});
it('runs actions', () => {
getters.someGetter = 1;
store.someAction();
expect(store.anotherAction).toHaveBeenCalledWith(1);
});
});
Avoid mocking getters in proper Pinia tests: this should only be used for migration. Instead, provide a valid state so a getter can return correct value.
Pinia does not return promises in actions by default.
Because of that pay a special attention when using createTestingPinia.
Since it stubs all the actions it does not guarantee that an action would return a promise.
If your component's code is expecting an action to return a promise stub it accordingly.
describe('MyComponent', () => {
let pinia;
beforeEach(() => {
pinia = createTestingPinia();
useMyStore().someAsyncAction.mockResolvedValue(); // this now returns a promise
});
});