Subscribe / watch from inside a store? #794
-
Hi, is it possible to use a subscribe / watch function inside (!!!) a store? I understand how to use (and in fact do use) subscribe / watch in my components but I don't see a way to offload this behaviour from component to store. |
Beta Was this translation helpful? Give feedback.
Replies: 17 comments 35 replies
-
Use a setup store instead, it's like a component but without a template: defineStore('id', () => {
const userId = ref(0)
// other state properties
// ...
watch(userId, doStuff)
function doStuff() {
// ...
}
// expose anything you need
return { state }
}) |
Beta Was this translation helpful? Give feedback.
-
Hi, I want to follow this Subscribing to the state example to have a global watcher defined inside of a store but defined with the Options API old-fashioned way but I do have an error (tried to import Here is what I got so far export const useLayersStore = defineStore('layers', {
state: () => ({
trackMe: 'please track me',
}),
})
useLayersStore.$subscribe((mutation, state) => {
console.log('tracked!')
})
PS: I thought it wasn't useful to create a new discussion since it is still related to the initial title. |
Beta Was this translation helpful? Give feedback.
-
@posva Is there a way to add a watcher to Option stores? |
Beta Was this translation helpful? Give feedback.
-
How do you subscribe just to one prop in the store changing? Stead of it firing for any property in the object? ie if just the playing changes, what would my export const initialVideoPlayerState = {
open: false,
playing: false,
videoPlayIndex: 0,
videoURIs: [
"./003-Video.mp4",
"./001-Video.mp4",
"./003-Video.mp4",
"./001-Video.mp4",
"./003-Video.mp4",
],
} |
Beta Was this translation helpful? Give feedback.
This comment was marked as spam.
This comment was marked as spam.
-
Guys (@posva @sockenklaus ), sorry for my multiple edits. Regarding the setup store:
Vue DevTools (with Pinia) doesn't list the setup store! |
Beta Was this translation helpful? Give feedback.
This comment has been hidden.
This comment has been hidden.
-
use import { watch } from "vue";
import { storeToRefs } from "pinia";
const { state } = storeToRefs(store);
watch(state, () => doSomeThing()); |
Beta Was this translation helpful? Give feedback.
-
With Options API you can use sth like this in your
|
Beta Was this translation helpful? Give feedback.
-
To watch a single property using the options API, import { defineStore } from 'pinia';
import { watch, toRef } from 'vue';
export const useStore = defineStore({
id: 'id',
state: () => ({
foo: 'bar',
}),
getters: {},
actions: {
onFooChanged() {
console.log('foo changed!');
},
},
});
const store = useStore();
watch(toRef(store, 'foo'), store.onFooChanged); I agree that a UPDATE (caveat): |
Beta Was this translation helpful? Give feedback.
-
a watch as part of the store itself (getters, actions) would be appreciated. the stores can be used in multiple places and the store itself is a single instance it seems adding a $subscribe to every place you use the store isnt a great solution vs having the watch / subscribe on the store itself. the hacky way might be to add a component early and attach the $subscribe but what if that store is never used. a use case for this would be if the state changes to call an action. (ie save to localstorage / do an api lookup etc. am i missing anything obvious on why adding a watch / subscribe on the store itself isnt possible? alternatively something like onMounted for the store could work. (hooks in the store lifecycle) (the earlier suggestion seems to be use the setup store instead. but i haven't been able to figure out how to use that with actions / getters in a sane way, like having a store with a few states and getters and actions) seems hacky
|
Beta Was this translation helpful? Give feedback.
-
I found two ways to watch and init an option store without any component hack. With a Proxy:
With Object.assign:
The truth is that you can wrap the pinia store factory only by adding an $id property to you wrapper function (for now at least). But by using a proxy or the Object.assign, it prevents breaking in case pinia devs choose to add new props.
I think what I did is called a pipe, which is equivalent to the decorator pattern in OOP. |
Beta Was this translation helpful? Give feedback.
-
@sockenklaus - I've got a plugin I just started using that helps solve this problem, although it takes it one step further and allows you to know if the store is actually being used or not. This means you could for example, trigger an API request when the store is starting to be used, watch some value in the store and trigger other api requests when it changes, and stop watching when no one is using the store anymore. For my use case I'm also toggling on/off websocket subscriptions. Code is up here - https://stackblitz.com/edit/pinia-hooks-plugin?file=src%2Fstore.ts,src%2FApp.vue,src%2Fpinia-hooks-plugin.ts Note that the If it seems stable in my own project, I'll probably open source it, but would ideally love for this kind of functionality to be built-into pinia itself... |
Beta Was this translation helpful? Give feedback.
-
This is how I've got it working. I'm waiting until Pinia is initialized and after that we can subscribe to the store.
|
Beta Was this translation helpful? Give feedback.
-
Are there any official plans to implement the ability to watch from within the store? |
Beta Was this translation helpful? Give feedback.
-
@posva What file are you suggesting we do this in? A file under components with a .vue extension or a stores/store.js file? Or something else? I need a little more context to understand how to use this. |
Beta Was this translation helpful? Give feedback.
-
latest version of my It adds an activation hook that will be triggered when a store is used by at least one component and deactivated when it is no longer used by any components. There is also an init hook that is called only the first time it is activated. This is useful for watchers, and also for things like websocket subscriptions. Will hopefully get it cleaned up and released on npm in the next few weeks - will post back here when I do! /* eslint-disable @typescript-eslint/no-explicit-any */
import { PiniaPlugin, PiniaPluginContext } from "pinia";
import {
ComponentInternalInstance,
computed,
getCurrentInstance,
reactive,
} from "vue";
function isPromise(obj: any) {
return !!obj && (typeof obj === 'object' || typeof obj === 'function') && typeof obj.then === 'function';
}
type MaybePromise<T> = T | Promise<T>;
declare module "pinia" {
/* eslint-disable @typescript-eslint/no-unused-vars */
export interface DefineStoreOptionsBase<S, Store> {
// adds our new custom option for activation/deactivation hook
onActivated?: (this: Store) => MaybePromise<void | (() => void)>;
// adds our new custom option for hook that first on first use/activation only
onInit?: (this: Store) => MaybePromise<void>;
}
export interface PiniaCustomStateProperties<S> {
trackStoreUsedByComponent(component: ComponentInternalInstance): void;
}
}
// TODO: couldnt get the typing of T happy here... but it works for consumers
export function addStoreHooks<T extends () => any>(useStoreFn: T) {
return (...args: Parameters<T>): ReturnType<T> => {
const store = useStoreFn.apply(null, [...args]) as ReturnType<T>;
const component = getCurrentInstance();
if (component) store.trackStoreUsedByComponent(component);
return store;
};
}
export const piniaHooksPlugin: PiniaPlugin = ({
// pinia,
// app,
store,
options: storeOptions,
}: PiniaPluginContext) => {
/* eslint-disable no-param-reassign */
// might not need this check, but not sure this plugin code is guaranteed to only be called once
if (store._trackedStoreUsers) return;
store._initHookCalled = false;
// keep a list of all components using this store
store._trackedStoreUsers = reactive<Record<string, boolean>>({});
store._trackedStoreUsersCount = computed(
() => Object.keys(store._trackedStoreUsers).length,
);
// expose this info to devtools
// TODO: determine the best way to safely check in both vite and webpack setups
if (import.meta.env.DEV /* || process.env.NODE_ENV === "development" */) {
store._customProperties.add("_trackedStoreUsers");
store._customProperties.add("_trackedStoreUsersCount");
}
function trackStoreUse(
component: ComponentInternalInstance,
trackedComponentId: string,
) {
// bail if already tracked - which can happen when stores are using each other in getters
if (store._trackedStoreUsers[trackedComponentId]) return;
store._trackedStoreUsers[trackedComponentId] = true;
if (!store._initHookCalled && storeOptions.onInit) {
// TODO: what to do if this errors?
// eslint-disable-next-line @typescript-eslint/no-floating-promises
storeOptions.onInit.call(store);
}
if (store._trackedStoreUsersCount === 1 && storeOptions.onActivated) {
// console.log(`${store.$id} - ACTIVATE`);
// activation fn can return a deactivate / cleanup fn
store._onDeactivated = storeOptions.onActivated.call(store);
// activate could be async, so need to resolve if so...
// TODO may need to think more about this - what if activate errors out?
if (isPromise(store._onDeactivated)) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
store._onDeactivated.then((resolvedOnDeactivate: () => void) => {
store._onDeactivated = resolvedOnDeactivate;
});
}
}
// attach the the unmounted hook here so it only ever gets added once
// (because we bailed above if this component was already tracked)
const componentAny = component as any;
// onBeforeUnmount(() => { store.unmarkStoreUsedByComponent(); });
componentAny.bum = componentAny.bum || [];
componentAny.bum.push(() => {
// console.log(`[${store.$id}] -- ${trackedComponentId} un-used`);
if (!store._trackedStoreUsers[trackedComponentId]) {
throw new Error(
`[${store.$id}] Expected to find component ${trackedComponentId} in list of users`,
);
}
delete store._trackedStoreUsers[trackedComponentId];
if (
store._trackedStoreUsersCount === 0 &&
store._onDeactivated &&
typeof store._onDeactivated === 'function'
) {
// console.log(`${store.$id} - DEACTIVATE`);
store._onDeactivated.call(store);
}
});
}
store.trackStoreUsedByComponent = (component: ComponentInternalInstance) => {
// console.log(
// `[${store.$id}] track use by ${component.type.__name} -- mounted? ${component.isMounted}`,
// component,
// );
// calling lifecycle hooks here (ie beforeMount) causes problems for useStore calls within other store getters/actions
// so we're injecting the hooks directly into the vue component instance
// this is probably inadvisable... but seems to work and I don't believe this will change any time soon
// as an added bonus, this means `watch` calls in our onActivated hook are not bound to the first component that used this store
// so they will not be destroyed on unmount. This requires us to clean up after ourselves, but it is the desired behaviour.
// onBeforeMount(() => { store.markStoreUsedByComponent(); });
const componentIdForUseTracking = `${component.type.__name}/${component.uid}`;
const componentAny = component as any;
// console.log(
// `tracking ${componentIdForUseTracking}`,
// JSON.stringify(store._trackedStoreUsers),
// );
if (component.isMounted) {
trackStoreUse(component, componentIdForUseTracking);
} else {
// note - this can happen multiple times, but we handle this case in `trackStoreUse()`
componentAny.m = componentAny.m || [];
componentAny.m.push(() => {
trackStoreUse(component, componentIdForUseTracking);
});
}
};
}; Use it like this: // load the plugin itself
const pinia = createPinia();
pinia.use(piniaApiToolkitPlugin);
// and then also wrap the defineStore call
export const useCounterStore = addStoreHooks(
defineStore("counter", {
state: () => ({
counter: 20,
}),
getters: {
counterX2: (state) => state.counter * 2,
},
actions: {
increment() {
this.counter++;
},
decrement() {
this.counter--;
},
reset() {
this.counter = 0;
},
},
// called each time the store is activated
// use to watch stuff, activate websocket subscriptions, etc
onActivated() {
console.log("counter store activated");
const cleanupAlertWatch = watch(
() => this.counter,
() => {
if (this.counter === 5) alert("counter = 5!");
},
);
// can return a deactivate function, use to cleanup (unwatch, unsubscribe, etc)
return () => {
console.log("counter store deactivated");
cleanupAlertWatch();
};
},
onInit() {
// called only the first time the store is activated
}
}),
); |
Beta Was this translation helpful? Give feedback.
Use a setup store instead, it's like a component but without a template: