Websockets on Django and React

For our new Shopify app I needed to create a websocket server that broadcasts a message when model is updated. I ended up with this solution. Start with installing channels and channels-redis to you Django app.

Add channels to INSTALLED_APPS and configure ASGI_APPLICATION and CHANNEL_LAYERS.

myproject/settings.py

INSTALLED_APPS = [
    ...
    "channels",
    ...
]

WSGI_APPLICATION = "myproject.wsgi.application"
ASGI_APPLICATION = "myproject.asgi.application"

CHANNEL_LAYERS = {
    "default": {
        "BACKEND": "channels_redis.core.RedisChannelLayer",
        "CONFIG": {
            "hosts": [os.environ.get("REDIS_URL")],
        },
    },
}

Setup the ASGI application so it's production ready. Based on How to deploy with ASGI I replaced waitress with daphne.

collection_sorting/asgi.py

import os

from django.core.asgi import get_asgi_application

# Fetch Django ASGI application early to ensure AppRegistry is populated
# before importing consumers and AuthMiddlewareStack that may import ORM
# models.
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myproject.settings")
django_asgi_app = get_asgi_application()

from channels.auth import AuthMiddlewareStack
from channels.routing import ProtocolTypeRouter, URLRouter

import myapp.routing

application = ProtocolTypeRouter(
    {
        "http": django_asgi_app,
        "websocket": AuthMiddlewareStack(URLRouter(myapp.routing.websocket_urlpatterns)),
    }
)

Create routing.py to hook up the consumer.

myapp/routing.py

from django.urls import re_path

from . import consumers

websocket_urlpatterns = [
    # We use re_path() due to limitations in URLRouter.
    re_path(r"ws/job-status/$", consumers.JobStatusConsumer.as_asgi()),
]

What is a consumer? This is a description form Django Channels documentation.

When Django accepts an HTTP request, it consults the root URLconf to lookup a view function, and then calls the view function to handle the request. Similarly, when Channels accepts a WebSocket connection, it consults the root routing configuration to lookup a consumer, and then calls various functions on the consumer to handle events from the connection.

This consumer accepts connections when user is signed in and adds them to their own group. When it receives propagate_status message it forwards it to all subscribers.

myapp/consumers.py

import json

from channels.generic.websocket import AsyncWebsocketConsumer


class JobStatusConsumer(AsyncWebsocketConsumer):
    async def connect(self):
        if self.scope["user"].is_anonymous:
            await self.close()
            return

        self.user = self.scope["user"]
        self.group_name = f"job-posting-{self.user.id}"

        await self.channel_layer.group_add(self.group_name, self.channel_name)
        await self.accept()

    async def propagate_status(self, event):
        if not self.scope["user"].is_anonymous:
            message = event["message"]
            await self.send(text_data=json.dumps(message))

Now in model's save method we call group_send to publish the update.

myapp/models.py

import channels.layers
from asgiref.sync import async_to_sync

class JobPosting(models.Model):

    ...

    def save(self, *args, **kwargs):
    super().save(*args, **kwargs)
    channel_layer = channels.layers.get_channel_layer()
    group = f"job-posting-{self.user.id}"
    async_to_sync(channel_layer.group_send)(
        group,
        {
            "type": "propagate_status",
            "message": {"id": self.id, "state": self.state},
        },
    )

Now when everything is wired up I created very simple client to make sure messages are being received.

const updatesSocket = new WebSocket(
  `ws://${window.location.host}/ws/job-status/`
)

updatesSocket.onmessage = function (e) {
  const data = JSON.parse(e.data)
  console.log(data)
}

updatesSocket.onclose = function (e) {
  console.error('Chat socket closed unexpectedly')
}

When the server side was working I used react-use-websocket to add live updates to React.

import React, { useState } from 'react';
import useWebSocket from 'react-use-websocket';

export default function List() {
  const [data, setData] = useState([]);

  useWebSocket(`wss://${window.location.host}/ws/job-status/`, {
    onMessage: (e) => {
      const message = JSON.parse(e.data);
      setData((data) => (data.map((item) => {
          if (item.id === message.id) {
            item.state = message.state;
          }
          return item;
        }));
    },
    shouldReconnect: (closeEvent) => true,
  });

  return (<div></div>);
}