Pinia scoped side


Problem

To date, all state managers in the Vue ecosystem (originally Vuex, later Pinia, which will be used as an example) provide a global centralized repository tied to the application root. And this is great: I think almost every reader has benefited from this approach, whether it’s accessing the store at any level of nesting or reusing data between different blocks of the page. However, the current system has one important limitation – Pinia’s global store modules do not allow you to create independent states for instances one component/module. I will give a few examples.

We at YCLIENTS ran into this problem when we were trying to reuse a large and complex payment module between tabs in the same modal window. Several instances of the module can coexist in the window at the same time, the states of which are stored in the store and must be completely independent of each other, which is contrary to the concept of a single global storage from Pinia.

Facing a similar problem Pinia users on GitHub:

There are cases when it is necessary to display several datagrids on one page (for example, comparing different data sets). Thus, the states of the component trees of each datagrid must be independent of each other.

It is also worth noting that there are no uniform rules for working with stores in frontend development. They store completely different types and volumes of data. I have seen projects where even small reusable components/blocks had their own stores. In general, there are more and more stores – one of the recent trends is the transition from a single monolith to working with many small, specialized stores (Pinia and Effector are a vivid example of this). All this (industry trends and a variety of approaches to working with stores) makes the problem much more relevant.

Community Solutions

In the comments below the discussion, the community (with the help of one of Pinia’s maintainers) suggested several solutions (once And two). However, they are all united by one main feature – the use of a custom identifier to access the store (in the solutions above, this is tableId or listViewId). Without the ID, third party components will not be able to access the desired Pinia module. Therefore, it is necessary to implement a mechanism for storing and passing custom identifiers (because there can be several such stores) to all components using this module, including descendant components. Having solved one problem, we got another.

The library deserves special attention. pinia di. It can solve the problem described above, but the presented approach is much more complicated than the Pinia approach. Most likely, the team will need time to study and implement it. In fact, the authors propose a new syntax for working with the store, which in many respects goes against the main advantages and principles of Pinia: simplicity and accessibility. There seems to be a need for a solution that is more like the original syntax.

My solution

Pinia store linked to a scope (or Pinia scoped side). In this case, the scope (or scope) is the component instance that was the first in the hierarchy to use this store. All child components of this instance get access to the desired store automatically, the scope ID is passed under the hood, the developer does not need to think through this mechanism or change the standard approach to working with stores. When using a module in a parallel hierarchy, a new, independent store is created, which will also be automatically accessed by descendants.

As a result, each hierarchy will use its own separate store (storeModuleName / 3 and storeModuleName / 6 in the picture above), the scope of which is the instance of the initializing component.

This was achieved through two important concepts:

  1. The creation of the store (calling the original defineStore()) occurs at the moment of direct use, which allows you to bind to the scope (component instance)

  2. Provide/inject is used to pass an identifier to scope components. At the same time, the receipt and sending of the identifier occur under the hood, inside the useStore function

Now let’s move on to the implementation. Based on source defineStore function code. The typing is almost completely copied from the original (the Vue core team actively uses as and any, so I did not avoid them either). Explanations for each important step have been added to the commentary:

import {defineStore, Pinia, StoreDefinition, StoreGeneric, getActivePinia} from 'pinia'
import {inject, getCurrentInstance, onUnmounted, ComponentInternalInstance, InjectionKey} from 'vue'

// id и piniaId.
// id - это первый аргумент функции defineScopeYcStore. К примеру, RecordAcquiringPaymentRedesign.
// piniaId - id стора в pinia, содержит в себе идентификтор скоупа. К примеру, RecordAcquiringPaymentRedesign/123124123123123, где 123124123123123 - идентификатор скоупа(в качестве идентификатора скоупа используется uid первого компонента иерархии, в котором использовался стор)
//
// scopedStoresIdsByScope содержит информацию о том, в каких скоупах(scopeId) и какие именно сторы(id и piniaId) создавались.
// Позволяет для данного скоупа(scopeId) получить id и piniaId всех созданных в данном скоупе сторов. Используется для предотвращения повторного создания сторов с одниковым скоупом
type ScopedStoresIds = {[id in string]: string} // {RecordAcquiringPaymentRedesign: 'RecordAcquiringPaymentRedesign/123124123123123', ...}
const scopedStoresIdsByScope: {[scopeId in string]: ScopedStoresIds} = {} // {123123: {RecordAcquiringPaymentRedesign: 'RecordAcquiringPaymentRedesign/123124123123123', ...}}

//  Содержит ссылки на созданные ранее scoped сторы. Ключом является piniaId, значением - стор
const scopedStoresByPiniaId: {[piniaId in string]: ReturnType<typeof defineStore>} = {}

export const defineScopedStore: typeof defineStore = function( // Сигнатуру функции скопировал из сорсов defineStore (https://github.com/vuejs/pinia/blob/v2/packages/pinia/src/store.ts#L852)
  idOrOptions: any,
  setup?: any,
  setupOptions?: any,
): StoreDefinition {
  let id
  let options
  // На основе входящи параметров выделяем id и options. Скопировал из сорсов defineStore
  const isSetupStore = typeof setup === 'function'
  if (typeof idOrOptions === 'string') {
    id = idOrOptions
    options = isSetupStore ? setupOptions : setup
  } else {
    options = idOrOptions
    id = idOrOptions.id
  }

  function useStore(pinia?: Pinia | null | undefined, hot?: StoreGeneric): StoreGeneric {
    const currentInstance = getCurrentInstance()
    if (currentInstance === null) {
      throw new Error('Scoped stores can not be used outside of Vue component')
    }

    const scopeId = currentInstance.uid // Если опасаетесь использовать uid компонента в качестве идентификатора скоупа - можно самостоятельно проставлять всем компонентам уникальный id с помощью простенького плагина(https://github.com/vuejs/vue/issues/5886#issuecomment-308647738) и опираться на него
    let piniaId: string | undefined // Id нужного нам scoped стора в pinia

    // Проверяем, создавался ли ранее нужный нам стор в текущем компоненте или компонентах-предках. Пытаемся получить piniaId scoped стора
    if (scopedStoresIdsByScope?.[scopeId]?.[id]) {
      piniaId = scopedStoresIdsByScope[scopeId][id]
    } else {
      piniaId = inject<string>(id)
    }

    // Если scoped стор уже создан(удалось получить piniaId) - возвращаем его
    if (piniaId && scopedStoresByPiniaId[piniaId]) {
      return scopedStoresByPiniaId[piniaId](pinia, hot)
    }

    // Если выяснилось, что scoped стор еще не создавался(не удалось получить piniaId) - создаем его
    // piniaId = id стора + идентификатор скоупа
    piniaId = `${id}/${scopeId}`

    // Создаем стор и сохраняем на него ссылку в scopedStoresByPiniaId
    if (isSetupStore) {
      scopedStoresByPiniaId[piniaId] = defineStore(piniaId, setup, options)
    } else {
      scopedStoresByPiniaId[piniaId] = defineStore(piniaId, options)
    }

    // Сохраняем piniaId и id стора в scopedStoresIdsByScopeId
    scopedStoresIdsByScope[scopeId] = scopedStoresIdsByScope[scopeId] ?? {}
    scopedStoresIdsByScope[scopeId][id] = piniaId

    // После создания стора провайдим его piniaId всем потомкам. Так они смогут получить к нему доступ
    // Для совместимости с Options API и map-фукнциями пришлось добавить в provide возможность задавать извне инстанс компонента-провайдера. Подробнее ниже
    // Важно! Если работаете только в Composition API - лучше заменить на обычный provide
    provideInInstance(id, piniaId, currentInstance)

    // Удаляем стор при удалении скоупа. Нет скоупа - нет scoped стора
    onUnmounted(() => {
      const pinia = getActivePinia()

      if (!pinia || !piniaId) return

      delete pinia.state.value[piniaId] // Взял из api документации pinia (https://pinia.vuejs.org/api/interfaces/pinia._StoreWithState.html#Methods-$dispose)
      delete scopedStoresByPiniaId[piniaId]
      delete scopedStoresIdsByScope[scopeId]
    }, currentInstance)

    // Возвращаем созданный стор
    return scopedStoresByPiniaId[piniaId](pinia, hot)
  }

  useStore.$id = String(Date.now()) // В scoped сторах id присваивается позже, в момент использования стора. Нужно лишь для типизации

  return useStore
}

// Vue core team убрали provides из общедоступного типа ComponentInternalInstance, пришлось его вернуть. Типизацию скопировал из сорсов ComponentInternalInstance (https://github.com/vuejs/core/blob/98f1934811d8c8774cd01d18fa36ea3ec68a0a54/packages/runtime-core/src/component.ts#L245)
type ComponentInternalInstanceWithProvides = ComponentInternalInstance & {provides?: Record<string, unknown>}

// Пришлось добавить в provide возможность задавать извне инстанс компонента-провайдера. Код практически полностью скопировал из сорсов provide, единственное отличие - currentInstance передается аргументом извне (https://github.com/vuejs/core/blob/98f1934811d8c8774cd01d18fa36ea3ec68a0a54/packages/runtime-core/src/apiInject.ts#L8)
const provideInInstance = <T>(key: InjectionKey<T> | string | number, value: T, instance: ComponentInternalInstanceWithProvides) => {
  let provides = instance.provides!

  const parentProvides =
    instance.parent && (instance.parent as ComponentInternalInstanceWithProvides).provides
  if (parentProvides === provides) {
    provides = instance.provides = Object.create(parentProvides)
  }

  provides[key as string] = value
}

Version without comments

The current solution works in both the Compotition API and the Options API (compatible with mapState, mapWritableState, mapGetters and mapActions). The signature of the defineScopedStore function is exactly the same as the signature of the original defineStore.

Notice the provideInInstance function. If you work only in the Composition API or don’t use map functions, it’s better to replace it with the standard provide.

Learn more about replace provide

The problem is that the currentInstance for provide is set during the call to the setup function, and some map functions (like mapState) are called before by calling setup. As a result, provide does not work in some map functions, because it cannot find currentInstance. I had to pass currentInstance directly

An example from our practice

Let’s consider the use of a scoped store in the YCLIENTS product code using the payment module as an example. The first step is to create the scoped module of the recordPayment store (the syntax and set of options are completely identical to the standard Pinia store):

export const useRecordPaymentStore = defineScopeYcStore('RecordPayment', { // можно использовать любой поддерживаемый Pinia синтаксис 
 state: () => ({
   isPaid: false,
 }),
 actions: {
   setIsPaid(val: boolean) {
     this.isPaid = val
   },
 },
})

Let’s move on to the components. The entry point to the payment module is the VPayment.vue component. It is in it that the scoped recordPayment store is first used and initiated:

export default defineComponent({
 name: 'VPayment',
 setup() {
  …

  return {
   recordPaymentStore: useRecordPaymentStore(),
  }
 },
})

Child components (in this example, the VPaymentLoyaltyMethod.vue component) of the VPayment.vue module access the recordPayment store in the same way as if it were a standard Pinia store:

export default defineComponent({
 name: 'VPaymentLoyaltyMethod',
 setup() {
  ...

  return {
   recordPaymentStore: useRecordPaymentStore(),
  }
 },
})

The payment module itself is used in several tabbed components of one modal window. As a result, in each tab of the modal window, the VPayment module will have its own, independent state, which all components of the module can access automatically.

As you can see from the code, the standard Pinia syntax is used, nothing new. To use scoped stores, the team does not need to change established approaches.

At the same time, stores become more encapsulated and independent, which significantly expands their scope and allows you to cope with the problems described above. The current solution also does not prevent us from using nested stores, everything will work out of the box. However, binding to a scope entails a number of limitations.

Restrictions

  • Scoped stores can only be used inside components or in functions called from components. No component instance – no scope – no store

  • The scope dies (unmount the instance of the component in which the store was first used) – the store also dies

  • For compatibility with the map functions mapState, mapWritableState, mapGetters, and mapActions, we had to use the hidden bean instance API (currentInstance.provides). But it was not possible to achieve compatibility with the mapStores function

Where might it apply?

The main use case is the coexistence on one page of several instances of a component/module with a store, the states of which must be independent. Here are some examples:

  • A large module that is reused between tabs of the same window (an example from the article) or between several tabs within the same page. Any module with a store is suitable, the instances of which must be independent (in our case, this is the payment module)

  • Multiple tables with stores per page (example from GitHub discussion)

  • Filters. For example, if there are several sets of filters and each of them has its own unique state

  • Complex control, the state of which is stored in the store, reused in different frequent pages

Pinia’s approach pushes us to create small and narrow stores, as opposed to massive and feature rich modules from Vuex. In addition, the focus is shifted from their binding to the global context: if earlier you had to initialize the store yourself during the init of the entire application, now this process happens automatically. All this is in perfect agreement with the concept of scoped stores – narrowly focused, local stores tied to a specific module instance.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *