How to Initialize Pinia Store State with Single-SPA Props & Single-SPA Vue
Context
We're developing a platform using Single-SPA as the micro-frontend framework. This platform integrates multiple applications, and it's common to share state between the App Shell (root-config) and these applications since it enhances re-usability and maintains clean code.
Problem
However, we encountered the challenge of sharing state between the App Shell (root-config) and a top-level Single-SPA Vue application (Header app) utilizing Pinia for state management.
A typical example is countries
. Being a food delivery platform operating in numerous countries, many of our
applications require access to country information. We initially fetch this "country" data from the
backend/authorization during the App Shell initialization and aim to
share it with top-level apps.
We identified two main methods to pass this data from the App Shell to the Header app:
-
Using
window.xxx
as a global variable to pass props; -
Utilizing Single-SPA Props to pass props to the application.
Here's an example using the global variable window._$jetms.availableCountries
within the store definition:
// Original store code snippet
export const useCountryStore = defineStore("country", () => {
const countries = ref<Country[]>([...window._$jetms.availableCountries].sort((a, b) => a.name.localeCompare(b.name)));
const activeCountry = ref<Country>(JETMS.activeCountry);
return {countries, activeCountry};
});
However, I decided against using window.xxx
due to its lack of type safety and instead opted for the more reliable
Single-SPA Props. Here's how I configured it in root-config
and read it in the
Header/main.ts.
const singleSpaGlobalProps = {
...app,
// customProps is the key to pass props to the application
customProps: {
...legacyFrameIntegration,
countries,
},
}
import {createApp, h} from 'vue';
import singleSpaVue from 'single-spa-vue';
import App from './App.vue';
import {createPinia} from "pinia";
const pinia = createPinia();
const vueLifecycles = singleSpaVue({
createApp,
appOptions: {
render() {
return h(App, {
// ...other props
countries: this.countries,
});
},
},
handleInstance(app: any) {
app.use(pinia)
}
});
The challenge then became initializing the Pinia store state with these custom props from Single-SPA.
Initial Attempt
Initially, I attempted to initialize the state with a high-order function useCountryStoreWithDefault
, accepting "
countries" as a parameter. This method
ensured a single store instance but required repetitive calls in every component using the store, leading to
inefficiency and somewhat hacky.
// High-order function definition
export const useCountryStoreWithDefault = (cs: Country[]) => defineStore("country", () => {
const countries = ref<Country[]>(cs.sort((a, b) => a.name.localeCompare(b.name)));
const activeCountry = ref<Country>(JETMS.activeCountry);
return {countries, activeCountry};
});
<script setup lang="ts">
import {defineProps} from "vue";
import {useCountryStoreWithDefault} from "../stores/CountryStore";
const props = defineProps<{
countries: Country[];
}>();
// TODO: extending country store with props.countries is a hack, need to find a better solution
const countryStore = useCountryStoreWithDefault(props.countries)();
</script>
App.vue
setup:
<script setup lang="ts">
import {defineProps} from "vue";
interface Props {
menuItems: { [path: string]: MenuItem };
activeLegacyMenuId: string;
countries: Country[];
}
const props = defineProps<Props>();
</script>
<template>
<header class="font-bold relative z-10 bg-white flex items-center shadow-md">
<LogoIcon :countries="props.countries" class="my-[12px] mx-[24px]"/>
<NavigationBar
:menu-items="props.menuItems"
:active-legacy-menu-id="props.activeLegacyMenuId"
/>
<Controls :countries="props.countries"/>
</header>
</template>
This approach, however, had several drawbacks:
- Creation Complexity: It changed the store's creation process.
- Usage Redundancy: Modifying every component using the store was repetitive and not in line with DRY principles.
- Exhaustion: Propagating props layer by layer was cumbersome, especially for deeply nested components.
Refined Solution
I then sought a better solution that involved setting the store's initial state directly after app creation, I suddenly realized the problem resembled handling state in SSR -- the way that Single-SPA framework provides data from the App Shell to the application is just like an SSR process.
After consulting the Pinia SSR and SingleSpaVue documentation, and after several attempts, I discovered a solution.
Pinia SSR documentation, thankfully, provided a method to initialize the Pinia store state:
pinia.state.value = initialState || {}
My Pinia store's state was defined as follows, with states default values set to be empty:
export const useCountryStore = defineStore("country", () => {
const countries = ref<Country[]>([]);
const activeCountry = ref<Country>(null);
// ...
return {
countries,
activeCountry,
};
});
Then, utilizing the SingleSpa Vue documentation, I accessed the custom props we pass to the app
in the handleInstance(app, props)
function. Actually, we also use pinia
here app.use(pinia)
, so this was the ideal
place to initialize the store state.
So after combining these two approaches, I applied this method in the handleInstance
function of the SingleSpaVue.
import {Country, JETMS} from "@jet/jetms-client-sdk";
import {createPinia} from "pinia";
import singleSpaVue from "single-spa-vue";
import {createApp, h} from "vue";
import App from "./App.vue";
const vueLifecycles = singleSpaVue({
createApp,
appOptions: {
render() {
return h(App, {
// custom props
});
},
},
handleInstance(app, props: { countries: Country[] }) {
const pinia = createPinia();
app.use(pinia);
// All states in a store need initial values.
pinia.state.value = {
country: {
activeCountry: SDK.activeCountry,
countries: props.countries.sort((a, b) => a.name.localeCompare(b.name)),
}
};
},
});
After launching the application, we can see that Pinia successfully initialized the store state with the custom props passed. (TODO: add screenshot) This solution seamlessly merged the initial state with the default state without altering the standard store creation and usage methods.
Key Takeaways:
- Initialization: Setting the initial state in the
handleInstance
function afterapp.use(pinia)
is crucial. - Completeness: If we set initial state using
pinia.state.value
, all other initial state setting in the store definition will be ignored. It means if no other setting function is called, these state will beundefined
. - Client-Side Hydration: Ensure to hydrate Pinia's state before any
useStore()
function call on the client side.
Conclusion
This streamlined approach effectively initializes the Pinia store state with custom props from Single-SPA, ensuring efficiency and code cleanliness. I hope this article can help you if you encounter a similar problem.
References
-
An alternative method found on GitHub issue:
export default function makeSeparatedStore<
T extends (storeKey: string, props: any) => any,
K extends T extends (storeKey: string) => infer StoreDef ? StoreDef : never,
>(defineStore: T) {
const definedStores = new Map<string, K>();
return (
storeKey: string,
props: any = {},
): K => {
if (!definedStores.has(storeKey)) {
definedStores.set(storeKey, defineStore(storeKey, props));
}
// @ts-expect-error
return definedStores.get(storeKey)();
};
}
export const useCountryStore = makeSeparatedStore(
(key: string, props: any) => defineStore(key, () => {
const countries = ref<Country[]>(props.countries.sort((a, b) => a.name.localeCompare(b.name)));
const activeCountry = ref<Country>(JETMS.activeCountry);
return {countries, activeCountry};
})
)