Generic Stores
In large projects, you might have a lot of stores that are very similar. With generic stores, you only need to define the common parts once, and then reuse them in other stores.
Simple generic store
Just like when splitting stores, we need to define a type for the generic store.
The only difference is that we can add a generic parameter to our store's type.
type BaseStore<T> = PiniaStore<
'base',
{
current: T | null
all: T[]
},
{
getLength: () => number
getName: () => string | undefined
},
{
add: (item: T) => void
}
>
defineGenericStore()
function
A generic store needs to be a generic function, which returns a defineGenericStore()
call. We define T
in our function, and then pass it to the defineGenericStore()
function.
Also, you might need a type constraint on T
using extends
. This is when you want to access a property T
might have (in this case name
) in the generic store. This way the generic store will work with any type that has a name
property.
You can also use T extends Category
.
// const baseStore = <T>() => { // or
function baseStore<T extends { name: string }>() {
return defineGenericStore<BaseStore<T>>({
state: {
current: null,
all: [],
},
getters: {
getLength() {
return this.all.length
},
getName() {
// without the type constraint, this would throw an error:
// Property 'name' does not exist on type 'NonNullable<T>'.ts(2339)
return this.current?.name
},
},
actions: {
add(item: T) {
this.all.push(item)
},
},
})
}
Using the generic store
Let's say we have a type that's used throughout our project (this will go in the place of T
):
interface Category {
id: number
name: string
}
We define a store type that uses the generic store, and pass Category
as the generic parameter.
type CategoryStore = PiniaStore<'category', object, object, object, BaseStore<Category>>
useStore()
function
The useStore()
function is a wrapper around the defineStore()
function, with the additional generic store parameter. It returns a store with the combined state, getters and actions.
Here we add default values to the state defined in the generic store.
export const useCategoryStore = useStore<CategoryStore, BaseStore<Category>>(
'category',
{
state: {
current: { id: 1, name: 'Laptops' },
all: [
{ id: 1, name: 'Laptops' },
{ id: 2, name: 'Phones' },
],
},
},
baseStore<Category>(),
)
NOTE
You need to pass your store as the first generic parameter, and the generic store as the second, with the T
type substituted.
Result:
Extending the generic store
The store that extends the generic store can add some properties that are specific to the store, but still use the generic store's properties.
type CategoryStore = PiniaStore<
'category',
{
description: string
},
{
getMaxId(): number
},
{},
BaseStore<Category>
>
Let's go ahead and split the state and getters just because we can.
const state = createState<CategoryStore, BaseStore<Category>>({
description: '',
})
const getters = createGetters<CategoryStore, BaseStore<Category>>({
getMaxId() {
return this.all.reduce((max, item) => Math.max(max, item.id), 0)
},
})
And pass them to the useStore()
function.
export const useCategoryStore = useStore<CategoryStore, BaseStore<Category>>(
'category',
{
state,
getters,
},
baseStore<Category>(),
)
We can also just define the store inline, if there's no need to split it up.
export const useCategoryStore = useStore<CategoryStore, BaseStore<Category>>(
'category',
{
state: {
description: '',
},
getters: {
getMaxId() {
return this.all.reduce((max, item) => Math.max(max, item.id), 0)
},
},
},
baseStore<Category>(),
)
Using more types
Some things might change when you have more types you want to use.
Let's create a Todo
type with a remove
action. And as we don't want Categories to be removed, we don't define that in the generic store.
interface Todo {
id: number
name: string
done: boolean
}
type TodoStore = PiniaStore<
'todo',
object,
object,
{
remove: (id: number) => void
},
BaseStore<Todo>
>
export const useTodoStore = useStore<TodoStore, BaseStore<Todo>>(
'todo',
{
actions: {
remove(id: number) {
this.all = this.all.filter(item => item.id !== id)
},
},
},
baseStore<Todo>(),
)
Here the Todo
type has a name
property, so the getName()
getter will work. However, we will need a type guard to access the done
property, so let's move baseStore()
's type constraints to a separate type.
interface BaseStoreType {
name: string
done?: boolean
}
function baseStore<T extends { name: string }>() {}
function baseStore<T extends BaseStoreType>() {
return defineGenericStore<BaseStore<T>>({
state: {
current: null,
all: [],
},
getters: {
getLength() {
return this.all.length
},
getName() {
return this.current?.name
},
isDone() {
return this.current?.done
},
},
actions: {
add(item: T) {
this.all.push(item)
},
},
})
}
Don't forget to add isDone(): boolean | undefined
to the generic store type's getters.
And you probably want to extend your types with the BaseStoreType
too.
interface Todo extends BaseStoreType {
id: number
name: string
done: boolean
}
// ... Category as well
Overriding the generic store
The generic store's properties are passed as optionals when defining the store. This means that we can override them, but they are not required (that would be undesirable).
This allows you to add a default value to the generic store's state.
export const useTodoStore = useStore<TodoStore, BaseStore<Todo>>(
'todo',
{
state: {
all: [
{ id: 1, name: 'First Todo', done: false },
],
},
actions: {
remove(id: number) {
this.all = this.all.filter(item => item.id !== id)
},
},
},
baseStore<Todo>(),
)
Or to disable a getter or an action.
export const useTodoStore = useStore<TodoStore, BaseStore<Todo>>(
'todo',
{
getters: {
getName: undefined, // getName no longer shows up in the store
},
actions: {
remove(id: number) {
this.all = this.all.filter(item => item.id !== id)
},
},
},
baseStore<Todo>(),
)
That's it!
Every store - including the generic store - can be split up into state, getters and actions to be defined in separate files (and potentially reused as modules). See the Examples for a comparison.