Articles by Sergio Xalambrí

Use Server-Sent Events with Remix

Using Server-Sent Events (SSE) you can keep a connection between a browser (client) and an HTTP server open and let the server send messages across the network. Similar to how WebSockets works but limited to a single direction from server to client instead of being bi-directional.

SSE is more straightforward but not less powerful. You can achieve bi-directional communication with standard forms making POST requests to send data back to the server.

Let's see how we could use SSE in a Remix app to add real-time features.

We first need a Resource Route with a loader returning a text/event-stream response while we could build one ourselves. Let's use Remix Utils eventStream response helper to achieve it.

// app/routes/sse.time.ts
import type { LoaderArgs } from "@remix-run/node";

import { eventStream } from "remix-utils";

export async function loader({ request }: LoaderArgs) {
  return eventStream(request.signal, function setup(send) {
    let timer = setInterval(() => {
      send({ event: "time", data: new Date().toISOString() });
    }, 1000);

    return function clear() {
      clearInterval(timer);
    };
  });
}

Now that we have our resource route ready let's subscribe. In our app/routes/time.tsx file, let's add the following code:

import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { useEventSource } from "remix-utils";

export async function loader() {
  return json({ time: new Date().toISOString() });
}

export default function Time() {
  let loaderData = useLoaderData<typeof loader>();
  let time = useEventSource("/sse/time", { event: "time" }) ?? loaderData.time;

  return (
    <time dateTime={time}>
      {new Date(time).toLocaleTimeString("en", {
        minute: "2-digit",
        second: "2-digit",
        hour: "2-digit",
      })}
    </time>
  );
}

If we visit our route at /time, we'll see the initial time we got from the loader, and then this time will update every second to display the new value coming from our event stream.

Now that we have a simple example, let's do something more interesting. We'll build a simple chat app. To do this, we need a way to store our data and send new messages to the chat and subscribe to them.

Suppose we were using Prisma here as our data storage. Let's create a route to get the current chat messages and create new ones.

// routes/chat.tsx
import type { ActionArgs } from "@remix-run/node";

import { json } from "@remix-run/node";
import { useActionData, useLoaderData, useTransition } from "@remix-run/react";

import { db } from "~/services/db.server";

export async function loader() {
  let messages = await db.messages.findAll();
  return json({ messages });
}

export async function action({ request }: ActionArgs) {
  let formData = await request.formData();

  let message = formData.get("message") as string;

  try {
    await db.messages.create({ data: { content: message } });

    return json(null, { status: 201 });
  } catch (error) {
    if (error instanceof Error) {
      return json({ error: error.message }, { status: 400 });
    }
    throw error;
  }
}

export default function Chat() {
  let loaderData = useLoaderData<typeof loader>();
  let actionData = useActionData<typeof action>();

  let { key } = useTransition();

  return (
    <>
      <Form method="post">
        <label htmlFor="message">Message</label>
        <input type="text" name="message" id="message" required key={key} />
        <button>Send</button>
      </Form>

      <ul>
        {loaderData.messages.map((message) => {
          return <li key={message.id}>{message.content}</li>;
        })}
      </ul>
    </>
  );
}

If we test this, it should let us create new messages, Remix should revalidate the loader, and we'll see the message appear on the screen, but if we open it on a different tab, the second tab will not get new data unless the user reloads the app.

Let's add the real-time part. We will create a route, /chat/subscribe.

// routes/chat.subscribe.ts
import type { LoaderArgs } from "@remix-run/node";
import type { Message } from "@prisma/client";

import { emitter } from "~/services/emitter.server";

import { eventStream } from "remix-utils";

export async function loader({ request }: LoaderArgs) {
  return eventStream(request.signal, function setup(send) {
    function handle(message: Message) {
      send({ event: "new-message", data: message.id });
    }

    emitter.on("message", handle);

    return function clear() {
      emitter.off("message", handle);
    };
  });
}

You'll see this line import { emitter } from "~/services/emitter.server";, let's create that file

import { EventEmitter } from "node:events";
export let emitter = new EventEmitter();

That's it. We're creating an EventEmitter instance and exporting it. Then in our event stream, we're subscribing to the event message and sending the message.id back to the subscribers.

Now, we need to change our action where we create messages to emit this event message.

import { emitter } from "~/services/emitter.server";

export async function action({ request }: ActionArgs) {
  let formData = await request.formData();

  let message = formData.get("message") as string;

  try {
    let message = await db.messages.create({ data: { content: message } });

    emitter.emit("message", message.id);

    return json(null, { status: 201 });
  } catch (error) {
    if (error instanceof Error) {
      return json({ error: error.message }, { status: 400 });
    }
    throw error;
  }
}

Finally, let's make our loader revalidate using the useDataRefresh hook from Remix Utils and subscribe to our event stream.

Note: Ensure you follow the useDataRefresh setup guide on the docs of Remix Utils.

import { useDataRefresh, useEventSource } from "remix-utils";

export default function Chat() {
  let loaderData = useLoaderData<typeof loader>();
  let actionData = useActionData<typeof action>();

  let { key } = useTransition();

  let { refresh } = useDataRefresh();
  let lastMessageId = useEventSource("/chat/subscribe", {
    event: "new-message",
  });

  useEffect(() => refresh(), [lastMessageId]);

  return (
    <>
      <Form method="post">
        <label htmlFor="message">Message</label>
        <input type="text" name="message" id="message" required key={key} />
        <button>Send</button>
      </Form>

      <ul>
        {loaderData.messages.map((message) => {
          return <li key={message.id}>{message.content}</li>;
        })}
      </ul>
    </>
  );
}

This way, every time the lastMessageId change (someone creates a new message), our effect will run refresh, and Remix will revalidate our loader, getting the latest list of messages.