How to make Rails Action Cable work in your Next.js app

How to make Rails Action Cable work in your Next.js app

TLDR

About a month ago I had to implement simple chat in one of our applications. Our stack is simple - Next.js + React used on the frontend communicating with our Ruby on Rails API on the backend.

I've decided to use Rails Action Cable for this job, so I've checked the docs , read a few articles to get an overview how others solved it and started implementing. Setup and actual API of Action Cable js library seemed pretty easy in React only projects. But make it work with Next.js was not and I've spent more than 2 hours until I was actually able to connect to my socket channel on the server.

I hope this article will help to anyone who has same struggles to make it work together.

**Go straight to Solution **

The problem

On my first attempt I've tried to do simple PoC component to connect to actioncable websocket.

import { useEffect, useState } from "react";
import { useSession } from "next-auth/react";
import { createConsumer } from "@rails/actioncable";

const ChatRoom = (props) => {
  const { data: session } = useSession();
  const [cable, setCable] = useState();
  const [channel, setChannel] = useState(null);

  useEffect(() => {
    if (session?.user) {
      const consumer = createConsumer(`${process.env.NEXT_PUBLIC_API_WS_URL}?token=${session?.user?.accessToken}`);
      setCable(consumer);
    }
  }, [session]);

  useEffect(() => {
    if (!cable) return;

    const chnl = cable.subscriptions.create(
      {
        channel: "ChatRoomChannel",
        id: props.chat.id,
      },
      {
        connected: () => {
          console.log("RoomsChannel connected!");
        },
        disconnected: () => {
          console.log("RoomsChannel disconnected!");
        },
      }
    );

    setChannel(chnl);
  }, [cable]);

  return <>My chat room</>;
};

export default ChatRoom;

And boom I've got error ReferenceError: self is not defined.

error2.png

That error occurs because, Action Cable library assumes, that window object is defined and It's running on client side. The solution should be pretty easy, just use next/dynamic to disable server side rendering and let it import on client side. So I've changed imports and useEffect like this:

import { useEffect, useState } from "react";
import { useSession } from "next-auth/react";
import dynamic from "next/dynamic";
const createConsumer = dynamic(() => import("@rails/actioncable", { ssr: false }).then((mod) => mod.createConsumer));

// unchanged code

useEffect(() => {
  if (session?.user && createConsumer && typeof window !== "undefined")
      const consumer = createConsumer(`${process.env.NEXT_PUBLIC_API_WS_URL}?token=${session?.user?.accessToken}`);
      setCable(consumer);
    }
  }, [session, createConsumer, window]);

// the rest of the code

But the result was still the same error message. So what solution I've ended up that worked?

The Solution

Here is step by step solution that worked for me. Versions:

{
  "nextjs": "12.1.6",
  "react": "18.1",
  "@rails/actioncable": "7.0.3"
}

We will implement this as provider to be able to use Action Cable connection anywhere in our application, when We need to.

1) Create a new file for example in src/providers/action_cable_provider.js with following content:

// src/providers/action_cable_provider.js

import { createContext, useEffect, useState } from "react";
import { useSession } from "next-auth/react";

const ActionCableContext = createContext();

const ActionCableProvider = ({ children }) => {
  const { data: session } = useSession();
  const [CableApp, setCableApp] = useState({});

  const loadConsumer = async () => {
    const { createConsumer } = await import("@rails/actioncable");
    return createConsumer;
  };

  useEffect(() => {
    if (typeof window !== "undefined" && session?.user && session?.user?.accessToken && CableApp.cable === undefined) {
      loadConsumer().then((createConsumer) => {
        setCableApp({
          cable: createConsumer(`${process.env.NEXT_PUBLIC_API_WS_URL}?token=${session?.user?.accessToken}`),
        });
      });
    }
  }, [session, window]);

  return <ActionCableContext.Provider value={CableApp.cable}>{children}</ActionCableContext.Provider>;
};

export { ActionCableContext, ActionCableProvider };

NEXT_PUBLIC_API_WS_URL is your environment variable with websocket address to connect. Usually something like ws://localhost:3000/cable

  • So basically we are waiting for window and session object to be ready.

  • Then we import @rails/actioncable dynamically and after that we call createConsumer function (only once) and pass value to provider.

2) Edit your _app.js file as follows:

// pages/_app.js

// other imports
import { ActionCableProvider } from "../src/providers/action_cable_provider";

function MyApp({ Component, pageProps: { session, ...pageProps } }) {

  return (
        // <OtherProvider>
          <ActionCableProvider>
            <Layout>
              <Component {...pageProps} />
            </Layout>
          </ActionCableProvider>
        // </ OtherProvider>
  );
}
  • Here we just imported our newly created provider and wrapped our layout with it.

3) Now you can use this provider in your components as follows:

// ChatRoom component

import { useState, useEffect, useRef, useContext } from "react";
import { ActionCableContext } from "../../src/providers/action_cable_provider";

const ChatRoom = (props) => {
  const cable = useContext(ActionCableContext);
  const [channel, setChannel] = useState(null);

  useEffect(() => {
      if (!channel && cable && props.chat) {
        const chnl = cable.subscriptions.create(
          {
            channel: "ChatRoomChannel",
            id: props.chat.id,
          },
          {
            connected: () => {
              console.log("RoomsChannel connected!");
            },
            disconnected: () => {
              console.log("RoomsChannel disconnected!");
            },
            received: (data) => {
              console.log(data);
            },
          }
        );

        setChannel(chnl);
      }

      return () => {
        if (cable) {
          cable.disconnect();
        }
      };
    }, [props.chat, cable]);

  return <>My chat room</>;
}

export default ChatRoom;
  • First, we have to wait for our cable connection to be ready.

  • Then, we create a subscription to specified channel and save that to state, so We can work with that object later.

And that's it you should be able to connect to your channel on server and see log message in the console. With all these steps, I've been able to successfully create a simple chat app in Next.js using Rails Action Cable websockets.

This approach should be general enough to use it for any purpose you need.

Conditions used in these components might differ based on your app or a problem.

A little off topic note

I've had a hard time to update react array of messages when received a new message from the server. I've had something like this:

const [messages, setMessages] = useState([]);

const onMessageReceive = (msg) => {
  if (messages.find((m) => m.id == msg.id) return;

  setMessages([...messages, msg]);
}

But this didn't work. Sometimes messages disappeared sometimes didn't appear at all and so on. So If you have these types of non-deterministic behaviour use the functional form of useState instead:

setMessages((messages) => [...messages, msg]);

This solved all my problems with receiving messages through channels.

That's all, hope this helped.