Search code examples
reactjswebsocketreact-reduxredux-toolkitpusher

Memoizing PusherJS subscriptions in a react app


Initially, I wrote a hook, which would connect to a pusher channel then update the internal payload to whatever message is received from pusher.

export const ChannelHook = <T>(
  channelName: string,
  messageType: string,
  skip = false
) => {
  const [payload, setPayload] = useState<T | null>(null)
  const [isLoading, setIsLoading] = useState(true)
  useEffect(() => {
    if (!skip) {
      setIsLoading(true)
      const channel = Pusher.subscribe(channelName)
      channel.bind(messageType, (data: T) => {
        setPayload(data)
      })
      channel.bind('pusher:subscription_succeeded', () => setIsLoading(false))
      return () => {
        channel.disconnect()
        channel.unsubscribe()
        Pusher.unsubscribe(channelName)
      }
    }
  }, [channelName, messageType, skip])
  return {
    payload,
    isLoading,
  }
}

following a very similar design pattern to rtk query.

Now, I want to avoid additional subscription calls when connecting to the same channel from other components as every subscription request requires an auth API call. The main idea is to also prevent a subscription when routing between pages.

You cant use useMemo on react hooks (which would be awesome if possible) but is there a way I can integrate pusher with maybe a redux store?

Since my pusher channels are categorized by two params, a) roomtype b) roomname and the datatype for a roomtype is fixed. I used redux toolkit to write something like the following

export const Pusher = new pusherJs(key, {
  cluster,
  channelAuthorization,
})

const initialState: PusherState = {
  typeAChannelData: {},
  typeBChannelData: {},
}


export const subscribe = <T>(
  roomName: string,
  messageKey: string,
  dataCallback: (data: T) => void
): Promise<Channel> => {
  return new Promise((res, rej) => {
    const channel = Pusher.subscribe(roomName)
    channel.bind('pusher:subscription_succeeded', () => res(channel))
    channel.bind('pusher:subscription_error', (err: Error) => rej(err))
    channel.bind(messageKey, dataCallback)
  })
}

export const pusherSlice = createSlice({
  name: 'pusher',
  initialState,
  reducers: {
    connect: (
      state,
      action: PayloadAction<{ type: RoomType; roomName: string }>
    ) => {
      const { type, roomName } = action.payload
      switch (type) {
        case RoomType.TypeA:
          if (!(roomName in state.typeAChannelData)) {
            state.typeAChannelData[roomName] = {
              isLoading: true,
            }
            subscribe(roomName, 'EVENTA', (data: DataTypeA) => {
              state.typeAChannelData[roomName].data = data
            }).then((channel) => {
              state.typeAChannelData[roomName].isLoading = false
              state.typeAChannelData[roomName].channel = channel
            })
          }
          break
        case RoomType.TypeB:
          if (!(roomName in state.typeBChannelData)) {
            state.typeBChannelData[roomName] = {
              isLoading: true,
            }
            subscribe(roomName, 'EVENTB', (data: DataTypeB) => {
              state.typeBChannelData[roomName].data = data
            }).then((channel) => {
              state.typeBChannelData[roomName].isLoading = false
              state.typeBChannelData[roomName].channel = channel
            })
          }
          break
        default:
          throw new Error('Invalid Room type')
      }
    },
    disconnect: (
      state,
      action: PayloadAction<{ type: RoomType; roomName: string }>
    ) => {
      const { type, roomName } = action.payload
      switch (type) {
        case RoomType.TypeA:
          if (roomName in state.typeAChannelData) {
            state.typeAChannelData[roomName].channel?.disconnect()
            state.typeAChannelData[roomName].channel?.unsubscribe()
            Pusher.unsubscribe(roomName)
            delete state.typeAChannelData[roomName]
          }
          break
        case RoomType.TypeB:
          if (roomName in state.typeBChannelData) {
            state.typeBChannelData[roomName].channel?.disconnect()
            state.typeBChannelData[roomName].channel?.unsubscribe()
            Pusher.unsubscribe(roomName)
            delete state.typeBChannelData[roomName]
          }
          break
        default:
          throw new Error('Invalid Room type')
      }
    },
  },
})

export const { connect, disconnect } = pusherSlice.actions

export default pusherSlice.reducer

But this throws a illegal operation attempted on a revoked proxy error on redux which I am unable to debug. Are there better alternatives?

I found this package https://github.com/TheRusskiy/pusher-redux but I need strong ts support.


Solution

  • I'm not sure what you mean by "You can't use useMemo on react hooks"?, but this is how I implement my pusher subscription channel hook.

    It's not an exact answer to your question, but I couldn't fit this in a comment and hopefully it helps you in the right direction.

    export const useChannel = (channelName: string, userId?: string) => {
      if (!pusher) {
        initPusher();
      }
      const channel = useMemo(() => {
        if (!channelName) return null;
        if (!pusher.channel(channelName)) { // you can use this to check if you're already subscribed to a pusher channel
          return pusher.subscribe(channelName);
        }
        // if we already have the channel we still want to return it
        return pusher.channel(channelName);
      }, [channelName]);
    
      return channel;
    };
    

    I have a separate hook for binding to events, but maybe you could use something like this:

    const channel = Pusher.channel(channelName) || Pusher.subscribe(channelName);
    

    That way you're not resubscribing if you have the channel already.