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!