-
-
Notifications
You must be signed in to change notification settings - Fork 1.1k
This issue was moved to a discussion.
You can continue the conversation there. Go to discussion →
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
How to split store in different files? And the limits of object type inference #802
Comments
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:
|
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! |
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! |
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); |
This was added to #829 |
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! 😁 |
@aparajita thank you! but how to access getters inside of actions? |
@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)
}
} |
@aparajita It sounds like an anti-pattern to define different stores for state, actions and getters. |
Yes @mahmoudsaeed, I think the same it is really hacky and a bad pattern, hope there will be a real fix. |
@mahmoudsaeed Thanks for stating the obvious. Feel free to find a more elegant workaround. |
@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. |
@aparajita I think @posva meant to say splitting up a store into smaller single-file stores. |
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. |
@aparajita Please don't take it personally. I appreciate your solution. |
@mahmoudsaeed I am just as eager as you to have an official solution that is not a hack. |
@aparajita I'm with you. I am thinking of migrating to Pinia but would rather not until there is an official way to break a store into modules and keep good type safety. |
@fgarit-te Then bug @posva to show us how — or make whatever changes are necessary to make it possible. Note that my technique, though a bit of a hack, is quite clean and maintains complete type safety. The benefits of using Pinia are worth it. |
Re: well actually, the following is pretty typesafe. src/stores/useSomeNumberStore.ts import { defineStore, Store } from 'pinia';
/* Defining our store types */
type State = {
someNumber: number;
};
type Getters = {
someNumberPlusOne: number;
someNumberPlusOneTimesTwo: number;
};
type Actions = {
setSomeNumber: (newNumber: number) => Promise<void>;
wrappedSetSomeNumber: (newNumber: number) => Promise<void>;
};
type SomeNumberStore = Store<'someNumber', State, Getters, Actions>;
/* Now defining the store one element at a time */
const getters: PiniaGetterAdaptor<Getters, SomeNumberStore> = {
someNumberPlusOne(state) {
return state.someNumber + 1;
},
someNumberPlusOneTimesTwo() {
return this.someNumberPlusOne * 2;
},
};
const actions: PiniaActionAdaptor<Actions, SomeNumberStore> = {
async setSomeNumber(newNumber: number) {
this.someNumber = newNumber;
},
async wrappedSetSomeNumber(newNumber: number) {
this.setSomeNumber(newNumber);
},
};
export default defineStore('someNumber', {
state: getState,
getters,
actions,
});
function getState(): State {
return {
someNumber: 12,
};
}
/** Some type helpers */
type PiniaActionAdaptor<
Type extends Record<string, (...args: any) => any>,
StoreType extends Store,
> = {
[Key in keyof Type]: (this: StoreType, ...p: Parameters<Type[Key]>) => ReturnType<Type[Key]>;
};
type PiniaGetterAdaptor<GettersType, StoreType extends Store> = {
[Key in keyof GettersType]: (this: StoreType, state: StoreType['$state']) => GettersType[Key];
}; src/App.vue <template>
<div>someNumberPlusOnTimesTwoDividedByThree: {{ someNumberPlusOnTimesTwoDividedByThree }}</div>
<div>
<button @click="setRandomNumber">setRandomNumber</button>
</div>
</template>
<script lang="ts">
import { computed, defineComponent } from 'vue';
import useSomeNumberStore from './stores/useSomeNumberStore';
export default defineComponent({
setup() {
const someNumberStore = useSomeNumberStore();
const someNumberPlusOnTimesTwoDividedByThree = computed(
() => someNumberStore.someNumberPlusOneTimesTwo / 3,
);
return {
someNumberPlusOnTimesTwoDividedByThree,
setRandomNumber,
};
function setRandomNumber() {
someNumberStore.wrappedSetSomeNumber(Math.round(Math.random() * 1000));
}
},
});
</script> src/main.ts import { createApp } from 'vue'
import App from './App.vue'
import { createPinia } from 'pinia'
const app = createApp(App)
app.use(createPinia())
app.mount('#app') |
@fgarit Brilliant! I knew it was just a matter of finding someone who was better at TypeScript than me. 😁 It's almost working for me. I successfully split the state, getters and actions into separate files, but I'm getting an error in
Are you not getting that error? If not, it may be a difference in our tsconfig. Here's the relevant part of mine: {
"compilerOptions": {
"allowSyntheticDefaultImports": true,
"allowUnreachableCode": false,
"allowUnusedLabels": false,
"declaration": false,
"declarationMap": false,
"esModuleInterop": true,
"exactOptionalPropertyTypes": true,
"importsNotUsedAsValues": "error",
"module": "esnext",
"moduleResolution": "node",
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noPropertyAccessFromIndexSignature": false,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": false,
"noUnusedParameters": true,
"pretty": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"target": "esnext",
"types": [
"node"
],
"useDefineForClassFields": true,
"useUnknownInCatchVariables": true,
"lib": ["esnext", "dom"],
} |
@aparajita I tested with your tsconfig and it's fine but judging from the error you get, it's the way your type Actions = {
someActionName: (params: any) => any;
} |
@fgarit Okay, the issue was my @typescript-eslint rules change object type declarations into interfaces. Changing it back to a type removed the Actions error. This is a very interesting behavior of TypeScript; apparently TS requires an interface which extends However, I'm now getting a very deep TS error with my code, which is a much more complex example. I'm not going to ask you to debug it, so unfortunately I'll have to abandon your approach. |
If doesn't exist an official solution for this, why this issue is closed ? I'm trying to port a big frontend application and the first thing is to split the huge state/actions modules and is a disappointment to see a poor doc for the new official state management of Vue community. Coming from other libs and communities, I see Vue (and its libraries) is lacking behind in the documentation. Just recently the docs was revamped for Vue 3. Back to the problem, I have solution to splitting problem, but the typing isn't 100% accurate, because my types like:
|
What solution did you use for typing? I have had the same issue btw with types loosing their original reference. |
That's the actual problem. Splitting is easy, but my types lost their reference. |
@fgarit @aislanmaia If it's the problem I think you're talking about, this is a known problem. For reference: |
In other words I need, for now, to force typing by casting for every state usage? |
Not if you use my technique. |
best solution |
Thanks @diadal. I truly wish there were a better solution, but pinia depends heavily on very complex type inference, and no other solution I have seen so far works with complex stores. |
Thanks for the tips above. Here is how I've separated each actions, getters, state in files while keeping types: Folder
|
hi @alexisjamet |
@tunngtv you can create a plugin call it take note of
|
@diadal ProfilePage.vue
constants.ts file
Folder Store: state.ts file
Folder Store: actions.ts file
Folder Store: index.ts file
i tried creating an actions file outside the store folder, and importing it into SFC vue used to change the state everything seems fine but i don't know how to use actions ? actions.ts
|
you can separate it simple
|
you need this |
yes i know that, and i added it in main.ts |
@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 |
you need to clean up. the code to your need also creates your
|
I refactored it a bit, here is my suggestion: Folder
|
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 :) |
Hi everyone! import {IPiniaExtractProperties, withExtract} from "pinia-extract";
const pinia = withExtract();
declare module "pinia" {
export interface PiniaCustomProperties extends IPiniaExtractProperties {}
} Plugin adds two methods to each store instance: const store = 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} from "./store";
const car = useGetter(getCar);
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. |
@aparajita I like your solution, does your solution work with mapState & mapActions ? |
Haven't tried. I don't see why not, it is easy enough to do a simple test yourself. |
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() {
}, } |
@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. |
This issue was moved to a discussion.
You can continue the conversation there. Go to discussion →
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.
The text was updated successfully, but these errors were encountered: