How to split store in different files? And the limits of object type inference #1324
Replies: 52 comments 24 replies
-
It would be nice to document how to split the store into multiple files or functions when needed but the problem has always been mentioned without code samples. It is crucial that real-world examples are provided to better understand what kind of splitting is needed. So far I identified these possibilities:
|
Beta Was this translation helpful? Give feedback.
-
Regarding the class types, that's a completely different paradigm and, like vuex-smart-module, it should exist in a separate package. I don't use vuex-smart-module but you seem to like it very much, you should write a similar plugin for pinia! |
Beta Was this translation helpful? Give feedback.
-
This is a considerably prevalent design pattern (using Vuex 4 currently) for my team, and we'd like to continue using it.
@posva Have you had the chance to try any of these options you've listed? I'm just curious if anyone has figured out a clean way to implement this before I deep dive this weekend. I'd be happy to (attempt to) contribute if supporting this pattern is indeed a direction you'd like to go in with Pinia. In any case, I'm super impressed with Pinia and look forward to adopting it across our projects over the next year! |
Beta Was this translation helpful? Give feedback.
-
Maybe I'll put something, that will be usefull to this discussion. So far I came across with this example that is working well for me with pinia store.
And according to this, I provided a real-world example. I have some common CRUD getters and actions that I want to use in multiple stores.
import axios from 'axios';
export interface CrudState {
loading: boolean;
items: any[];
apiUrl: string;
}
export function crudState(apiCollectionUrl: string) {
return (): CrudState => ({
loading: false,
items: [],
apiUrl: `http://localhost:8080/api/${apiCollectionUrl}`,
});
}
export function crudGetters() {
return {
allItems: (state:CrudState) => state.items,
getById():any {
return (id) => this.allItems.find((x) => x.id === id);
},
};
}
export function crudActions() {
return {
async initStore() {
this.loading = true;
const { data } = await axios.get(this.apiUrl);
this.items = data;
this.loading = false;
},
async clearAndInitStore() {
this.items = [];
await this.initStore();
},
};
}
import { defineStore } from 'pinia';
import { crudState, crudGetters, crudActions } from './baseCrud';
export default defineStore('tasks', {
state: crudState('tasks/'),
getters: {
...crudGetters(),
// Your additional getters
activeTasks: (state) => state.items.filter((task) => !task.done),
allItemsCount(): number {
return this.allItems.length;
},
},
actions: {
...crudActions(),
// Your additional actions
},
}); using import useTaskStore from './taskStore';
// Autocompletion works fine here
const taskStore = useTaskStore();
taskStore.initStore();
const count = taskStore.allItemsCount;
const task = taskStore.getById(1); |
Beta Was this translation helpful? Give feedback.
-
This was added to #829 |
Beta Was this translation helpful? Give feedback.
-
Just a followup on this. The example provided by @rzym-on may run, but it fails under strict type checking. After a massive amount of sleuthing into the pinia types, I finally figured out how to split up a store into multiple files and preserve complete type checking. Here we go! First you need to add this utility somewhere in your project. Let's assume it's in
import type {
PiniaCustomStateProperties,
StoreActions,
StoreGeneric,
StoreGetters,
StoreState
} from 'pinia'
import type { ToRefs } from 'vue'
import { isReactive, isRef, toRaw, toRef } from 'vue'
type Extracted<SS> = ToRefs<
StoreState<SS> & StoreGetters<SS> & PiniaCustomStateProperties<StoreState<SS>>
> &
StoreActions<SS>
/**
* Creates an object of references with all the state, getters, actions
* and plugin-added state properties of the store.
*
* @param store - store to extract the refs from
*/
export function extractStore<SS extends StoreGeneric>(store: SS): Extracted<SS> {
const rawStore = toRaw(store)
const refs: Record<string, unknown> = {}
for (const [key, value] of Object.entries(rawStore)) {
if (isRef(value) || isReactive(value)) {
refs[key] = toRef(store, key)
} else if (typeof value === 'function') {
refs[key] = value
}
}
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return refs as Extracted<SS>
} Now create a directory for the store. Let's assume it's
export interface State {
foo: string
bar: number
}
export const useState = defineStore({
id: 'repo.state',
state: (): State => {
return {
foo: 'bar',
bar: 7
}
}
})
import { computed } from 'vue'
import { useState } from './state'
export const useGetters = defineStore('repo.getters', () => {
const state = useState()
const foobar = computed((): string => {
return `foo-${state.foo}`
})
const doubleBar = computed((): string => {
return state.bar * 2
})
return {
foobar,
doubleBar
}
})
import { useState } from './state'
export const useActions = defineStore('repo.actions', () => {
const state = useState()
function alertFoo(): void {
alert(state.foo)
}
function incrementBar(amount = 1) {
state.bar += amount
}
// Note you are free to define as many internal functions as you want.
// You only expose the functions that are returned.
return {
alerttFoo,
incrementBar
}
}) Now you can bring all of the pieces together like this: import { extractStore } from '@store/extractStore'
import { defineStore } from 'pinia'
import { useActions } from './actions'
import { useGetters } from './getters'
import { useState } from './state'
export const useFooStore = defineStore('foo', () => {
return {
...extractStore(useState()),
...extractStore(useGetters()),
...extractStore(useActions())
}
}) Boom! Completely typed and nicely factored. You would then import it like this: import { useFooStore } from '@store/foo'
const store = useFooStore()
let foo = store.foo // 'bar'
foo = store.foobar // 'foo-bar'
let bar = store.doubleBar // 14
store.incrementBar(3) // store.bar === 10
bar = store.doubleBar // 20 Enjoy! 😁 |
Beta Was this translation helpful? Give feedback.
-
@aparajita thank you! but how to access getters inside of actions? |
Beta Was this translation helpful? Give feedback.
-
@Grawl the getters are just a regular store with access to your state.
import { useGetters } from './getters'
import { useState } from ['./state']()
export const useActions = defineStore('repo.actions', () => {
const state = useState()
const getters = useGetters()
function alertFoo(): void {
alert(getters.doubleBar)
}
} |
Beta Was this translation helpful? Give feedback.
-
@aparajita It sounds like an anti-pattern to define different stores for state, actions and getters. |
Beta Was this translation helpful? Give feedback.
-
Yes @mahmoudsaeed, I think the same it is really hacky and a bad pattern, hope there will be a real fix. |
Beta Was this translation helpful? Give feedback.
-
@mahmoudsaeed Thanks for stating the obvious. Feel free to find a more elegant workaround. |
Beta Was this translation helpful? Give feedback.
-
@mahmoudsaeed and @nicolidin, I should add that I was actually following @posva's earlier suggestion to split up a store into smaller stores, so if it's an anti-pattern you can blame him! I was unable to get his other suggestions to work with strict type checking — it requires some serious TypeScript ninja skills because of the way pinia infers types. Obviously it would be nice if we had an easier way to split up stores. |
Beta Was this translation helpful? Give feedback.
-
@aparajita I think @posva meant to say splitting up a store into smaller single-file stores. |
Beta Was this translation helpful? Give feedback.
-
As I said before, if you can figure out a way to split up a store that is completely type safe and retains type inference, please do. Since you have the time to criticize others, maybe you would be kind enough to spend the time and effort to come up with a better solution. |
Beta Was this translation helpful? Give feedback.
-
@aparajita Please don't take it personally. I appreciate your solution. |
Beta Was this translation helpful? Give feedback.
-
@diadal I tried it this afternoon, but it looks like we are defining more stores, everything is fine, but looking at vue devtools it's weird :D, anyway thank you very much |
Beta Was this translation helpful? Give feedback.
-
you need to clean up. the code to your need also creates your
|
Beta Was this translation helpful? Give feedback.
-
I refactored it a bit, here is my suggestion: Folder
|
Beta Was this translation helpful? Give feedback.
-
If someone is interested. I came up with this. Type Definitions:piniaTypes.ts: import { StateTree, Store} from 'pinia'
export type PiniaStateTree = StateTree
export type PiniaGetterTree = Record<string, (...args: any) => any>
export type PiniaActionTree = Record<string, (...args: any) => any>
export type PickState<TStore extends Store> = TStore extends Store<string, infer TState, PiniaGetterTree, PiniaActionTree> ? TState : PiniaStateTree
export type PickActions<TStore extends Store> = TStore extends Store<string, PiniaStateTree, PiniaGetterTree, infer TActions> ? TActions : never
export type PickGetters<TStore extends Store> = TStore extends Store<string, PiniaStateTree, infer TGetters, PiniaActionTree> ? TGetters : never
export type CompatiblePiniaState<TState> = () => TState
export type CompatiblePiniaGetter<TGetter extends (...args: any) => any, TStore extends Store> = (this: TStore, state: PickState<TStore>) => ReturnType<TGetter>
export type CompatiblePiniaGetters<TGetters extends PiniaGetterTree, TStore extends Store> = {
[Key in keyof TGetters]: CompatiblePiniaGetter<TGetters[Key], CompatibleStore<TStore>>;
}
export type CompatiblePiniaAction<TAction extends (...args: any) => any, TStore extends Store> = (this: TStore, ...args: Parameters<TAction>) => ReturnType<TAction>
export type CompatiblePiniaActions<TActions extends PiniaActionTree, TStore extends Store> = {
[Key in keyof TActions]: CompatiblePiniaAction<TActions[Key], CompatibleStore<TStore>>;
}
export type CompatibleStore<TStore extends Store> = TStore extends Store<string, infer TState, infer TGetters, infer TActions> ? Store<string, TState, TGetters, TActions> : never
export type PiniaState<TStore extends Store> = CompatiblePiniaState<PickState<TStore>>;
export type PiniaGetters<TStore extends Store> = CompatiblePiniaGetters<PickGetters<TStore>, TStore>;
export type PiniaActions<TStore extends Store> = CompatiblePiniaActions<PickActions<TStore>, TStore>;
export type PiniaStore<TStore extends Store> = {
state: PiniaState<TStore>,
getters: PiniaGetters<TStore>,
actions: PiniaActions<TStore>
} Counter Store:actions.ts: import { PiniaActions, PiniaActionTree } from '@common/piniaTypes';
import { CounterStore } from '@common/counter';
export interface CounterActions extends PiniaActionTree {
increment(amount: number): void;
}
export const actions: PiniaActions<CounterStore> = {
increment(amount) {
this.count += amount;
},
}; getters.ts import { PiniaGetters, PiniaGetterTree } from '@common/piniaTypes';
import { CounterStore } from '@common/counter';
export interface CounterGetters extends PiniaGetterTree {
doubleCount(): number;
offsetCount(): (offset: number) => number;
offsetDoubleCount(): (offset: number) => number;
}
export const getters: PiniaGetters<CounterStore> = {
doubleCount(state) {
return state.count * 2;
},
offsetCount(state) {
return (amount) => state.count + amount;
},
offsetDoubleCount() {
return (amount) => this.doubleCount + amount;
},
}; state.ts import { PiniaState, PiniaStateTree } from '@common/piniaTypes';
import { CounterStore } from '@common/counter';
export interface CounterState extends PiniaStateTree {
count: number
}
export const state : PiniaState<CounterStore> = () => ({ count: 0 }) import { defineStore, Store, StoreDefinition } from 'pinia'
import { CounterState, state} from '@common/counter/state'
import { CounterActions, actions } from '@common/counter/actions'
import { CounterGetters, getters } from '@common/counter/getters'
export type CounterStore = Store<'counter', CounterState, CounterGetters, CounterActions>
export type CounterStoreDefinition = StoreDefinition<'counter', CounterState, CounterGetters, CounterActions>
export const useStore: CounterStoreDefinition = defineStore('counter', { state, getters, actions }) Inherited Counter StoreextendedCounter.ts import { defineStore, Store, StoreDefinition } from 'pinia';
import { PiniaStore } from '@common/piniaTypes';
import { CounterState, state } from '@common/counter/state';
import { CounterGetters, getters } from '@common/counter/getters';
import { CounterActions, actions } from '@common/counter/actions';
interface ExtendedCounterState extends CounterState {
currencySymbol: string;
}
interface ExtendedCounterGetters extends CounterGetters {
countWithCurrency(): string;
}
type ExtendedCounterStore = Store<'extendedCounterStore', ExtendedCounterState, ExtendedCounterGetters, CounterActions>;
type ExtendedCounterStoreDefinition = StoreDefinition<'extendedCounterStore', ExtendedCounterState, ExtendedCounterGetters, CounterActions>;
const extendedStore: PiniaStore<ExtendedCounterStore> = {
state: () => ({ ...state(), currencySymbol: '$' }),
getters: {
...getters,
countWithCurrency(state) {
return `${state.count} ${state.currencySymbol}`;
},
},
actions: {
...actions,
},
};
export const useExtendedCounter: ExtendedCounterStoreDefinition = defineStore('extendedCounterStore', extendedStore); Have fun :) |
Beta Was this translation helpful? Give feedback.
-
Hi everyone! import {IPiniaExtractProperties} from "pinia-extract";
declare module "pinia" {
export interface PiniaCustomProperties extends IPiniaExtractProperties {}
} Plugin adds two methods to each store instance: const store = postponed(useSomeStore);
export const requestGetCar = store.defineAction(
async function (id: string) {
const car = await fetch(`/api/cars/${id}`);
this.car = await car.json();
}
);
export const getCar = store.defineGetter(
(state) => state.car,
); Externally defined actions can be used as independent functions, for using getters there is a special API: import {useGetter} from "pinia-extract";
import {getCar, requestCar} from "./store";
const car = useGetter(getCar);
await requestGetCar();
export const getCustomerCar = defineGetter(
getCustomerJobTitle, // first getter
getCustomerName, // second getter
getCarModel, // third getter
getCarType, // fourth getter
(
jobTitle: string, // first getter return value
name: string, // second getter return value
model: string, // third getter return value
carType: string, // fourth getter return value
// ...instead of whole state
): string => `${jobTitle} ${name} drives ${model} ${type}`;
); There are some more features in addition — I tried to describe most of things in readme. Thanks. |
Beta Was this translation helpful? Give feedback.
-
@aparajita I like your solution, does your solution work with mapState & mapActions ? |
Beta Was this translation helpful? Give feedback.
-
Haven't tried. I don't see why not, it is easy enough to do a simple test yourself. |
Beta Was this translation helpful? Give feedback.
-
hi I am newbie dont know too much stuff but i made it work like this export const useNewUser = defineStore('NewUser', { state, export const actions= { async getU() {
}, } |
Beta Was this translation helpful? Give feedback.
-
@harriskhalil We have all tried something that simple, and if it worked believe me we would use it. The first example at the top of this thread is exactly the same as what you propose. If you are using strict type checking it fails, at least with complex state. |
Beta Was this translation helpful? Give feedback.
-
Hi, I though I will also share my solution :) I think the best way to manage your store is to create a separate file for each action. /// Store.ts
import { Store } from 'pinia'
import Actions from './Actions'
import Getters from './Getters'
import State from './State'
export type MyStore = Store<'store-name', State, Getters, Actions> State is the easiest to define I think :) /// State.ts
import { StateTree } from 'pinia'
export default interface State extends StateTree {
val1: string | null;
val2: number;
....
} Actions are unfortunately pretty chunky. /// Actions.ts
import { _ActionsTree } from 'pinia'
import { MyStore } from './Store'
export enum ActionTypes {
ACTION1 = 'action1',
ACTION2 = 'action2'
}
type ActionWithStore<T> = T extends (...params: infer U) => infer R
? (this: MyStore, ...params: U) => R
: never;
type action1 = (val1: string) => void
type action2 = (val2: number) => void
export default interface Actions extends _ActionsTree {
[ActionTypes.ACTION1]: ActionWithStore<action1>;
[ActionTypes.ACTION2]: ActionWithStore<action2>;
} Getters are defined in similar way /// Getters.ts
import { _GettersTree } from 'pinia'
import { UnwrapRef } from 'vue'
import MyState from './State'
type GetterWithState<T> = T extends infer R
? (state: UnwrapRef<MyState>) => R
: never;
export enum GetterTypes {
GET1 = 'get1'
}
type Get1 = (val: string) => string | false
export default interface MyGetters extends _GettersTree<MyState> {
[GetterTypes.GET1]: GetterWithState<Get1>
} Thanks to these types, we can now easily use actions and getters, that are defined in separate files. /// actions/action1.ts
import Actions from '@/types/store/my-store/Actions'
const action1: Actions['action1'] = function(... args) {
...
}
export default action1 /// getters/getter1.ts
import Getters from '@/types/store/my-store/Getters'
const get1: Getters['get1'] = (state) => (...args) => {
...
}
export default get1 Then you can create an index file in actions and getters folders, for ease import: /// actions/index.ts
import Actions from '@/types/store/my-store/Actions'
import Action1 from './action1'
const actions: Actions = {
Action1
}
export default actions And that's it. I hope it will be useful :) |
Beta Was this translation helpful? Give feedback.
-
Hi, the discussion is a bit confusing. Is there currently a working (or best) solution? Thanks! :) |
Beta Was this translation helpful? Give feedback.
-
It would be helpful if a solution was offered in the official docs. |
Beta Was this translation helpful? Give feedback.
-
Hello. I will share my solution to this problem, in case anyone finds it helpful. This is taken from a simple to-do application I am currently working on. Defining the structure of the To-Do object:
|
Beta Was this translation helpful? Give feedback.
-
How about using /// other module
const useUserModule = (phoneRef: Ref<string | undefined>) => {
const formatPhone = computed(() => {
if (phoneRef.value === undefined) return undefined
return `${phoneRef.value.slice(0, 3)}****${phoneRef.value.slice(-4)}`
})
return { formatPhone }
}
/// action module
const useActions = (phoneRef: Ref<string | undefined>) => {
const updatePhone = (phone?: string) => {
phoneRef.value = phone
}
return { updatePhone }
}
/// main store
const useUserStore = defineStore('user', () => {
const userPhoneNoRef = ref<string>()
/// use modules
const userModule = useUserModule(userPhoneNoRef)
const actionModule = useActions(userPhoneNoRef)
return {
...userModule,
...actionModule,
}
})
/// example
const userStore = useUserStore()
userStore.updatePhone('13011112222')
console.log(userStore.formatPhone) |
Beta Was this translation helpful? Give feedback.
-
So I guess going by the fact that we have a gazillion comments here I assume there is no real solution and this is just yet another indicator that being type-safe is somewhat of an afterthought with Vue. :/ |
Beta Was this translation helpful? Give feedback.
-
Hello,
I would simply like to split a store in different files and especially to make my different store parts (state, getter, actions) able to inherit from interface, abstract classes, etc..
Furthermore, splitting parts of a Pinia store seems to break the
this
usage.As Pinia seems to not really be typed (cause it is only based on object type inference), we just can't split the different parts of a store.
I really wonder why Pinia didn't do like vuex-smart-module allowing to have strong typing through classes usage and to use directly the type of each parts of a store without creating redundant Interface types.
People easily say, "oh classes usage like in vuex-smart-module is more verbose cause you have to specify generics", but when you have to use State, Getter or Action TYPE, you clearly don't want to write/specify n interfaces with k methods for each part a store.
It is common for a lot of projects with strong architecture to have for requirement the need of an abstraction between different parts of different stores.
Example two instances of store like:
ProjectResourceStore
andHomeResourceStore
having each one Action inheriting from the same abstract classAResourceActions
.What is really useful is that
AResourceActions
allows having default implementations and specifying a "contract".ProjectResourceAction
andHomeResourceAction
will be obligated to implement all non-implemented AResourceActions methods.What I say above seems to be clearly impossible with Pinia as it has only object type inference.
It's also important to understand that with classes if I want to have a Type of a store part (State, Getter, Action), I don't have to manually specify all the properties/methods in a redundant Interface that will be used as a way to have non-infered type.
Indeed, with classes, I can directly use the class as a Type.
This problem has already been mentioned here: #343, but my issue just try to expose the problem with other related issues and limitations of Pinia type system.
Also, no satisfying code example in the original issue has been provided.
Beta Was this translation helpful? Give feedback.
All reactions