89 | );
90 | }
91 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Remix Live Loader
2 |
3 | ## Introduction
4 |
5 | This repo demonstrates how to use Server-Sent Events to invalidate data on other clients in real-time. It showcases a practical implementation of real-time data updates across multiple clients, ensuring that all users see the most current data as it changes.
6 |
7 | For the best demo of this repo, how it was implemented, and how to use it, I highly recommend watching the following video:
8 |
9 | [Server-Sent Events in Remix](https://www.youtube.com/watch?v=_7yJEC124jM)
10 |
11 | ## Getting Started
12 |
13 | To implement this functionality, incorporate the following files into your app.
14 |
15 | - [`app/utils/emitter.server.ts`](app/utils/emitter.server.ts): Manages the event emission across various clients.
16 |
17 | - [`app/utils/create-event-stream.server.ts`](app/utils/create-event-stream.server.ts): Sets up an event stream for listening to specific events.
18 |
19 | - [`app/utils/use-live-loader.ts`](app/utils/use-live-loader.ts): Extends `useLoaderData` for real-time data revalidation.
20 |
21 | ## Usage
22 |
23 | ### emitter
24 |
25 | ```tsx
26 | const emitter: EventEmitter;
27 | ```
28 |
29 | An EventEmitter singleton used across all requests to emit events to the event stream. Example use case: Emitting a 'new-message' event when a new chat message is received.
30 |
31 | ### createEventStream
32 |
33 | ```tsx
34 | function createEventStream(request: Request, eventName: string): EventStream;
35 | ```
36 |
37 | This function initializes an event stream that listens for the specified event name. It sends an event with the current timestamp as data and includes cleanup logic for memory optimization.
38 |
39 | ### useLiveLoader
40 |
41 | ```tsx
42 | function useLiveLoader(): SerializeFrom;
43 | ```
44 |
45 | This function extends useLoaderData, automatically revalidating all data upon event stream triggers. Ideal for real-time data updates on pages like live chat or notifications.
46 |
47 | Listens to events being emitted to the current path + `/stream` and revalidates the data when events are received.
48 |
49 | ## Walkthrough
50 |
51 | 1. **Stream Setup**: Create `stream.tsx` in the relevant directory. This route will manage the event stream. For example, for a `/chat` route, set up a corresponding `/chat/stream`.
52 |
53 | 2. **Event Listening**: Use `createEventStream` in the `stream` route's loader function to listen for events. For a chat application, this could be listening for new messages in a chat room.
54 |
55 | ```tsx
56 | import type { LoaderFunctionArgs } from "@remix-run/node";
57 | import { createEventStream } from "~/utils/create-event-stream.server";
58 |
59 | export function loader({ request, params }: LoaderFunctionArgs) {
60 | // Here we are listening for events emitted to "chat" and returning an event stream
61 | return createEventStream(request, "chat");
62 | }
63 | ```
64 |
65 | 3. **Data Revalidation**: Implement `useLiveLoader` in your data-serving route to automatically revalidate data with each event. In a chat application, this ensures that the chat view updates in real-time as new messages arrive.
66 |
67 | ```tsx
68 | import { useLiveLoader } from "~/utils/use-live-loader";
69 | import { json } from "@remix-run/node";
70 |
71 | export async function loader() {
72 | let chats = await db.chats.findMany();
73 |
74 | return json({
75 | chats,
76 | });
77 | }
78 |
79 | export default function Chat() {
80 | // Here we are using the useLiveLoader hook to get the data from the loader function
81 | // and revalidate it whenever the event stream is triggered
82 | let { chats } = useLiveLoader();
83 |
84 | return (
85 |
86 |
Chat
87 |
88 | {chats.map((chat) => (
89 |
{chat.message}
90 | ))}
91 |
92 |
93 |
97 |
98 | );
99 | }
100 | ```
101 |
102 | 4. **Triggering Updates**: Utilize the `emitter` in routes where you want to trigger updates (like after creating a new chat message). This ensures all clients connected to the event stream receive real-time updates.
103 |
104 | ```tsx
105 | import { emitter } from "~/utils/emitter.server";
106 | import { json, type ActionFunctionArgs } from "@remix-run/node";
107 |
108 | export async function action({ request }: ActionFunctionArgs) {
109 | let formData = await request.formData();
110 | let message = formData.get("message");
111 |
112 | await db.chats.create({
113 | data: {
114 | message,
115 | },
116 | });
117 |
118 | // Here we are emitting an event to the "chat" event stream
119 | // which will trigger a revalidation of the data in the useLiveLoader hook
120 | // for all clients listening to the event stream
121 | emitter.emit("chat");
122 |
123 | return null;
124 | }
125 | ```
126 |
127 | ## Acknowledgements
128 |
129 | Special thanks to [Alex Anderson](https://twitter.com/ralex1993) and his [great talk](https://www.youtube.com/watch?v=cAYHw_dP-Lc) at RemixConf 2023 which inspired this repo.
130 |
131 | Also, a shoutout to the Remix team and [Brooks Lybrand](https://twitter.com/BrooksLybrand) for hosting me and for all the support.
132 |
133 | ## Contributing
134 |
135 | Contributions are welcome! Feel free to open issues for bugs or feature requests, or submit PRs for improvements. Please ensure your contributions are well-documented and tested.
136 |
--------------------------------------------------------------------------------
/app/routes/todos/$listSlug/index.tsx:
--------------------------------------------------------------------------------
1 | import type { DataFunctionArgs } from "@remix-run/node";
2 | import { useFetcher } from "@remix-run/react";
3 | import { useState } from "react";
4 | import { db } from "~/utils/db.server";
5 | import { emitter } from "~/utils/emitter.server";
6 | import { useLiveLoader } from "~/utils/use-live-loader";
7 |
8 | export async function action({ request, params }: DataFunctionArgs) {
9 | let formData = await request.formData();
10 | let id = formData.get("id") as string | null;
11 | let completed = formData.get("completed") as string | null;
12 | let title = formData.get("title") as string | null;
13 | let action = formData.get("action") as string | null;
14 |
15 | await handleTodoAction({
16 | id,
17 | title,
18 | completed,
19 | action,
20 | listSlug: params.listSlug!,
21 | });
22 |
23 | emitter.emit(params.listSlug!);
24 |
25 | return null;
26 | }
27 |
28 | export async function loader({ params }: DataFunctionArgs) {
29 | let listWithTodos = await db.todoList.findFirstOrThrow({
30 | where: { slug: params.listSlug! },
31 | include: {
32 | todos: {
33 | orderBy: { createdAt: "asc" },
34 | },
35 | },
36 | });
37 |
38 | let { todos, ...list } = listWithTodos;
39 |
40 | return { list, todos, time: Date.now() };
41 | }
42 |
43 | export default function Index() {
44 | let { list, todos, time } = useLiveLoader();
45 |
46 | return (
47 |