Real-Time Rails Without Turbo: Modern Reactive UIs with Inertia and DexieCable

Real-Time Rails Without Turbo: Modern Reactive UIs with Inertia and DexieCable

不使用 Turbo 的 Rails 实时应用:结合 Inertia 与 DexieCable 构建现代响应式 UI

Hotwire and Turbo Streams have become the default answer for real-time applications in the Rails ecosystem. They are great for HTML-over-the-wire setups, but what if you prefer building your frontend with component frameworks like Svelte, paired with Inertia.js for server-driven routing? When you step outside the Turbo ecosystem, real-time sync often gets cumbersome. Do you re-fetch Inertia page props on every WebSocket event? Do you write custom ActionCable subscribers and manually mutate complex client-side stores? There’s a much cleaner way to handle real-time sync without Turbo—by placing a local database in the middle. Meet DexieCable.

Hotwire 和 Turbo Streams 已成为 Rails 生态系统中实时应用的首选方案。它们非常适合 HTML-over-the-wire(通过网络传输 HTML)的架构,但如果你更倾向于使用 Svelte 等组件框架,并搭配 Inertia.js 进行服务器驱动的路由,该怎么办呢?当你跳出 Turbo 生态系统时,实时同步往往会变得非常繁琐。你是要在每次 WebSocket 事件发生时重新获取 Inertia 页面属性吗?还是编写自定义的 ActionCable 订阅者并手动修改复杂的客户端状态存储?其实有一种更简洁的方法可以在没有 Turbo 的情况下处理实时同步——在中间引入一个本地数据库。这就是 DexieCable。

The Problem: Real-Time in Inertia Apps

问题:Inertia 应用中的实时性挑战

Inertia.js gives us monolith productivity with the rich UI component model of Svelte, Vue, or React. However, handling real-time updates over WebSockets in an Inertia app typically leads to one of two awkward patterns:

  1. Inertia Reloads (router.reload({ only: ['todos'] })): Every WebSocket event triggers a network request back to Rails to fetch updated props. It works, but it causes unnecessary server load and adds latency to UI updates.
  2. Manual Component State Sync: You listen to ActionCable events and manually splice arrays or update objects inside frontend state stores. This scales poorly and quickly leads to brittle client-side logic.

Inertia.js 为我们提供了单体应用的开发效率,同时结合了 Svelte、Vue 或 React 丰富的 UI 组件模型。然而,在 Inertia 应用中通过 WebSocket 处理实时更新通常会导致两种尴尬的模式:

  1. Inertia 重载 (router.reload({ only: ['todos'] })): 每个 WebSocket 事件都会触发一次返回 Rails 的网络请求以获取更新后的属性。这虽然可行,但会导致不必要的服务器负载,并增加 UI 更新的延迟。
  2. 手动组件状态同步: 你需要监听 ActionCable 事件,并手动在前端状态存储中拼接数组或更新对象。这种方式扩展性差,且很快会导致客户端逻辑变得脆弱。

The Solution: Local-First Synchronization with DexieCable

解决方案:使用 DexieCable 实现本地优先同步

DexieCable bridges the gap between Rails (via ActionCable) and client-side IndexedDB (via Dexie.js). Instead of pushing WebSocket updates directly into your UI components, DexieCable allows Rails to execute Dexie.js write operations straight into IndexedDB over ActionCable. Your UI components then subscribe to Dexie using reactive liveQueries.

DexieCable 弥合了 Rails(通过 ActionCable)与客户端 IndexedDB(通过 Dexie.js)之间的鸿沟。DexieCable 不会将 WebSocket 更新直接推送到 UI 组件,而是允许 Rails 通过 ActionCable 直接在 IndexedDB 中执行 Dexie.js 的写入操作。随后,你的 UI 组件只需使用响应式的 liveQueries 订阅 Dexie 即可。

[ Rails / ActionCable ] | [ DexieCable ] | [ Dexie (IndexedDB) ] | [ LiveQuery ]

This architecture gives you some significant benefits:

  • Zero Sync Boilerplate: Components don’t care how data arrived in IndexedDB; they simply observe local tables.
  • Instant UI Updates: UI rendering runs off browser memory/IndexedDB—eliminating network render delays.
  • Decoupled Architecture: ActionCable updates IndexedDB in the background, regardless of which page or component is currently mounted.
  • Declarative Rails Macros: You can automate model broadcasting on the backend using simple ActiveRecord macros.

这种架构带来了显著的优势:

  • 零同步样板代码: 组件无需关心数据是如何进入 IndexedDB 的;它们只需观察本地表即可。
  • 即时 UI 更新: UI 渲染直接基于浏览器内存/IndexedDB,消除了网络渲染延迟。
  • 解耦架构: ActionCable 在后台更新 IndexedDB,无论当前挂载的是哪个页面或组件。
  • 声明式 Rails 宏: 你可以使用简单的 ActiveRecord 宏在后端自动化模型广播。

How It Works in Practice

实践操作

Let’s look at a complete example using Rails, ActionCable, DexieCable, and Svelte.

让我们看一个使用 Rails、ActionCable、DexieCable 和 Svelte 的完整示例。

1. Setting Up the Client (Dexie + DexieCable)

1. 设置客户端 (Dexie + DexieCable)

Configure your Dexie database and point DexieCable to your database instance.

配置你的 Dexie 数据库,并将 DexieCable 指向你的数据库实例。

// db.js
import Dexie from "dexie";
import DexieCable from "dexiecable";

export const db = new Dexie("MyAppDB");
db.version(1).stores({ todos: "id, title, completed, updated_at" });

// Pass your Dexie database instance to DexieCable and subscribe to your channel
DexieCable.db = db;
DexieCable.subscribe("UserChannel");

2. Setting Up Rails (Channel & Model)

2. 设置 Rails (Channel & Model)

First, include DexieCable in your ActionCable channel:

首先,在你的 ActionCable channel 中引入 DexieCable:

# app/channels/user_channel.rb
class UserChannel < ApplicationCable::Channel
  include DexieCable

  def subscribed
    stream_for current_user
  end
end

Next, use the syncs_to_dexie macro on your model:

接下来,在你的模型中使用 syncs_to_dexie 宏:

# app/models/todo.rb
class Todo < ApplicationRecord
  belongs_to :user
  # Automatically syncs create (add), update (put), and destroy (delete) events
  syncs_to_dexie via: UserChannel, to: :user
end

With syncs_to_dexie, whenever a Todo is created, updated, or deleted, DexieCable automatically broadcasts the corresponding Dexie operation targeting the user’s channel stream.

通过 syncs_to_dexie,每当 Todo 被创建、更新或删除时,DexieCable 都会自动广播相应的 Dexie 操作,并将其发送到用户的频道流中。

3. Reactive Rendering in Svelte

3. 在 Svelte 中进行响应式渲染

In your Svelte + Inertia view, query Dexie using liveQuery. When ActionCable pushes changes, Dexie updates IndexedDB, and Svelte reactively re-renders the UI automatically.

在你的 Svelte + Inertia 视图中,使用 liveQuery 查询 Dexie。当 ActionCable 推送更改时,Dexie 会更新 IndexedDB,Svelte 会自动响应式地重新渲染 UI。

<!-- Todos.svelte -->
<script>
  import { db } from './db';
  import { liveQuery } from 'dexie';

  // Observe the local IndexedDB table reactively
  let todos = liveQuery(() => db.todos.toArray());
</script>

<div class="todo-list">
  <h1>Real-Time Todos</h1>
  {#if $todos}
    <ul>
      {#each $todos as todo (todo.id)}
        <li class:completed={todo.completed}>
          {todo.title}
        </li>
      {/each}
    </ul>
  {/if}
</div>

Advanced Query Chaining from Rails

来自 Rails 的高级查询链

syncs_to_dexie covers standard CRUD synchronization, but DexieCable also lets you chain arbitrary Dexie operations directly from Rails controllers or background jobs:

syncs_to_dexie 涵盖了标准的 CRUD 同步,但 DexieCable 还允许你直接从 Rails 控制器或后台任务中链式调用任意 Dexie 操作:

# Single item insert
UserChannel[current_user].table("todos").add(id: 1, title: "Buy milk")

# Modify matching records
UserChannel[current_user]
  .table("todos")
  .where(:completed).equals(false)
  .modify(completed: true)

# Delete specific scopes
UserChannel[current_user]
  .table("todos")
  .where(:project_id).equals(project.id)
  .delete()

DexieCable serializes the method chain into JSON, sends it across ActionCable, and replays the exact operation chain against the local IndexedDB database in the browser.

DexieCable 将方法链序列化为 JSON,通过 ActionCable 发送,并在浏览器中针对本地 IndexedDB 数据库重放该操作链。

Why Choose This Over Turbo Streams?

为什么要选择它而不是 Turbo Streams?

Turbo Streams couple your backend directly to HTML fragment generation or DOM manipulation. By choosing the DexieCable + Inertia + Svelte approach:

  • You keep complete control over your frontend state inside Svelte components.
  • Your backend serves pure data rather than rendering HTML partials over WebSockets.
  • Your UI feels immediate because reads occur locally against IndexedDB.

Turbo Streams 将你的后端直接与 HTML 片段生成或 DOM 操作耦合在一起。通过选择 DexieCable + Inertia + Svelte 方案:

  • 你可以在 Svelte 组件内部完全控制前端状态。
  • 你的后端提供的是纯数据,而不是通过 WebSocket 渲染 HTML 片段。
  • 你的 UI 响应迅速,因为读取操作是在本地针对 IndexedDB 进行的。

Wrapping Up

总结

If you prefer the Rails + Inertia stack but want reactive real-time updates without Turbo, DexieCable provides a lightweight pattern that bridges the gap. Rails manages data and business logic, ActionCable handles transport, Dexie manages local browser storage, and Svelte delivers the UI.

如果你偏爱 Rails + Inertia 技术栈,但又想在不使用 Turbo 的情况下实现响应式实时更新,DexieCable 提供了一种轻量级的模式来弥补这一差距。Rails 管理数据和业务逻辑,ActionCable 处理传输,Dexie 管理本地浏览器存储,而 Svelte 则负责呈现 UI。

Check out the repository on GitHub: 👉 github.com/buhrmi/dexiecable

查看 GitHub 仓库:👉 github.com/buhrmi/dexiecable