├── .gitignore ├── app ├── tailwind.css ├── routes │ ├── index.tsx │ ├── chat │ │ ├── stream.tsx │ │ └── index.tsx │ └── todos │ │ ├── $listSlug │ │ ├── stream.tsx │ │ └── index.tsx │ │ └── $listSlug.tsx ├── utils │ ├── emitter.server.ts │ ├── db.server.ts │ ├── create-event-stream.server.ts │ └── use-live-loader.ts ├── components │ └── hero.tsx └── root.tsx ├── public └── favicon.ico ├── remix.env.d.ts ├── styles └── app.css ├── prisma ├── migrations │ ├── migration_lock.toml │ ├── 20231026151004_message_model │ │ └── migration.sql │ ├── 20231026151343_fixed_moishis_mistake │ │ └── migration.sql │ └── 20231026141909_init │ │ └── migration.sql └── schema.prisma ├── .eslintrc.cjs ├── remix.config.js ├── .env.example ├── tsconfig.json ├── tailwind.config.ts ├── package.json ├── scripts └── seed.ts └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /.cache 4 | /build 5 | /public/build 6 | .env 7 | -------------------------------------------------------------------------------- /app/tailwind.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moishinetzer/remix-live-loader/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /remix.env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /styles/app.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss/base"; 2 | @import "tailwindcss/components"; 3 | @import "tailwindcss/utilities"; -------------------------------------------------------------------------------- /app/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "@remix-run/node"; 2 | 3 | export function loader() { 4 | return redirect("/todos/groceries"); 5 | } 6 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "sqlite" -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('eslint').Linter.Config} */ 2 | module.exports = { 3 | extends: ["@remix-run/eslint-config", "@remix-run/eslint-config/node"], 4 | }; 5 | -------------------------------------------------------------------------------- /prisma/migrations/20231026151004_message_model/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Message" ( 3 | "id" TEXT NOT NULL PRIMARY KEY, 4 | "message" TEXT NOT NULL 5 | ); 6 | -------------------------------------------------------------------------------- /app/utils/emitter.server.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "events"; 2 | import { remember } from "@epic-web/remember"; 3 | 4 | export const emitter = remember("emitter", () => new EventEmitter()); 5 | -------------------------------------------------------------------------------- /app/utils/db.server.ts: -------------------------------------------------------------------------------- 1 | import { remember } from "@epic-web/remember"; 2 | import { PrismaClient } from "@prisma/client"; 3 | 4 | export const db = remember("db", () => { 5 | return new PrismaClient(); 6 | }); 7 | -------------------------------------------------------------------------------- /app/routes/chat/stream.tsx: -------------------------------------------------------------------------------- 1 | import type { LoaderFunctionArgs } from "@remix-run/node"; 2 | import { createEventStream } from "~/utils/create-event-stream.server"; 3 | 4 | export async function loader({ request }: LoaderFunctionArgs) { 5 | return createEventStream(request, "chat"); 6 | } 7 | -------------------------------------------------------------------------------- /app/routes/todos/$listSlug/stream.tsx: -------------------------------------------------------------------------------- 1 | import type { LoaderFunctionArgs } from "@remix-run/node"; 2 | import { createEventStream } from "~/utils/create-event-stream.server"; 3 | 4 | export function loader({ request, params }: LoaderFunctionArgs) { 5 | return createEventStream(request, params.listSlug!); 6 | } 7 | -------------------------------------------------------------------------------- /remix.config.js: -------------------------------------------------------------------------------- 1 | import { createRoutesFromFolders } from "@remix-run/v1-route-convention"; 2 | 3 | /** @type {import('@remix-run/dev').AppConfig} */ 4 | export default { 5 | ignoredRouteFiles: ["**/.*"], 6 | routes(defineRoutes) { 7 | // uses the v1 convention, works in v1.15+ and v2 8 | return createRoutesFromFolders(defineRoutes); 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /prisma/migrations/20231026151343_fixed_moishis_mistake/migration.sql: -------------------------------------------------------------------------------- 1 | -- RedefineTables 2 | PRAGMA foreign_keys=OFF; 3 | CREATE TABLE "new_Message" ( 4 | "id" TEXT NOT NULL PRIMARY KEY, 5 | "message" TEXT NOT NULL, 6 | "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP 7 | ); 8 | INSERT INTO "new_Message" ("id", "message") SELECT "id", "message" FROM "Message"; 9 | DROP TABLE "Message"; 10 | ALTER TABLE "new_Message" RENAME TO "Message"; 11 | PRAGMA foreign_key_check; 12 | PRAGMA foreign_keys=ON; 13 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Environment variables declared in this file are automatically made available to Prisma. 2 | # See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema 3 | 4 | # Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB. 5 | # See the documentation for all the connection string options: https://pris.ly/d/connection-strings 6 | 7 | DATABASE_URL="postgresql://johndoe:randompassword@localhost:5432/mydb?schema=public" -------------------------------------------------------------------------------- /app/utils/create-event-stream.server.ts: -------------------------------------------------------------------------------- 1 | import { eventStream } from "remix-utils/sse/server"; 2 | import { emitter } from "./emitter.server"; 3 | 4 | export function createEventStream(request: Request, eventName: string) { 5 | return eventStream(request.signal, (send) => { 6 | const handle = () => { 7 | send({ 8 | data: String(Date.now()), 9 | }); 10 | }; 11 | 12 | emitter.addListener(eventName, handle); 13 | 14 | return () => { 15 | emitter.removeListener(eventName, handle); 16 | }; 17 | }); 18 | } 19 | -------------------------------------------------------------------------------- /app/utils/use-live-loader.ts: -------------------------------------------------------------------------------- 1 | import { 2 | useLoaderData, 3 | useResolvedPath, 4 | useRevalidator, 5 | } from "@remix-run/react"; 6 | import { useEffect } from "react"; 7 | import { useEventSource } from "remix-utils/sse/react"; 8 | 9 | export function useLiveLoader() { 10 | const path = useResolvedPath("./stream"); 11 | const data = useEventSource(path.pathname); 12 | 13 | const { revalidate } = useRevalidator(); 14 | 15 | useEffect(() => { 16 | revalidate(); 17 | // eslint-disable-next-line react-hooks/exhaustive-deps -- "we know better" — Moishi 18 | }, [data]); 19 | 20 | return useLoaderData(); 21 | } 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"], 3 | "compilerOptions": { 4 | "lib": ["DOM", "DOM.Iterable", "ES2022"], 5 | "isolatedModules": true, 6 | "esModuleInterop": true, 7 | "jsx": "react-jsx", 8 | "moduleResolution": "Bundler", 9 | "resolveJsonModule": true, 10 | "target": "ES2022", 11 | "strict": true, 12 | "allowJs": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "baseUrl": ".", 15 | "paths": { 16 | "~/*": ["./app/*"] 17 | }, 18 | 19 | // Remix takes care of building everything in `remix build`. 20 | "noEmit": true 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /prisma/migrations/20231026141909_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "TodoList" ( 3 | "id" TEXT NOT NULL PRIMARY KEY, 4 | "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 5 | "updatedAt" DATETIME NOT NULL, 6 | "slug" TEXT NOT NULL, 7 | "title" TEXT NOT NULL 8 | ); 9 | 10 | -- CreateTable 11 | CREATE TABLE "Todo" ( 12 | "id" TEXT NOT NULL PRIMARY KEY, 13 | "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 14 | "updatedAt" DATETIME NOT NULL, 15 | "title" TEXT NOT NULL, 16 | "completed" BOOLEAN NOT NULL DEFAULT false, 17 | "listId" TEXT NOT NULL, 18 | CONSTRAINT "Todo_listId_fkey" FOREIGN KEY ("listId") REFERENCES "TodoList" ("id") ON DELETE RESTRICT ON UPDATE CASCADE 19 | ); 20 | 21 | -- CreateIndex 22 | CREATE UNIQUE INDEX "TodoList_slug_key" ON "TodoList"("slug"); 23 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | export default { 4 | content: ["./app/**/*.{js,jsx,ts,tsx}"], 5 | theme: { 6 | extend: { 7 | colors: { 8 | primary: { 9 | 50: "#f3fffb", 10 | 100: "#ecfdf8", 11 | 200: "#dffbf2", 12 | 300: "#bcf1df", 13 | 400: "#7de3c1", 14 | 500: "#3ed8a4", 15 | 600: "#00cc87", 16 | 700: "#00a36d", 17 | 800: "#007a52", 18 | 900: "#005236", 19 | }, 20 | secondary: { 21 | 50: "#f5f7f9", 22 | 100: "#e6f1f5", 23 | 200: "#c7dfe8", 24 | 300: "#96c5d6", 25 | 400: "#5fa5be", 26 | 500: "#4588a1", 27 | 600: "#326578", 28 | 700: "#264f5e", 29 | 800: "#1f404c", 30 | 900: "#1f343e", 31 | }, 32 | }, 33 | }, 34 | }, 35 | plugins: [require("@tailwindcss/forms")], 36 | } satisfies Config; 37 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | generator client { 5 | provider = "prisma-client-js" 6 | } 7 | 8 | datasource db { 9 | provider = "sqlite" 10 | url = env("DATABASE_URL") 11 | } 12 | 13 | model TodoList { 14 | id String @id @default(uuid()) 15 | createdAt DateTime @default(now()) 16 | updatedAt DateTime @updatedAt 17 | slug String @unique 18 | title String 19 | todos Todo[] 20 | } 21 | 22 | model Todo { 23 | id String @id @default(uuid()) 24 | createdAt DateTime @default(now()) 25 | updatedAt DateTime @updatedAt 26 | title String 27 | completed Boolean @default(false) 28 | list TodoList @relation(fields: [listId], references: [id]) 29 | listId String 30 | } 31 | 32 | model Message { 33 | id String @id @default(uuid()) 34 | message String 35 | createdAt DateTime @default(now()) 36 | } 37 | -------------------------------------------------------------------------------- /app/components/hero.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from "react"; 2 | 3 | export function Hero({ children }: { children?: ReactNode }): JSX.Element { 4 | return ( 5 | <> 6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | {children} 16 |
17 |
18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "", 3 | "private": true, 4 | "sideEffects": false, 5 | "type": "module", 6 | "scripts": { 7 | "build": "remix build", 8 | "dev": "remix dev --manual", 9 | "start": "remix-serve ./build/index.js", 10 | "typecheck": "tsc" 11 | }, 12 | "dependencies": { 13 | "@epic-web/remember": "^1.0.2", 14 | "@prisma/client": "^5.5.2", 15 | "@remix-run/css-bundle": "^2.1.0", 16 | "@remix-run/node": "^2.1.0", 17 | "@remix-run/react": "^2.1.0", 18 | "@remix-run/serve": "^2.1.0", 19 | "@remix-run/v1-route-convention": "^0.1.4", 20 | "@tailwindcss/forms": "^0.5.6", 21 | "isbot": "^3.6.8", 22 | "prisma": "^5.5.2", 23 | "react": "^18.2.0", 24 | "react-dom": "^18.2.0", 25 | "remix-utils": "^7.1.0", 26 | "tailwindcss": "^3.3.5" 27 | }, 28 | "devDependencies": { 29 | "@remix-run/dev": "^2.1.0", 30 | "@remix-run/eslint-config": "^2.1.0", 31 | "@types/react": "^18.2.20", 32 | "@types/react-dom": "^18.2.7", 33 | "eslint": "^8.38.0", 34 | "typescript": "^5.1.6" 35 | }, 36 | "engines": { 37 | "node": ">=18.0.0" 38 | }, 39 | "prisma": { 40 | "seed": "npx tsx ./scripts/seed.ts" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /app/root.tsx: -------------------------------------------------------------------------------- 1 | import { cssBundleHref } from "@remix-run/css-bundle"; 2 | import type { LinksFunction } from "@remix-run/node"; 3 | import { 4 | Links, 5 | LiveReload, 6 | Meta, 7 | Outlet, 8 | Scripts, 9 | ScrollRestoration, 10 | } from "@remix-run/react"; 11 | import styles from "./tailwind.css"; 12 | import { Hero } from "~/components/hero"; 13 | 14 | export const links: LinksFunction = () => [ 15 | ...(cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : []), 16 | { rel: "stylesheet", href: styles }, 17 | ]; 18 | 19 | export default function App() { 20 | return ( 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /app/routes/todos/$listSlug.tsx: -------------------------------------------------------------------------------- 1 | import { json } from "@remix-run/node"; 2 | import { 3 | NavLink as RemixNavLink, 4 | Outlet, 5 | useLoaderData, 6 | } from "@remix-run/react"; 7 | import { db } from "~/utils/db.server"; 8 | 9 | export async function loader() { 10 | return json({ 11 | todoLists: await db.todoList.findMany(), 12 | }); 13 | } 14 | 15 | export default function Demo() { 16 | let { todoLists } = useLoaderData(); 17 | 18 | return ( 19 |
20 |
21 |

22 | TODOS+ 23 |

24 | {todoLists.map((list) => ( 25 | 26 | {list.title} 27 | 28 | ))} 29 |
30 | 31 | 32 |
33 | ); 34 | } 35 | 36 | function NavLink({ to, children }: { to: string; children: React.ReactNode }) { 37 | return ( 38 | 41 | `w-full p-4 pr-8 rounded text-secondary-200 hover:text-white/80 transition-all 42 | ${isActive ? "bg-white/5" : "text-opacity-30 hover:bg-white/[1%]"}` 43 | } 44 | > 45 | {children} 46 | 47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /scripts/seed.ts: -------------------------------------------------------------------------------- 1 | import { db } from "~/utils/db.server"; 2 | 3 | async function seed() { 4 | let startingCreatedAt = new Date(); 5 | const p0 = await db.todoList.create({ 6 | data: { 7 | slug: "wishlist", 8 | title: "Wishlist", 9 | }, 10 | }); 11 | 12 | [ 13 | { 14 | title: "Pet Rock", 15 | completed: true, 16 | createdAt: startingCreatedAt, 17 | }, 18 | { 19 | title: "Crododile Slippers", 20 | completed: false, 21 | createdAt: new Date(startingCreatedAt.getTime() + 1000), 22 | }, 23 | { 24 | title: "Remix v3", 25 | completed: false, 26 | createdAt: new Date(startingCreatedAt.getTime() + 2000), 27 | }, 28 | ].forEach(async (todo) => { 29 | await db.todo.create({ 30 | data: { 31 | listId: p0.id, 32 | ...todo, 33 | }, 34 | }); 35 | }); 36 | 37 | const p1 = await db.todoList.create({ 38 | data: { 39 | slug: "groceries", 40 | title: "Groceries", 41 | }, 42 | }); 43 | 44 | [ 45 | { 46 | title: "Chocolate", 47 | completed: false, 48 | createdAt: startingCreatedAt, 49 | }, 50 | { 51 | title: "Milk", 52 | completed: true, 53 | createdAt: new Date(startingCreatedAt.getTime() + 1000), 54 | }, 55 | { 56 | title: "Mice Cream", 57 | completed: false, 58 | createdAt: new Date(startingCreatedAt.getTime() + 2000), 59 | }, 60 | ].forEach(async (todo) => { 61 | await db.todo.create({ 62 | data: { 63 | listId: p1.id, 64 | ...todo, 65 | }, 66 | }); 67 | }); 68 | 69 | const p2 = await db.todoList.create({ 70 | data: { 71 | slug: "work", 72 | title: "Work", 73 | }, 74 | }); 75 | 76 | [ 77 | { 78 | title: "Write blog post", 79 | completed: false, 80 | createdAt: startingCreatedAt, 81 | }, 82 | { 83 | title: "Write newsletter", 84 | completed: false, 85 | createdAt: new Date(startingCreatedAt.getTime() + 1000), 86 | }, 87 | { 88 | title: "Write docs", 89 | completed: false, 90 | createdAt: new Date(startingCreatedAt.getTime() + 2000), 91 | }, 92 | ].forEach(async (todo) => { 93 | await db.todo.create({ 94 | data: { 95 | listId: p2.id, 96 | ...todo, 97 | }, 98 | }); 99 | }); 100 | } 101 | 102 | // @ts-ignore - we can run this in bun 😎 103 | await seed().then(() => { 104 | console.log("Done seeding"); 105 | }); 106 | -------------------------------------------------------------------------------- /app/routes/chat/index.tsx: -------------------------------------------------------------------------------- 1 | import { json, type ActionFunctionArgs } from "@remix-run/node"; 2 | import { Form } from "@remix-run/react"; 3 | import { db } from "~/utils/db.server"; 4 | import { emitter } from "~/utils/emitter.server"; 5 | import { useLiveLoader } from "~/utils/use-live-loader"; 6 | 7 | export async function action({ request }: ActionFunctionArgs) { 8 | const formData = await request.formData(); 9 | 10 | const message = formData.get("message"); 11 | if (!message || typeof message !== "string") { 12 | throw new Error("you messed up, it's not my fault"); 13 | } 14 | 15 | await db.message.create({ 16 | data: { 17 | message, 18 | }, 19 | }); 20 | 21 | emitter.emit("chat"); 22 | 23 | return null; 24 | } 25 | 26 | export async function loader() { 27 | const messages = await db.message.findMany({ 28 | orderBy: { 29 | createdAt: "asc", 30 | }, 31 | }); 32 | 33 | return json({ messages }); 34 | } 35 | 36 | export default function Index() { 37 | const { messages } = useLiveLoader(); 38 | 39 | return ( 40 |
41 |
42 | 43 | Chat 44 | 45 | 💿 46 |
47 |
48 | {messages.map(({ id, message }) => ( 49 | 50 | ))} 51 |
52 |
53 | 54 |
55 |
56 |
57 | 63 | 64 | 72 |
73 |
74 |
75 |
76 | ); 77 | } 78 | 79 | function Message({ text }: { text: string }) { 80 | return ( 81 |
82 |
83 |
84 | {text[0].toUpperCase()} 85 |
86 |
{text}
87 |
88 |
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 |
94 | 95 | 96 |
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 |
48 |

{list.title}

49 |
50 |
51 | {todos.map((todo) => ( 52 | 53 | ))} 54 | 55 | 56 |
57 |
58 |
59 | ); 60 | } 61 | 62 | function Todo({ 63 | title, 64 | completed, 65 | id, 66 | }: { 67 | title: string; 68 | completed: boolean; 69 | id: string; 70 | }) { 71 | let fetcher = useFetcher(); 72 | let [optimiticTitle, setOptimiticTitle] = useState(title); 73 | 74 | return ( 75 |
76 | { 84 | fetcher.submit( 85 | { 86 | completed: e.target.checked, 87 | id: id, 88 | action: "completed", 89 | }, 90 | { 91 | method: "PUT", 92 | } 93 | ); 94 | }} 95 | className="h-8 w-8 text-primary-500 bg-transparent border-2 border-primary-100/80 rounded focus:ring-0" 96 | /> 97 | { 101 | setOptimiticTitle(e.target.value); 102 | }} 103 | onBlur={() => { 104 | fetcher.submit( 105 | { 106 | title: optimiticTitle, 107 | id: id, 108 | action: "title", 109 | }, 110 | { 111 | method: "PUT", 112 | } 113 | ); 114 | }} 115 | className={`w-full text-primary-100 border text-3xl h-12 bg-transparent border-none outline-none focus:ring-0 ${ 116 | completed ? `line-through opacity-60` : `` 117 | }`} 118 | /> 119 |
120 | ); 121 | } 122 | 123 | function NewTodo() { 124 | let fetcher = useFetcher(); 125 | 126 | return ( 127 |
128 | 133 | { 139 | e.target.value && 140 | fetcher.submit( 141 | { 142 | title: e.target.value, 143 | action: "title", 144 | }, 145 | { 146 | method: "POST", 147 | } 148 | ); 149 | }} 150 | /> 151 |
152 | ); 153 | } 154 | 155 | function assert(condition: any, message: string): asserts condition { 156 | if (!condition) { 157 | throw new Error(message); 158 | } 159 | } 160 | 161 | function handleTodoAction({ 162 | id, 163 | title, 164 | completed, 165 | action, 166 | listSlug, 167 | }: { 168 | id: string | null; 169 | title: string | null; 170 | completed: string | null; 171 | action: string | null; 172 | listSlug: string; 173 | }) { 174 | assert(action, "Action is required"); 175 | 176 | if (!id) { 177 | assert(title, "Title is required"); 178 | 179 | return db.todo.create({ 180 | data: { 181 | title, 182 | completed: false, 183 | list: { 184 | connect: { slug: listSlug }, 185 | }, 186 | }, 187 | }); 188 | } else { 189 | if (action === "title") { 190 | if (!title) { 191 | return db.todo.delete({ where: { id } }); 192 | } else { 193 | return db.todo.update({ 194 | where: { id }, 195 | data: { title }, 196 | }); 197 | } 198 | } else if (action === "completed") { 199 | assert(completed, "Completed is required"); 200 | 201 | console.log(completed); 202 | return db.todo.update({ 203 | where: { id }, 204 | data: { completed: completed === "true" }, 205 | }); 206 | } 207 | } 208 | } 209 | --------------------------------------------------------------------------------