Tutorials

How to Use WebSockets For Real-Time Data in Joystick

Learn how to implement real-time features in your Joystick app using WebSockets, including server setup, client connections, message handling, and practical examples like chat and live updates.

This tutorial was written by AI

The examples and explanations have been reviewed for accuracy. If you find an error, please let us know.

Real-time features are essential for modern web applications. Whether you're building a chat system, live notifications, or collaborative tools, WebSockets provide the foundation for instant, bidirectional communication between your server and clients. This tutorial will show you how to implement WebSockets in your Joystick app.

What You'll Learn

  • How to define WebSocket servers in Joystick
  • How to connect to WebSocket servers from components
  • How to send and receive real-time messages
  • How to handle connection events (open, close, error)
  • How to filter messages for specific users or channels
  • How to build practical real-time features like chat and live updates

Prerequisites

  • A Joystick app created and running
  • Basic understanding of Joystick components and server setup
  • A database configured (for storing messages/data)

Step 1: Define a WebSocket Server

First, let's create a WebSocket server in your index.server.js file. WebSocket servers are defined in the websockets object passed to joystick.app():

index.server.js

import joystick, { websockets } from '@joystick.js/node';
import api from './api/index.js';

joystick.app({
  api,
  websockets: {
    chat_messages: {
      on_open: (connection = {}) => {
        console.log('New client connected to chat');
        
        // Send a welcome message to the new connection
        connection.send(JSON.stringify({
          type: 'system',
          message: 'Welcome to the chat!',
          timestamp: new Date().toISOString(),
        }));
      },
      on_message: async (message = {}, connection = {}) => {
        console.log('Received message:', message);
        
        if (message.type === 'chat') {
          // Save message to database
          await process.databases.mongodb.collection('chat_messages').insertOne({
            _id: joystick.id(),
            user: message.user,
            message: message.message,
            timestamp: new Date().toISOString(),
          });
          
          // Broadcast the message to all connected clients
          websockets('chat_messages').send({
            type: 'chat',
            user: message.user,
            message: message.message,
            timestamp: new Date().toISOString(),
          });
        }
      },
      on_close: (code = 0, reason = '', connection = {}) => {
        console.log('Client disconnected:', { code, reason });
      },
    },
  },
  routes: {
    '/': (req = {}, res = {}) => {
      res.render('ui/pages/index/index.js', {
        layout: 'ui/layouts/app/index.js',
      });
    },
    '/chat': (req = {}, res = {}) => {
      res.render('ui/pages/chat/index.js', {
        layout: 'ui/layouts/app/index.js',
      });
    },
  },
});

Step 2: Create a Chat Component

Now let's create a component that connects to our WebSocket server and handles real-time messaging:

ui/components/chat_room/index.js

import joystick from '@joystick.js/ui';

const ChatRoom = joystick.component({
  state: {
    messages: [],
    connected: false,
    current_message: '',
    user_name: '',
  },
  websockets: (instance = {}) => {
    return {
      chat_messages: {
        options: {
          logging: true,
          auto_reconnect: true,
        },
        events: {
          on_open: (connection = {}) => {
            console.log('Connected to chat server');
            instance.set_state({ connected: true });
          },
          on_message: (message = {}) => {
            console.log('Received message:', message);
            
            // Add the new message to our state
            instance.set_state({
              messages: [...instance.state.messages, message],
            });
            
            // Auto-scroll to bottom of chat
            setTimeout(() => {
              const chatContainer = instance.DOMNode.querySelector('.messages-container');
              if (chatContainer) {
                chatContainer.scrollTop = chatContainer.scrollHeight;
              }
            }, 100);
          },
          on_close: (code = 0, reason = '', connection = {}) => {
            console.log('Disconnected from chat server');
            instance.set_state({ connected: false });
          },
        },
      },
    };
  },
  events: {
    'submit .chat-form': (event = {}, instance = {}) => {
      event.preventDefault();
      
      const message = instance.state.current_message.trim();
      const userName = instance.state.user_name.trim();
      
      if (!message || !userName) {
        alert('Please enter both your name and a message');
        return;
      }
      
      // Send message via WebSocket
      instance.websockets.chat_messages.send({
        type: 'chat',
        user: userName,
        message: message,
      });
      
      // Clear the message input
      instance.set_state({ current_message: '' });
    },
    
    'input .message-input': (event = {}, instance = {}) => {
      instance.set_state({ current_message: event.target.value });
    },
    
    'input .name-input': (event = {}, instance = {}) => {
      instance.set_state({ user_name: event.target.value });
    },
    
    'keypress .message-input': (event = {}, instance = {}) => {
      // Send message on Enter key
      if (event.key === 'Enter' && !event.shiftKey) {
        event.preventDefault();
        const form = instance.DOMNode.querySelector('.chat-form');
        if (form) {
          form.dispatchEvent(new Event('submit'));
        }
      }
    },
  },
  css: `
    .chat-room {
      max-width: 600px;
      margin: 0 auto;
      border: 1px solid #ddd;
      border-radius: 8px;
      overflow: hidden;
    }
    
    .chat-header {
      background: #007bff;
      color: white;
      padding: 15px;
      display: flex;
      justify-content: space-between;
      align-items: center;
    }
    
    .connection-status {
      font-size: 14px;
      padding: 4px 8px;
      border-radius: 4px;
      background: rgba(255, 255, 255, 0.2);
    }
    
    .connection-status.connected {
      background: #28a745;
    }
    
    .connection-status.disconnected {
      background: #dc3545;
    }
    
    .messages-container {
      height: 400px;
      overflow-y: auto;
      padding: 15px;
      background: #f8f9fa;
    }
    
    .message {
      margin-bottom: 15px;
      padding: 10px;
      border-radius: 8px;
      background: white;
      box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
    }
    
    .message.system {
      background: #e3f2fd;
      color: #1976d2;
      font-style: italic;
      text-align: center;
    }
    
    .message-header {
      display: flex;
      justify-content: space-between;
      align-items: center;
      margin-bottom: 5px;
    }
    
    .message-user {
      font-weight: bold;
      color: #007bff;
    }
    
    .message-time {
      font-size: 12px;
      color: #6c757d;
    }
    
    .message-content {
      color: #333;
      line-height: 1.4;
    }
    
    .chat-input-area {
      padding: 15px;
      background: white;
      border-top: 1px solid #ddd;
    }
    
    .input-row {
      display: flex;
      gap: 10px;
      margin-bottom: 10px;
    }
    
    .name-input {
      flex: 1;
      padding: 10px;
      border: 1px solid #ccc;
      border-radius: 4px;
      font-size: 14px;
    }
    
    .message-input {
      flex: 3;
      padding: 10px;
      border: 1px solid #ccc;
      border-radius: 4px;
      font-size: 14px;
      resize: none;
    }
    
    .send-button {
      padding: 10px 20px;
      background: #007bff;
      color: white;
      border: none;
      border-radius: 4px;
      cursor: pointer;
      font-size: 14px;
    }
    
    .send-button:disabled {
      background: #6c757d;
      cursor: not-allowed;
    }
    
    .empty-state {
      text-align: center;
      color: #6c757d;
      padding: 40px;
      font-style: italic;
    }
  `,
  render: ({ state, when, each }) => {
    return `
      <div class="chat-room">
        <div class="chat-header">
          <h3>Live Chat</h3>
          <div class="connection-status ${state.connected ? 'connected' : 'disconnected'}">
            ${state.connected ? '● Connected' : '● Disconnected'}
          </div>
        </div>
        
        <div class="messages-container">
          ${when(state.messages.length === 0, `
            <div class="empty-state">
              No messages yet. Start the conversation!
            </div>
          `)}
          
          ${each(state.messages, (message) => `
            <div class="message ${message.type === 'system' ? 'system' : ''}">
              ${when(message.type === 'system', `
                ${message.message}
              `)}
              ${when(message.type === 'chat', `
                <div class="message-header">
                  <span class="message-user">${message.user}</span>
                  <span class="message-time">${new Date(message.timestamp).toLocaleTimeString()}</span>
                </div>
                <div class="message-content">${message.message}</div>
              `)}
            </div>
          `)}
        </div>
        
        <div class="chat-input-area">
          <form class="chat-form">
            <div class="input-row">
              <input 
                type="text" 
                class="name-input" 
                placeholder="Your name..."
                value="${state.user_name}"
                ${!state.connected ? 'disabled' : ''}
              />
              <textarea 
                class="message-input" 
                placeholder="Type your message... (Press Enter to send)"
                value="${state.current_message}"
                rows="1"
                ${!state.connected ? 'disabled' : ''}
              ></textarea>
              <button 
                type="submit" 
                class="send-button"
                ${!state.connected || !state.user_name.trim() || !state.current_message.trim() ? 'disabled' : ''}
              >
                Send
              </button>
            </div>
          </form>
        </div>
      </div>
    `;
  },
});

export default ChatRoom;

Step 3: Create a Chat Page

Let's create a page that uses our chat component:

ui/pages/chat/index.js

import joystick from '@joystick.js/ui';
import ChatRoom from '../../components/chat_room/index.js';

const Chat = joystick.component({
  data: async (api = {}) => {
    // Load recent chat messages from the database
    return {
      recent_messages: await api.get('recent_chat_messages'),
    };
  },
  lifecycle: {
    on_mount: (instance = {}) => {
      // Pre-populate chat with recent messages
      if (instance.data?.recent_messages) {
        const chatComponent = instance.DOMNode.querySelector('[js-c]');
        if (chatComponent && chatComponent.joystick_instance) {
          chatComponent.joystick_instance.set_state({
            messages: instance.data.recent_messages,
          });
        }
      }
    },
  },
  render: ({ component }) => {
    return `
      <div class="chat-page">
        <h1>Real-Time Chat</h1>
        <p>Connect with others in real-time using WebSockets!</p>
        ${component(ChatRoom)}
      </div>
    `;
  },
});

export default Chat;

Step 4: Create a Getter for Recent Messages

Add a getter to load recent chat messages:

api/chat/getters.js

const getters = {
  recent_chat_messages: {
    get: async () => {
      const messages = await process.databases.mongodb
        .collection('chat_messages')
        .find()
        .sort({ timestamp: -1 })
        .limit(50)
        .toArray();
      
      // Return in chronological order (oldest first)
      return messages.reverse().map(message => ({
        type: 'chat',
        user: message.user,
        message: message.message,
        timestamp: message.timestamp,
      }));
    },
  },
};

export default getters;

Don't forget to register this getter in your API schema:

api/index.js

import chat_getters from './chat/getters.js';

const api = {
  getters: {
    ...chat_getters,
  },
  setters: {
    // Your setters here
  },
};

export default api;

Step 5: Add User-Specific WebSocket Filtering

Let's enhance our WebSocket server to support user-specific message filtering:

index.server.js (Enhanced)

import joystick, { websockets } from '@joystick.js/node';

joystick.app({
  websockets: {
    user_notifications: {
      on_open: (connection = {}) => {
        console.log('User connected to notifications');
      },
      on_message: async (message = {}, connection = {}) => {
        if (message.type === 'notification') {
          // Save notification to database
          await process.databases.mongodb.collection('notifications').insertOne({
            _id: joystick.id(),
            user_id: message.user_id,
            title: message.title,
            content: message.content,
            read: false,
            created_at: new Date().toISOString(),
          });
          
          // Send notification to specific user
          websockets('user_notifications').send({
            type: 'notification',
            title: message.title,
            content: message.content,
            timestamp: new Date().toISOString(),
          }, message.user_id); // Filter by user ID
        }
      },
      on_close: (code = 0, reason = '', connection = {}) => {
        console.log('User disconnected from notifications');
      },
    },
  },
  routes: {
    // Your routes here
  },
});

Step 6: Create a Live Notifications Component

Now let's create a component that receives user-specific notifications:

ui/components/live_notifications/index.js

import joystick from '@joystick.js/ui';

const LiveNotifications = joystick.component({
  state: {
    notifications: [],
    connected: false,
    unread_count: 0,
  },
  websockets: (instance = {}) => {
    return {
      user_notifications: {
        options: {
          logging: true,
          auto_reconnect: true,
        },
        query: {
          id: instance.props?.user_id, // Filter messages by user ID
        },
        events: {
          on_open: (connection = {}) => {
            instance.set_state({ connected: true });
          },
          on_message: (message = {}) => {
            if (message.type === 'notification') {
              const newNotification = {
                id: Date.now(),
                title: message.title,
                content: message.content,
                timestamp: message.timestamp,
                read: false,
              };
              
              instance.set_state({
                notifications: [newNotification, ...instance.state.notifications],
                unread_count: instance.state.unread_count + 1,
              });
              
              // Show browser notification if permission granted
              if (Notification.permission === 'granted') {
                new Notification(message.title, {
                  body: message.content,
                  icon: '/favicon.ico',
                });
              }
            }
          },
          on_close: (code = 0, reason = '', connection = {}) => {
            instance.set_state({ connected: false });
          },
        },
      },
    };
  },
  lifecycle: {
    on_mount: (instance = {}) => {
      // Request notification permission
      if ('Notification' in window && Notification.permission === 'default') {
        Notification.requestPermission();
      }
    },
  },
  events: {
    'click .notification-item': (event = {}, instance = {}) => {
      const notificationId = parseInt(event.currentTarget.getAttribute('data-id'));
      
      // Mark notification as read
      instance.set_state({
        notifications: instance.state.notifications.map(notification => 
          notification.id === notificationId 
            ? { ...notification, read: true }
            : notification
        ),
        unread_count: Math.max(0, instance.state.unread_count - 1),
      });
    },
    
    'click .clear-all': (event = {}, instance = {}) => {
      instance.set_state({
        notifications: [],
        unread_count: 0,
      });
    },
  },
  css: `
    .live-notifications {
      position: fixed;
      top: 20px;
      right: 20px;
      width: 300px;
      max-height: 400px;
      background: white;
      border: 1px solid #ddd;
      border-radius: 8px;
      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
      z-index: 1000;
    }
    
    .notifications-header {
      padding: 15px;
      background: #007bff;
      color: white;
      border-radius: 8px 8px 0 0;
      display: flex;
      justify-content: space-between;
      align-items: center;
    }
    
    .notifications-title {
      margin: 0;
      font-size: 16px;
    }
    
    .unread-badge {
      background: #dc3545;
      color: white;
      border-radius: 50%;
      padding: 2px 6px;
      font-size: 12px;
      min-width: 18px;
      text-align: center;
    }
    
    .notifications-list {
      max-height: 300px;
      overflow-y: auto;
    }
    
    .notification-item {
      padding: 12px 15px;
      border-bottom: 1px solid #eee;
      cursor: pointer;
      transition: background-color 0.2s;
    }
    
    .notification-item:hover {
      background: #f8f9fa;
    }
    
    .notification-item.unread {
      background: #e3f2fd;
      border-left: 3px solid #007bff;
    }
    
    .notification-title {
      font-weight: bold;
      margin-bottom: 4px;
      font-size: 14px;
    }
    
    .notification-content {
      font-size: 13px;
      color: #6c757d;
      margin-bottom: 4px;
    }
    
    .notification-time {
      font-size: 11px;
      color: #999;
    }
    
    .notifications-footer {
      padding: 10px 15px;
      background: #f8f9fa;
      border-radius: 0 0 8px 8px;
      text-align: center;
    }
    
    .clear-all {
      background: none;
      border: none;
      color: #007bff;
      cursor: pointer;
      font-size: 13px;
      text-decoration: underline;
    }
    
    .empty-state {
      padding: 30px 15px;
      text-align: center;
      color: #6c757d;
      font-style: italic;
    }
    
    .connection-indicator {
      width: 8px;
      height: 8px;
      border-radius: 50%;
      display: inline-block;
      margin-right: 5px;
    }
    
    .connection-indicator.connected {
      background: #28a745;
    }
    
    .connection-indicator.disconnected {
      background: #dc3545;
    }
  `,
  render: ({ state, when, each }) => {
    return `
      <div class="live-notifications">
        <div class="notifications-header">
          <h3 class="notifications-title">
            <span class="connection-indicator ${state.connected ? 'connected' : 'disconnected'}"></span>
            Notifications
          </h3>
          ${when(state.unread_count > 0, `
            <span class="unread-badge">${state.unread_count}</span>
          `)}
        </div>
        
        <div class="notifications-list">
          ${when(state.notifications.length === 0, `
            <div class="empty-state">
              No notifications yet
            </div>
          `)}
          
          ${each(state.notifications, (notification) => `
            <div 
              class="notification-item ${!notification.read ? 'unread' : ''}"
              data-id="${notification.id}"
            >
              <div class="notification-title">${notification.title}</div>
              <div class="notification-content">${notification.content}</div>
              <div class="notification-time">
                ${new Date(notification.timestamp).toLocaleString()}
              </div>
            </div>
          `)}
        </div>
        
        ${when(state.notifications.length > 0, `
          <div class="notifications-footer">
            <button class="clear-all">Clear All</button>
          </div>
        `)}
      </div>
    `;
  },
});

export default LiveNotifications;

Step 7: Create a Live Data Dashboard

Let's create a component that shows live data updates:

ui/components/live_dashboard/index.js

import joystick from '@joystick.js/ui';

const LiveDashboard = joystick.component({
  state: {
    stats: {
      users_online: 0,
      total_messages: 0,
      server_uptime: 0,
    },
    connected: false,
  },
  websockets: (instance = {}) => {
    return {
      dashboard_updates: {
        options: {
          auto_reconnect: true,
        },
        events: {
          on_open: (connection = {}) => {
            instance.set_state({ connected: true });
            
            // Request initial stats
            connection.send(JSON.stringify({
              type: 'request_stats',
            }));
          },
          on_message: (message = {}) => {
            if (message.type === 'stats_update') {
              instance.set_state({
                stats: message.stats,
              });
            }
          },
          on_close: (code = 0, reason = '', connection = {}) => {
            instance.set_state({ connected: false });
          },
        },
      },
    };
  },
  css: `
    .live-dashboard {
      display: grid;
      grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
      gap: 20px;
      margin: 20px 0;
    }
    
    .stat-card {
      background: white;
      padding: 20px;
      border-radius: 8px;
      box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
      text-align: center;
    }
    
    .stat-value {
      font-size: 2em;
      font-weight: bold;
      color: #007bff;
      margin-bottom: 5px;
    }
    
    .stat-label {
      color: #6c757d;
      font-size: 14px;
    }
    
    .connection-status {
      text-align: center;
      padding: 10px;
      margin-bottom: 20px;
      border-radius: 4px;
    }
    
    .connection-status.connected {
      background: #d4edda;
      color: #155724;
    }
    
    .connection-status.disconnected {
      background: #f8d7da;
      color: #721c24;
    }
  `,
  render: ({ state, when }) => {
    return `
      <div>
        <div class="connection-status ${state.connected ? 'connected' : 'disconnected'}">
          ${state.connected ? '● Live Data Connected' : '● Disconnected - Attempting to reconnect...'}
        </div>
        
        <div class="live-dashboard">
          <div class="stat-card">
            <div class="stat-value">${state.stats.users_online}</div>
            <div class="stat-label">Users Online</div>
          </div>
          
          <div class="stat-card">
            <div class="stat-value">${state.stats.total_messages}</div>
            <div class="stat-label">Total Messages</div>
          </div>
          
          <div class="stat-card">
            <div class="stat-value">${Math.floor(state.stats.server_uptime / 60)}m</div>
            <div class="stat-label">Server Uptime</div>
          </div>
        </div>
      </div>
    `;
  },
});

export default LiveDashboard;

Step 8: Testing WebSocket Functionality

Create tests to verify your WebSocket implementation:

tests/websockets/chat_messages.test.js

import test from '@joystick.js/test';

test.that('chat websocket sends and receives messages', async (assert = {}) => {
  const connection = await test.websockets.connect('chat_messages');

  // Send a test message
  connection.send({
    type: 'chat',
    user: 'TestUser',
    message: 'Hello, WebSocket world!',
  });

  // Close the connection
  connection.close();

  // Verify the message was processed
  const function_calls = await test.utils.get_function_calls('node.websockets.chat_messages.on_message');
  
  assert.like(function_calls[0]?.args[0], {
    type: 'chat',
    user: 'TestUser',
    message: 'Hello, WebSocket world!',
  });
});

Best Practices

  1. Connection Management: Always handle connection failures and implement auto-reconnect
  2. Message Validation: Validate all incoming WebSocket messages on the server
  3. Error Handling: Provide clear feedback when connections fail or messages can't be sent
  4. Performance: Avoid sending too many messages too quickly to prevent overwhelming clients
  5. Security: Validate user permissions before sending sensitive data
  6. Testing: Test WebSocket functionality thoroughly, including connection failures

Troubleshooting

WebSocket connection fails: Check that your server is running and the WebSocket endpoint is correctly defined.

Messages not received: Verify that the message format matches what your client expects.

Auto-reconnect not working: Ensure auto_reconnect: true is set in your WebSocket options.

Filtered messages not working: Check that the query.id is being passed correctly and matches the filter on the server.

Next Steps

  • Implement user presence indicators (who's online)
  • Add typing indicators for chat
  • Create room-based chat systems
  • Implement message persistence and history
  • Add file sharing through WebSockets
  • Build collaborative editing features

You now have a complete real-time system using WebSockets that can handle chat, notifications, live updates, and more!