typescriptvue.jssignalrpinia

Vue / Pinia / Typescript calling class instance method in Pinia store results in error


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
    },
  },
})


Solution

  • 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.