Back to blog
WebSockets with Pusher - Real-time Events, Notifications with Laravel & TanStack Start

WebSockets with Pusher - Real-time Events, Notifications with Laravel & TanStack Start

Want a quick summary of this post? Tune in 🎧
Table of Contents

1. Introduction

WebSockets are an excellent way to build real-time applications, but they can be tricky to implement. It’s been a while since I’ve used WebSockets in my projects, especially with Laravel and TanStack Start, so I thought it would be a good idea to write about it.

If you’re using Laravel like I did for a long time, dispatching jobs and notifying users in real-time can provide a nice UX improvement, and it’s probably the only “clean” way to handle this. Of course, you could always use polling, but that’s not the best solution in the long run. Let’s explore how to implement this in Laravel (PHP) and TanStack Start (JavaScript).

2. WebSocket Servers ⚡

While you could build your own WebSocket servers, sometimes it’s just not worth the effort—you want to ship your app without worrying about maintaining another service.

There are several solutions you can choose from. I’m biased toward Pusher because of my Laravel background, but that doesn’t mean you can’t use other solutions. In this article, I’ll explore the Pusher solution, but other solutions like socket.io are also excellent, and I might update this article in the future.

Here are a few you can pick from:

  • Pusher ( Paid as a service )
  • Sockudo ( Pusher compatible, Open source, self-hosted, Rust )
  • Soketi ( Pusher compatible, Open source, self-hosted )
  • Socket.io ( Open source, self-hosted )
  • Partykit ( Open source, self-hosted )
  • Laravel Reverb ( Pusher compatible, Laravel specific, Open source )
  • Crossws ( Open source, barebones )
  • uWebSockets ( Open source, barebones )
  • ws ( Open source, barebones )

There are tradeoffs to each solution, and you should pick the one that fits your needs best. For me, most of the time I don’t need super high performance or blazing-fast solutions, so I would just pick Pusher-compatible solutions because they’re easy to integrate, include authentication, and you can use the Pusher JS SDK and call it a day.

I’ll explore some self-hosted solutions here, but you can also use Pusher as a service—it’s an excellent solution if you’re not interested in self-hosting.

2.1 - Laravel Reverb 📡

Laravel Reverb is a Pusher-compatible solution for Laravel. If you’re a Laravel user, it’s probably the best solution for you, as it pairs perfectly with your current deployment pipeline and integrates with Laravel.

I’ll cover the basic installation and setup here, but you can find more information in the Laravel Reverb documentation.

php artisan install:broadcasting

In your environment file:

REVERB_APP_ID=my-app-id
REVERB_APP_KEY=my-app-key
REVERB_APP_SECRET=my-app-secret
php artisan reverb:start --host="0.0.0.0" --port=8080 --hostname="laravel.test"

Yes, it should be this easy! 🎉

2.2 - Sockudo 🦀

Sockudo is a new project written in Rust. It’s a Pusher-compatible solution that’s very easy to set up because it ships as a single binary—you can just download it and run it. While I previously used Soketi, it seems Soketi has been abandoned, so for me this is the best solution right now for a “plug-and-play” approach, especially if you’re in the JavaScript ecosystem where Reverb isn’t an option.

First, grab the latest release from the Sockudo GitHub and run it as follows:

sockudo --config=./config.json

Here’s an example config.json I use locally. For production, you should use environment variables. Please refer to the Sockudo documentation for more information about setting up default apps.

{
  "debug": true,
  "adapter": {
    "driver": "redis",
    "redis": {
      "requests_timeout": 5000,
      "prefix": "sockets_",
      "redis_pub_options": {
        "url": "redis://127.0.0.1:6379"
      },
      "redis_sub_options": {
        "url": "redis://127.0.0.1:6379"
      }
    }
  },
  "app_manager": {
    "driver": "memory",
    "array": {
      "apps": [
        {
          "id": "my-app-id",
          "key": "my-app-key",
          "secret": "my-app-secret",
          "max_connections": "10000",
          "enable_client_messages": true,
          "max_client_events_per_second": "200",
          "enabled": true
        }
      ]
    }
  },
  "cache": {
    "driver": "redis",
    "redis": {
      "redis_options": {
        "url": "redis://127.0.0.1:6379",
        "prefix": "sockets_cache"
      }
    }
  },
  "port": 6001
}

3. Server-Side Implementation 🛠️

Now that we have our Pusher service up and running, we can implement the server-side components like authentication, event dispatching, and more.

3.1 - Server-Side with Laravel 🐘

If you’re using Laravel, this is provided for you. You can use Laravel Events and implement traits like ShouldBroadcast and ShouldBroadcastNow on your events. Let’s explore how to do it.

If you ran the php artisan install:broadcasting command, you should have a BroadcastingServiceProvider and a route file called channels.php that handles authentication for different channels.

<?php

use App\Models\User;
use App\Services\Cart\CartService;
use Illuminate\Support\Facades\Broadcast;

/*
|--------------------------------------------------------------------------
| Broadcast Channels
|--------------------------------------------------------------------------
|
| Here you may register all of the event broadcasting channels that your
| application supports. The given channel authorization callbacks are
| used to check if an authenticated user can listen to the channel.
|
*/

Broadcast::routes(['middleware' => ['web']]);

Broadcast::channel('App.Models.User.{id}', function (User $user, $id) {
    return $user->id === (int) $id;
});

// Example of a channel for a cart
// In this case a guest-authenticated middleware should be used.
Broadcast::channel('App.Models.Cart.{uuid}', function (User $user, string $uuid) {
    return (new CartService)->get()->uuid === $uuid;
});

3.1.1 - Dispatching Events & Notifications 📢

Now that we have our authentication endpoint and channels set up, we can start dispatching events and notifications. I’ll cover how to do this in JavaScript later, but for now let’s explore how to do it in PHP.

This simple event broadcasts to the currently logged-in user with the event refresh:xxxx, where xxx can be anything you want—a table ID, cart ID, dialog ID, etc.

// App\Events\RefreshUIEvent

<?php

namespace App\Events;

use App\Helpers\Broadcast;
use App\Models\User;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Broadcasting\PrivateChannel;

final class RefreshUIEvent implements ShouldBroadcastNow
{
    use Dispatchable;
    use InteractsWithSockets;
    use Queueable;
    use SerializesModels;

    public function __construct(
        protected User $user,
        protected string $uniqueKey,
    ) {}

    public function broadcastOn(): PrivateChannel
    {
        return [
            new PrivateChannel('user.'.$this->user->id),
        ];
    }

    public function broadcastAs(): string
    {
        return 'refresh:'.$this->uniqueKey;
    }

    public function broadcastWith(): array
    {
        return [];
    }
}

We can then dispatch this event from anywhere in our codebase, like a job, a controller, a service, etc.

use App\Events\RefreshUIEvent;

event(new RefreshUIEvent($user, 'my-unique-key'));

There’s much more to cover here, but nothing beats diving into the Laravel Broadcasting documentation. You can implement this in many ways—handy aliases, presence channels, and more. I want to keep this article focused on the WebSocket implementation, so I won’t cover all the other features.

3.2 - Server-Side with TanStack Start (JavaScript) ⚡

While Laravel does all the heavy lifting for us (and we’re spoiled), in JavaScript land we need to do a bit more work. But fear not! This is where this article and the community shine.

First, let’s see how we can mimic Laravel’s authentication part by creating a simple auth service. I’ll try to replicate Laravel’s channel registration pattern that we’re familiar with: user.{userId}.

authenticator.channel<UserChannelParams>('user.{userId}', (user, params) => {
  //logger.info('🔑 Authenticating user', user, params)
  return Number(user.id) === Number(params.userId)
})

The following code is a bit complex but does the job and ensures several things:

  • Validates pattern syntax and matches channel names to patterns
  • Supports presence channels and private channels in a Pusher-like interface
  • Uses Better Auth to get user sessions and inject user data into channels
  • That’s probably all we need for now, but you can extend it to your needs!
  • Compatible with Laravel Echo
// src/services/sockets/sockets.auth.ts
import { logger } from '@bookmarks/logger'
import { auth } from '@/services/auth/auth.better-auth'
import type { SocketsChannelConfig, SocketsChannelParams, SocketsAuthCallback, SocketsMatchedChannelInfo, SocketsAuthOptions, SocketsAuthResponse, SocketsAuthErrorResponse, SocketsChannelPrefix, SocketsAuthUser } from './sockets.types'
import type Pusher from 'pusher'

interface StoredPatternInfo<
  TParams extends SocketsChannelParams,
  TPresenceData extends Pusher.PresenceChannelData
> {
  config: SocketsChannelConfig<TParams, TPresenceData>;
  patternParts: string[];
}

export class SocketAuthenticator<TUser extends SocketsAuthUser = SocketsAuthUser> {

  private channelPatternsByLength = new Map<number, StoredPatternInfo<any, any>[]>()

  private options: Required<SocketsAuthOptions>

  constructor(opts?: SocketsAuthOptions) {

    if (!opts?.pusher) {
      throw new Error('Pusher instance is required')
    }

    this.options = {
      privateChannelsPrefix: opts.privateChannelsPrefix ?? 'private-',
      presenceChannelsPrefix: opts.presenceChannelsPrefix ?? 'presence-',
      pusher: opts.pusher
    }
  }

  /**
   * Register a channel pattern with its auth callback
   */
  public channel<
    TParams extends SocketsChannelParams = SocketsChannelParams,
    TPresenceData extends Pusher.PresenceChannelData = Pusher.PresenceChannelData
  >(
    name: string,
    callback: SocketsAuthCallback<TParams, TPresenceData>,
  ): void {
    const normalizedName = name.replace(/^\.+|\.+$/g, '')

    // Validate pattern syntax
    this.validatePattern(normalizedName)

    const config: SocketsChannelConfig<TParams, TPresenceData> = { pattern: normalizedName, callback }
    const patternParts = normalizedName.split('.').filter(Boolean)

    const entry: StoredPatternInfo<TParams, TPresenceData> = {
      config,
      patternParts,
    }

    const len = patternParts.length
    if (!this.channelPatternsByLength.has(len)) {
      this.channelPatternsByLength.set(len, [])
    }
    const entries = this.channelPatternsByLength.get(len)
    if (entries) {
      logger.info(`✅ Registered auth callback for channel pattern: ${normalizedName} (Segments: ${len})`)
      entries.push(entry)
    }
  }

  /**
   * Clear all registered patterns (useful for testing)
   */
  public clearChannels(): void {
    this.channelPatternsByLength.clear()
  }

  /**
   * Validate pattern syntax
   */
  private validatePattern(pattern: string): void {
    // Check for balanced braces
    const braces = pattern.match(/[{}]/g) ?? []
    if (braces.length % 2 !== 0) {
      throw new Error(`❌ Invalid pattern syntax: unbalanced braces in "${pattern}"`)
    }

    // Check for empty parameter names
    const paramMatches = pattern.match(/\{[^}]*\}/g) ?? []
    for (const param of paramMatches) {
      if (param === '{}') {
        throw new Error(`❌ Invalid pattern syntax: empty parameter in "${pattern}"`)
      }
    }

    // Check for invalid characters in parameter names
    for (const param of paramMatches) {
      const paramName = param.slice(1, -1)
      if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(paramName)) {
        throw new Error(`❌ Invalid pattern syntax: invalid parameter name "${paramName}" in "${pattern}"`)
      }
    }
  }

  /**
   * Handle the socket auth request
   */
  public async handle(request: Request): Promise<SocketsAuthResponse> {
    const user = await this.session(request)

    if (!user) {
      return this.forbidden('❌ Unauthorized: user not found, banned or invalid')
    }

    const formData = await request.formData()
    const socketId = formData.get('socket_id') as string | null
    const channelName = formData.get('channel_name') as string | null

    if (!socketId) {
      return this.error('Socket ID is required')
    }

    // We dont have a channel but we have a user, i guess its ok.
    if (!channelName) {
      const userData = this.getUserPresenceData(user)
      const response = this.options.pusher.authenticateUser(socketId, userData)
      return this.ok(response)
    }

    // Attempt to match a channel for the given channel name prefixed
    const channel = this.matchChannel(channelName)

    if (!channel) {
      logger.warn(`❌ Forbidden: Channel ${channelName} (socket: ${socketId}) doesn't exist`)
      return this.forbidden('Channel not found or invalid')
    }

    const {
      config,
      params,
      prefix,
    } = channel

    try {
      // Here we call the provided registered callback to see if the user is authorized to join the channel
      // Similar to how laravel does it.
      const result = await config.callback(
        user as Pusher.UserChannelData,
        params,
        socketId,
        channelName,
      )

      // A special case here for the presence channel, where we can return additional data, but also the user presence data
      if (prefix === this.options.presenceChannelsPrefix && typeof result === 'object' && result !== null) {
        const response = this.options.pusher.authorizeChannel(socketId, channelName, {
          ...result,
          ...this.getUserPresenceData(user),
        })
        return this.ok(response)
      }

      // If the callback returns true, we authorize the channel
      if (result === true) {
        logger.info(`✅ Authorized channel "${channelName}". User: ${user.id}, Socket: ${socketId}, Pattern: "${config.pattern}"`)
        const response = this.options.pusher.authorizeChannel(socketId, channelName)
        return this.ok(response)
      }

      logger.warn(`❌ Forbidden: Access denied by callback for channel "${channelName}". User: ${user.id}, Socket: ${socketId}, Pattern: "${config.pattern}"`)
      return this.forbidden('Access denied')
    } catch (error) {
      logger.error(`❌ Error in channel auth callback for "${config.pattern}" (channel: ${channelName}):`, error)
      return this.error('Internal server error during auth callback')
    }
  }

  /**
   * Resolve the user session from the request ( in this case better-auth )
   */
  private async session(request: Request) {
    const authSession = await auth.api.getSession({ headers: request.headers })

    if (!authSession?.session || !authSession.user.id) {
      return null
    }
    return authSession.user as unknown as TUser
  }

  /**
   * Get the user presence data
   */
  private getUserPresenceData(user: TUser) {
    return {
      id: String(user.id),
      user_info: { name: user.name ?? user.username ?? 'Anonymous' },
    }
  }

  /**
   * Return a forbidden error
   */
  private forbidden(message: string): SocketsAuthErrorResponse {
    return { body: { message }, status: 403 }
  }

  /**
   * Return an error
   */
  private error(message: string): SocketsAuthErrorResponse {
    return { body: { message }, status: 500 }
  }

  /**
   * Return a success response
   */
  private ok(response: Pusher.ChannelAuthResponse): SocketsAuthResponse {
    return { body: response, status: 200 }
  }

  /**
   * Match the channel name to the channel config
   */
  private matchChannel<
    TParams extends SocketsChannelParams,
    TPresenceData extends Pusher.PresenceChannelData
  >(name: string): SocketsMatchedChannelInfo<TParams, TPresenceData> | null {

    let prefix: SocketsChannelPrefix | null = null
    let channelToMatch: string

    if (name.startsWith(this.options.privateChannelsPrefix)) {
      prefix = this.options.privateChannelsPrefix as SocketsChannelPrefix
      channelToMatch = name.substring(this.options.privateChannelsPrefix.length)
    } else if (name.startsWith(this.options.presenceChannelsPrefix)) {
      prefix = this.options.presenceChannelsPrefix as SocketsChannelPrefix
      channelToMatch = name.substring(this.options.presenceChannelsPrefix.length)
    } else {
      return null
    }

    if (!channelToMatch || !prefix) {
      return null
    }

    const match = this.findChannelMatch<TParams, TPresenceData>(channelToMatch)

    if (!match) {
      return null
    }

    return {
      config: match.config,
      params: match.params,
      prefix,
      name,
    }
  }

  /**
   * Find matching channel pattern for the given channel name
   */
  private findChannelMatch<
    TParams extends SocketsChannelParams,
    TPresenceData extends Pusher.PresenceChannelData
  >(channelToMatch: string): { config: SocketsChannelConfig<TParams, TPresenceData>; params: TParams } | null {
    const requestParts = channelToMatch.split('.').filter(Boolean)
    const relevantPatternEntries = this.channelPatternsByLength.get(requestParts.length)

    if (!relevantPatternEntries) {
      return null
    }

    for (const entry of relevantPatternEntries) {
      const { config, patternParts } = entry

      const params: SocketsChannelParams = {}
      let match = true

      for (let i = 0; i < patternParts.length; i++) {
        const patternPart = patternParts[i]
        const requestPart = requestParts[i]

        if (patternPart.startsWith('{') && patternPart.endsWith('}')) {
          const paramName = patternPart.substring(1, patternPart.length - 1)
          params[paramName] = requestPart
        } else if (patternPart !== requestPart) {
          match = false
          break
        }
      }

      if (match) {
        return {
          config,
          params: params as TParams,
        }
      }
    }
    return null
  }

}

Here are the type definitions for the auth service. I’ve collapsed them for readability.

👇 Socket Authenticator Type Definition 👇
import type { UserChannelData } from 'pusher'
import type Pusher from 'pusher'

export interface SocketsAuthOptions {
  pusher: Pusher
  privateChannelsPrefix?: string
  presenceChannelsPrefix?: string
}

export interface SocketsAuthUser {
  id: string | number
  [key: string]: any
}

export interface SocketsPresenceChannelData {
  user_id: string | number
  user_info?: Record<string, any>
}

export type SocketsChannelParams = Record<string, any>

export type SocketsChannelPrefix = 'private-' | 'presence-'

export type SocketsAuthCallback<
  TParams extends SocketsChannelParams = SocketsChannelParams,
  TPresenceData extends SocketsPresenceChannelData = SocketsPresenceChannelData,
  TUser extends UserChannelData = UserChannelData,
> = (
  user: TUser,
  params: TParams,
  socketId: string,
  channelName: string,
) => Promise<boolean | TPresenceData> | boolean | TPresenceData

export interface SocketsChannelConfig<
  TParams extends SocketsChannelParams = SocketsChannelParams,
  TPresenceData extends SocketsPresenceChannelData = SocketsPresenceChannelData,
> {
  pattern: string
  callback: SocketsAuthCallback<TParams, TPresenceData>
}

export interface SocketsMatchedChannelInfo<
  TParams extends SocketsChannelParams = SocketsChannelParams,
  TPresenceData extends SocketsPresenceChannelData = SocketsPresenceChannelData,
> {
  config: SocketsChannelConfig<TParams, TPresenceData>
  params: TParams
  prefix: SocketsChannelPrefix
  name: string
}

export interface SocketsAuthResponse {
  body: any
  status: number
}

export interface SocketsAuthErrorResponse {
  body: {
    message: string
  }
  status: number
}

Great! Now that we can authenticate channels, let’s also have an entry point. Every time we call the WebSocket server, we need to ensure our callback is registered somewhere.

Note: Make sure this is done only once!

import Pusher from 'pusher'
import { env } from '@/env'
import { SocketAuthenticator } from '@/services/sockets/sockets.auth'
import type { User } from '@/types'

export const pusher = new Pusher({
  appId: env.PUSHER_APP_ID ?? '',
  key: env.PUSHER_APP_KEY ?? '',
  secret: env.PUSHER_APP_SECRET ?? '',
  host: env.PUSHER_HOST ?? '',
  port: env.PUSHER_PORT ?? '',
  useTLS: env.PUSHER_SCHEME === 'https',
  cluster: 'mt1',
})

export const authenticator = new SocketAuthenticator<User>({ pusher })

interface UserChannelParams {
  userId: string
}

authenticator.channel<UserChannelParams>('user.{userId}', (user, params) => {
  //logger.info('🔑 Authenticating user', user, params)
  return Number(user.id) === Number(params.userId)
})

Now let’s ensure we also have an endpoint like Laravel’s. I’ll place this in a file called broadcast.auth.ts in our API routes. This file ensures that when the frontend connects to the WebSocket server, it will be authenticated and can join channels (regardless of which channel it is).

// src/routes/api/broadcast.auth.ts
import { json } from '@tanstack/react-start'
import { authenticator } from '@/services/sockets/sockets.server'

export const ServerRoute = createServerFileRoute().methods({
  POST: async ({ request }) => {
    const { body, status } = await authenticator.handle(request)
    return json(body, { status })
  },
})

Now we can finally dispatch demo events to connected clients. Here’s a simple example:

import { pusher } from '@/services/sockets/sockets.server'
const userId = '1'
await pusher.trigger(`private-user.${userId}`, 'article.created', { articleId: '1' })

In this example and in my apps, I use this to trigger events from Trigger.dev jobs. When a job finishes, I can notify the user and invalidate the TanStack Start router cache to reload the results.

4. Client-Side Implementation 🎯

Now that we have our server-side implementation fairly complete, let’s see how to implement the client-side part. For simplicity, I’ll use Laravel Echo. You could use the Pusher JS SDK if you prefer—just make sure to adjust the authentication and channel prefixes accordingly.

4.1 - Client-Side with Laravel Echo & React ⚛️

In this example, I’ll use a React Context Provider to ensure we can wrap our Layout with it, so the socket connection is available everywhere, connects once, and is available for registering new listeners anywhere.

Let’s break down the code before we start, so you can understand what’s happening:

  • Create a Context Provider
  • Initialize the Echo client once
  • Clean up channels/connections when the component unmounts
  • Store the connection state in context
  • Provide a clean way to join new channels
import { createContext, useContext, useState, useRef, useEffect } from 'react'
import type { ReactNode, Dispatch, SetStateAction } from 'react'
import { env } from '@/env'
import type Echo from 'laravel-echo'

interface SocketOptions {
  encrypted?: boolean;
}

type EchoType = Echo<'reverb' | 'pusher'>

interface SocketState {
  connected: boolean;
  reconnecting: boolean;
  socketId: string | null;
  channels: string[];
}

interface SocketContextType {
  client: EchoType | null;
  connect: () => void;
  disconnect: () => void;
  addChannel: (channel: string) => void;
  removeChannel: (channel: string) => void;
  state: SocketState;
  setState: Dispatch<SetStateAction<SocketState>>;
}

const SocketContext = createContext<SocketContextType | null>(null)

export interface SocketProviderProps {
  children: ReactNode
  options?: SocketOptions
}

export function SocketProvider({
  children,
}: SocketProviderProps) {

  const [state, setState] = useState<SocketState>({
    connected: false,
    reconnecting: false,
    socketId: null,
    channels: [],
  })

  const clientRef = useRef<EchoType | null>(null)
  const didBoundInitialEvents = useRef(false)

  const initializeClient = async () => {
    if (typeof window !== 'undefined') {
      // @ts-expect-error
      window.Pusher = await import('pusher-js').then(m => m.default)
    }

    const { default: Echo } = await import('laravel-echo')

    clientRef.current = new Echo({
      broadcaster: 'pusher',
      key: env.VITE_PUSHER_APP_KEY,
      wsHost: env.VITE_PUSHER_HOST,
      wsPort: env.VITE_PUSHER_PORT ? Number(env.VITE_PUSHER_PORT) : 6001,
      wssPort: env.VITE_PUSHER_PORT ? Number(env.VITE_PUSHER_PORT) : 6001,
      disableStats: true,
      cluster: 'mt1',
      forceTLS: env.VITE_PUSHER_SCHEME === 'https',
      encrypted: env.VITE_PUSHER_ENCRYPTED === 'true',
      enabledTransports: ['ws', 'wss'],
      authEndpoint: '/api/broadcast/auth',
    })

  }

  const bindEvents = () => {
    if (didBoundInitialEvents.current || !clientRef.current) return

    const client = clientRef.current
    setState(prev => ({
      ...prev,
      connected: client.connector.pusher.connection.state === 'connected',
      socketId: client.socketId() ?? null
    }))

    client.connector.pusher.connection.bind('connected', () => {
      setState(prev => ({
        ...prev,
        connected: true,
        reconnecting: false,
        socketId: client.socketId() ?? null
      }))

      console.info('⚡ Socket was Connected with ID: ', client.socketId())
    })

    client.connector.pusher.connection.bind('disconnected', () => {
      setState(prev => ({
        ...prev, connected: false,
        reconnecting: false,
        socketId: null
      }))

      console.info('⚡ Socket Disconnected')
    })

    client.connector.pusher.connection.bind('reconnecting', (attemptNumber: number) => {
      setState(prev => ({
        ...prev, connected: false,
        reconnecting: true,
        socketId: null
      }))

      console.info('⚡ Socket is Trying to reconnect', attemptNumber)
    })

    didBoundInitialEvents.current = true
  }

  const unbindEvents = () => {
    if (!clientRef.current) {
      return
    }
    const client = clientRef.current
    client.connector.pusher.connection.unbind('connected')
    client.connector.pusher.connection.unbind('disconnected')
    client.connector.pusher.connection.unbind('reconnecting')
    didBoundInitialEvents.current = false
  }

  const connect = async () => {
    if (!clientRef.current) {
      await initializeClient()
    }

    if (state.connected) {
      return
    }

    if (!clientRef.current) {
      return
    }

    const client = clientRef.current

    if (client.connector.pusher.connection.state === 'connected') {
      bindEvents()
      return
    }

    client.connect()
    bindEvents()
  }

  const disconnect = () => {
    if (!clientRef.current || !state.connected) return
    clientRef.current.disconnect()
    unbindEvents()
  }

  const addChannel = (channel: string) => {
    setState(prev => ({
      ...prev,
      channels: prev.channels.includes(channel) ? prev.channels : [...prev.channels, channel],
    }))
  }

  const removeChannel = (channel: string) => {
    setState(prev => ({
      ...prev,
      channels: prev.channels.filter(c => c !== channel),
    }))
  }

  useEffect(
    () => {
      void connect()
      return () => disconnect()
    },
    // eslint-disable-next-line react-hooks/exhaustive-deps
    []
  )

  const contextValue: SocketContextType = {
    state,
    setState,
    client: clientRef.current,
    connect,
    disconnect,
    addChannel,
    removeChannel,
  }

  return (
    <SocketContext.Provider value={contextValue} >
      {children}
    </SocketContext.Provider>
  )
}

export const useSockets = () => {
  const context = useContext(SocketContext)
  if (!context) {
    throw new Error('useSockets must be used within a SocketProvider')
  }
  return context
}

Wrap your main layout with the SocketProvider like this:

<SocketProvider>
  <Layout>
    <Outlet />
  </Layout>
</SocketProvider>

Now you can use the useSockets hook to get the socket client and join channels. Here’s a demo of how to join a private channel and listen for events:

const { client } = useSockets()

const channelRef = useRef<any>(null)
const onArticleCreated = (e: any) => {
  void router.invalidate()
  toast.success('Your Article was created! ✅')
}

useEffect(() => {
  if (!client) return

  channelRef.current = client.private(`user.${userId}`)
  channelRef.current.listen('article.created', onArticleCreated)

  return () => {
    channelRef.current?.stopListening('article.created', onArticleCreated)
  }
}, [client, userId, onArticleCreated])

This approach is easily portable to other frameworks like Vue, Solid, etc. You can also create “online” and “offline” components next to user profile components to show connection status indicators. Just make sure to get the state from the context—it’s reactive and should be available everywhere.

5. Conclusion 🎉

I hope you enjoyed this article and learned something new! While we could dive deeper and explore many more features, I wanted to keep this article focused on the WebSocket implementation. Please make sure to read the documentation for anything mentioned here, as there are still some edge cases to cover!

6. Resources 📚

Like this post? Sharing it means a lot to me! ❤️