I have a project that utilizes Vue 3 (w/ Composition API), Pinia, Typescript, and SignalR. I'm running into an issue with calling a class instance method from within a pinia store action.
This is the problem line: currentObject.update(updatedObject)
in ObjectStore.ts towards the bottom.
That line of code can usually run once without issue, but if it is rerun, I get this error: Uncaught (in promise) TypeError: currentObject.update is not a function
.
I know it is because currentObject
is no longer of type ObjectTracking
because when I use currentObject instance of ObjectTracking
, it returns false. However, the instances within allObjectTracking
return true. I'm not sure where/how it would be "stripping" the class from currentObject
. My only guess is that Pinia strips classes from the properties when managing state. This is just a guess, but I'm hoping to get a more definitive answer and explanation.
I know I could achieve the same functionality by moving the update logic into ObjectStore
, but I feel like utilizing class instance methods is one of the benefits of using TypeScript. Is there a way to make this structure work, or should I always assume objects/properties used in a Pinia store are not instances of a class?
ObjectStore.ts
import { defineStore } from 'pinia'
import { getAllObjectTracking } from '@/api/services/objectTracking'
import ObjectTracking from '@/types/ObjectTracking'
export const useObjectTrackingStore = defineStore('ObjectTrackingStore', {
state: () => {
return {
allObjectTracking: [] as ObjectTracking[],
isInitialized: false,
}
},
actions: {
async initializeStore() {
try {
this.getObjectTracking()
this.isInitialized = true
} catch (error) {
console.error(error)
}
},
async getObjectTracking() {
try {
this.allObjectTracking = await getAllObjectTracking()
console.log(this.allObjectTracking[0] instanceof ObjectTracking) // Always true
} catch (error) {
console.error(error)
}
},
async updateObjectTracking(updatedObject: ObjectTracking) {
const currentObject = this.allObjectTracking.find((l) => l.id === updatedObject.id)
if (currentObject !== null && currentObject !== undefined) {
currentObject.update(updatedObject)
} else {
this.allObjectTracking.push(updatedObject)
}
},
},
/// getters
})
ObjectTracking.ts
import { Guid } from 'guid-typescript'
export interface IObjectTracking {
id: string
}
export default class ObjectTracking implements IObjectTracking {
id: string = Guid.createEmpty().toString()
constructor(instanceObject: ObjectTracking) {
this.update(instanceObject)
}
async update(updatedObject: ObjectTracking): Promise<void> {
this.id = updatedObject.id
}
}
objectTracking.ts
import apiClient from '../apiClient'
import ObjectTracking from '@/types/ObjectTracking'
const objectTrackingPath = { ** api path ** }
export async function getAllObjectTracking(): Promise<ObjectTracking[]> {
try {
const response = await apiClient.get<ObjectTracking[]>(objectTrackingPath)
if (response === null || response.data === null) {
return [] as ObjectTracking[]
} else {
const objectList = [] as ObjectTracking[]
for (const objectInstance in response.data) {
objectList.push(new ObjectTracking(response.data[objectInstance] as ObjectTracking))
}
return objectList
}
} catch (error) {
console.error(error)
return []
}
}
SignalRStore.ts
import { defineStore } from 'pinia'
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import SignalRState from '@/types/SignalRState'
import { ref } from 'vue'
import { useObjectTrackingStore } from './ObjectStore'
const disconnectedClock = ref()
export const useSignalRStore = defineStore('SignalRStore', {
state: () => {
return {
state: { newState: 0, oldState: 0 } as SignalRState,
}
},
actions: {
async startSignalR() {
const signalRConnection = $.hubConnection({ ** Connection URL **})
signalRConnection.reconnecting(() => {
console.log('SignalR Reconnecting...')
})
signalRConnection.reconnected(() => {
console.log('SignalR Reconnected')
})
signalRConnection.disconnected(() => {
console.log('State: ' + JSON.stringify(this.state))
disconnectedClock.value = setInterval(function () {
console.log('Trying to start')
signalRConnection.start({ withCredentials: false })
}, 5000) // Restart connection after 5 seconds.
})
signalRConnection.stateChanged((state: SignalRState) => {
this.state = state
console.log('state changed: ' + JSON.stringify(state))
if (state.newState === 0 || state.newState === 1) {
clearInterval(disconnectedClock.value)
}
})
const objectTrackingStore = useObjectTrackingStore()
const signalRProxy = signalRConnection.createHubProxy('ObjectHub')
signalRProxy.on('updateObjectTracking', objectTrackingStore.updateObjectTracking)
await signalRConnection
.start({ withCredentials: false })
.done(() => {
console.log('Now connected')
})
.fail(() => {
console.log('Could not connect')
})
},
},
/// getters
getters: {
isConnected(): boolean {
return this.state.newState === 1 // 0 connecting, 1 - connected, 2 - reconnecting, 4 - disconnected
},
},
})
There are multiple problems with reactivity in Vue 3 when using it with classes (e.g. this and this) that restrict their usage and design. In cases when making class instances reactive causes performance issues and faulty behaviour, they can opt out from the reactivity, e.g.:
objectList.push(markRaw(new ObjectTracking(...))
This isn't needed in this case. The implementation of ObjectTracking
is safe in terms of wrapping an instance with reactive proxy, so it cannot cause issues.
This is the part that is responsible for the problem:
this.allObjectTracking.push(updatedObject)
This results in allObjectTracking
containing objects that aren't instances of ObjectTracking
, this can be determined by debugging allObjectTracking
value at the time when the error occurs.
It should be:
this.allObjectTracking.push(new ObjectTracking(updatedObject))
At this point the use of ObjectTracking
class makes it more cumbersome than using plain objects, which Vue reactivity primarily was designed to work with. This could be achieved with fewer lines of code by making update
action in Pinia store fully responsible for updating the state. It depends whether getObjectTracking
needs to internally use it for consistency.
Also initializeStore
doesn't contain await
, this can result in problems during init routing.