# How Hotwire Turbo Powers SPA-like Interactions in Maybe Finance

> Discover how Maybe Finance achieves SPA-like interactions using Hotwire Turbo frames, streams, and Stimulus. Learn to enhance your web app with server-rendered HTML and progressive enhancement.

- Repository: [Maybe/maybe](https://github.com/maybe-finance/maybe)
- Tags: architecture
- Published: 2026-03-07

---

**Maybe Finance uses Hotwire Turbo Frames, Turbo Streams, and Stimulus controllers to deliver a single-page application experience without a heavy JavaScript framework, relying instead on server-rendered HTML and progressive enhancement.**

The open-source personal finance application [maybe-finance/maybe](https://github.com/maybe-finance/maybe) implements a modern **Hotwire Turbo SPA-like interaction** model that eliminates the complexity of traditional frontend frameworks. By combining Turbo Frames for partial page updates, Turbo Streams for real-time server broadcasting, and Stimulus for lightweight UI behaviors, the codebase remains Ruby-centric while providing fluid, app-like navigation that intercepts link clicks and form submissions to update only specific DOM regions.

## Turbo Frame Architecture and Initial Page Structure

The foundation of Maybe’s interactive frontend lies in strategic use of **Turbo Frames**, defined throughout the ERB view layer to create independent, lazily-loadable content regions.

### Layout-Level Frame Definition

The main layout at `app/views/layouts/application.html.erb` establishes a hierarchy of frames for distinct UI areas including sidebars, main content, modals, and a chat component. These containers use Rails helpers to generate the custom `<turbo-frame>` elements:

```erb
<%= turbo_frame_tag chat_frame, src: chat_view_path(@chat), loading: "lazy", class: "h-full" do %>

```

Each frame can specify a `src` attribute, instructing Turbo to fetch content via AJAX the first time the frame becomes visible, while the `loading: "lazy"` directive defers the network request until necessary.

### Component-Level Frame Integration

ViewComponents throughout the app include Turbo helper methods via [`app/components/application_component.rb`](https://github.com/maybe-finance/maybe/blob/main/app/components/application_component.rb), which incorporates `Turbo::FramesHelper` and `Turbo::StreamsHelper`. This allows reusable components to define their own frame boundaries:

```erb
<%= turbo_frame_tag dom_id(account, :sparkline), src: sparkline_account_path(account), loading: "lazy" do %>
  <div class="animate-pulse bg-gray-200 h-24 w-full"></div>
<% end %>

```

## Client-Side Navigation with Turbo Drive

The Turbo Drive driver initializes in [`app/javascript/application.js`](https://github.com/maybe-finance/maybe/blob/main/app/javascript/application.js) with a single import:

```javascript
import "@hotwired/turbo-rails";

```

Once loaded, this driver intercepts all `<a>` click and `<form>` submission events. Rather than triggering full page navigations, Turbo submits XHR requests, receives HTML fragments from the server, and swaps the targeted `<turbo-frame>` content—or the entire `<body>` when no specific frame is targeted. This creates the **SPA-like interaction** feel while maintaining server-side routing and state management.

## Enhancing Frames with Stimulus Controllers

While Turbo handles navigation and updates, **Stimulus** manages UI behaviors that fall outside Turbo’s scope. The [`config/importmap.rb`](https://github.com/maybe-finance/maybe/blob/main/config/importmap.rb) file pins Stimulus alongside Turbo:

```ruby
pin "@hotwired/stimulus", to: "stimulus.min.js"

```

### Handling Network Timeouts

The [`app/javascript/controllers/turbo_frame_timeout_controller.js`](https://github.com/maybe-finance/maybe/blob/main/app/javascript/controllers/turbo_frame_timeout_controller.js) controller manages loading states for frames that might hang. It attaches to frames with network requests and implements a default 10-second timeout:

```javascript
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static values = { timeout: { type: Number, default: 10_000 } }

  connect() {
    this.timeoutId = setTimeout(() => this.handleTimeout(), this.timeoutValue)
    this.element.addEventListener("turbo:frame-load", this.clearTimeout.bind(this))
  }

  clearTimeout() {
    if (this.timeoutId) { 
      clearTimeout(this.timeoutId)
      this.timeoutId = null 
    }
  }

  handleTimeout() {
    this.element.innerHTML = `<div class="flex items-center gap-1">
      <svg class="text-warning w-4 h-4">...</svg>
      <p class="text-xs text-warning">Request timed out</p>
    </div>`
  }
}

```

Usage in views combines the frame tag with controller data attributes:

```erb
<%= turbo_frame_tag "#{account_group.key}_sparkline",
      src: accountable_sparkline_path(account_group.key),
      loading: "lazy",
      data: { controller: "turbo-frame-timeout",
              turbo_frame_timeout_timeout_value: 10_000 } do %>
  <div class="flex items-center justify-center h-24">
    <%= icon("loader-circle", class: "animate-spin") %>
  </div>
<% end %>

```

### Preserving Scroll Position

The [`app/javascript/controllers/preserve_scroll_controller.js`](https://github.com/maybe-finance/maybe/blob/main/app/javascript/controllers/preserve_scroll_controller.js) maintains scroll positions across Turbo navigations. It listens to lifecycle events including `turbo:before-cache`, `turbo:before-render`, and `turbo:render` to remember and restore scroll positions for elements with unique IDs, ensuring users do not lose their place during partial page updates.

## Real-Time Updates via Turbo Streams

Beyond request-response cycles, Maybe implements **Turbo Streams** for server-pushed updates. The `UI::AccountPage` component demonstrates this pattern in [`app/components/UI/account_page.rb`](https://github.com/maybe-finance/maybe/blob/main/app/components/UI/account_page.rb) with the `broadcast_refresh!` method:

```ruby
def broadcast_refresh!
  Turbo::StreamsChannel.broadcast_replace_to(
    broadcast_channel,
    target: id,
    renderable: self,
    layout: false
  )
end

```

When account data changes—such as when new transactions arrive—calling `broadcast_refresh!` pushes HTML directly to subscribed browsers via WebSockets. Turbo automatically swaps the DOM element matching the `target` ID with the fresh server-rendered content, updating the UI instantly without page reloads or manual JavaScript manipulation.

## Import-Map Configuration and Asset Loading

Maybe eliminates Node.js bundlers by using **Import Maps** to serve JavaScript directly. The [`config/importmap.rb`](https://github.com/maybe-finance/maybe/blob/main/config/importmap.rb) configuration pins the required libraries:

```ruby
pin "@hotwired/turbo-rails", to: "turbo.min.js", preload: true
pin "@hotwired/stimulus", to: "stimulus.min.js"
pin_all_from "app/javascript/controllers", under: "controllers"

```

This approach loads Turbo and Stimulus as static assets from the Rails server, reducing build complexity and keeping the frontend lightweight while supporting the full **Hotwire Turbo SPA-like interaction** stack.

## Summary

- **Turbo Frames** in `app/views/layouts/application.html.erb` and component views create independent, lazily-loaded page regions that update without full reloads.
- **Turbo Drive** intercepts navigation in [`app/javascript/application.js`](https://github.com/maybe-finance/maybe/blob/main/app/javascript/application.js) to transform standard links and forms into XHR requests that swap targeted frame content.
- **Stimulus controllers** like [`turbo_frame_timeout_controller.js`](https://github.com/maybe-finance/maybe/blob/main/turbo_frame_timeout_controller.js) and [`preserve_scroll_controller.js`](https://github.com/maybe-finance/maybe/blob/main/preserve_scroll_controller.js) handle edge cases including network timeouts and scroll preservation.
- **Turbo Streams** enable real-time updates via `Turbo::StreamsChannel.broadcast_replace_to`, allowing server-side Ruby to push UI changes to connected clients.
- **Import Maps** in [`config/importmap.rb`](https://github.com/maybe-finance/maybe/blob/main/config/importmap.rb) serve the entire stack without complex JavaScript build tools, maintaining a Ruby-centric development experience.

## Frequently Asked Questions

### What is the difference between Turbo Frames and Turbo Streams in Maybe Finance?

**Turbo Frames** handle partial page updates in response to user actions like clicks or form submissions, isolating specific DOM regions that refresh independently. **Turbo Streams** push server-initiated updates to all connected clients via WebSockets, enabling real-time UI changes when backend data changes—such as when `UI::AccountPage#broadcast_refresh!` broadcasts a new account balance without requiring a page refresh.

### How does Maybe handle slow-loading Turbo Frames?

The application attaches a `turbo-frame-timeout` Stimulus controller to frames with lazy-loaded `src` attributes. This controller starts a timer when the frame connects and clears it upon the `turbo:frame-load` event; if the request exceeds the default 10-second threshold, the controller replaces the loading spinner with a timeout error message directly in the frame.

### Why does Maybe use Import Maps instead of Webpack or Vite?

Import Maps allow Maybe to pin Hotwire libraries (`@hotwired/turbo-rails`, `@hotwired/stimulus`) and serve them as static JavaScript files directly through the Rails asset pipeline. This eliminates the need for Node.js build tools, reduces deployment complexity, and keeps the frontend architecture aligned with the Ruby-centric backend while still supporting modern **SPA-like interactions**.

### Where does the initial Turbo Frame structure get defined?

The primary frame hierarchy is established in `app/views/layouts/application.html.erb`, which defines top-level frames for the sidebar, main content area, modal containers, and chat interface. Individual components and partials then nest additional frames using `turbo_frame_tag` helpers to create granular, independently updatable UI segments throughout the application.