ViewComponent Patterns for Reusable UI Components in Maybe Finance

The Maybe application uses a layered ViewComponent architecture with base classes (ApplicationComponent, DesignSystemComponent), namespace separation (UI::* vs DS::*), and slot-based composition (renders_one, renders_many) to build modular, Turbo-ready interface elements.

The open-source personal finance app maybe-finance/maybe implements a disciplined ViewComponent patterns strategy that separates domain-specific views from design-system primitives. By leveraging inheritance, slots, and inline templates, the codebase maintains consistent styling through Tailwind CSS while keeping components testable and composable.

Base Component Hierarchy

Every UI element in Maybe inherits from a chain of base classes that centralize common behavior.

ApplicationComponent

The root class at app/components/application_component.rb extends ViewComponent::Base and mixes in Turbo helpers. This ensures every component can broadcast stream updates and handle frame navigation without additional boilerplate.

DesignSystemComponent

A specialized subclass at app/components/design_system_component.rb serves as the foundation for the DS::* namespace. It provides utilities like class_names for Tailwind class merging and dom_id generation, ensuring consistent styling hooks across the design system.

Namespace Separation: Domain vs. Design System

Maybe strictly separates business logic components from presentational primitives through two namespaces.

UI Components (Domain-Specific)

Components under UI::* inherit from ApplicationComponent and encapsulate business-specific views. For example, app/components/UI/account_page.rb defines an account dashboard that uses slots to accept pluggable sub-components.

DS Components (Design System)

Components under DS::* inherit from DesignSystemComponent and provide styled primitives like buttons, tabs, and menus. These never contain business logic. Examples include:

Slot-Based Composition Patterns

Maybe heavily uses ViewComponent slots to create composable interfaces where parents define structure and children provide content.

Single Slots with renders_one

The renders_one macro defines a named slot that accepts exactly one component. In app/components/UI/account_page.rb, line 4 defines:

renders_one :activity_feed

This generates the with_activity_feed helper, allowing views to inject content:

<%= render UI::AccountPage.new(account: @account) do |page| %>
  <% page.with_activity_feed(feed_data: @feed, pagy: @pagy) %>
<% end %>

Collection Slots with renders_many

The renders_many macro handles lists of sub-components. app/components/DS/tabs.rb uses this for both navigation buttons and content panels:

renders_one :nav, "DS::Tabs::Nav"
renders_many :panels, "DS::Tabs::Panel"

The nested DS::Tabs::Nav component (in app/components/DS/tabs/nav.rb) further uses renders_many :btns to accept multiple tab buttons, creating a deeply composable structure:

<%= render DS::Tabs.new(active_tab: params[:tab]) do |tabs| %>
  <% tabs.with_nav(active_tab: params[:tab]) do |nav| %>
    <% nav.with_btns do |btns| %>
      <% btns.with_btn(id: "activity", label: "Activity") %>
      <% btns.with_btn(id: "holdings", label: "Holdings") %>
    <% end %>
  <% end %>

  <% tabs.with_panel(tab_id: "activity") do %>
    <%= render UI::Account::ActivityFeed.new(feed_data: @feed) %>
  <% end %>
<% end %>

Inline Template Patterns

Maybe uses the erb_template method to co-locate simple markup with Ruby logic, reducing file scatter. In app/components/DS/tabs/nav.rb, the template is defined inline:

erb_template <<~ERB
  <div class="<%= class_names("flex gap-2", @class) %>" ...>
    <%= btn %>
  </div>
ERB

This pattern is ideal for wrapper components that primarily manage layout and classes while delegating content to slots.

Utility Integration and Turbo Support

The base classes provide critical utilities that all components inherit:

  • class_names: Merges Tailwind classes with conditional logic, used throughout DS::* components to handle variants and custom classes.
  • dom_id: Generates stable DOM identifiers for Turbo stream targeting.
  • Turbo Streams: ApplicationComponent includes methods like broadcast_refresh!, allowing any component to trigger real-time updates without explicit channel code.

Summary

  • Inheritance hierarchy: ApplicationComponent → DesignSystemComponent provides Turbo helpers and Tailwind utilities to all UI elements.
  • Namespace separation: UI::* components handle domain logic while DS::* components provide styled, reusable primitives.
  • Slot composition: renders_one and renders_many enable declarative, nested component trees with automatic helper generation (with_*).
  • Inline templates: erb_template keeps simple markup co-located with component logic.
  • Turbo integration: Base classes bake in real-time update capabilities and DOM ID generation for seamless Hotwire workflows.

Frequently Asked Questions

What is the difference between UI and DS namespaces in Maybe's ViewComponent architecture?

UI::* components are domain-specific and inherit from ApplicationComponent, encapsulating business logic for features like account pages or transaction lists. DS::* components are design-system primitives that inherit from DesignSystemComponent and provide purely presentational elements like buttons, tabs, and menus styled with Tailwind CSS.

How do slots work in Maybe's ViewComponent implementation?

Slots use the renders_one and renders_many macros to define injection points in parent components. When UI::AccountPage declares renders_one :activity_feed, ViewComponent automatically generates a with_activity_feed method that views can call to pass child components into the slot, creating a composable, declarative UI structure.

Why does Maybe use inline ERB templates for some components?

The erb_template method keeps markup co-located with Ruby logic for simple wrapper components like DS::Tabs::Nav. This reduces file scattering across the codebase when a component's template is primarily structural—managing Tailwind classes and slot placement—rather than containing complex presentation logic that would benefit from a separate view file.

How does Maybe integrate Turbo streams with ViewComponent?

The ApplicationComponent base class mixes in Turbo helpers like broadcast_refresh!, allowing any component to trigger real-time DOM updates without manual channel configuration. Combined with dom_id generation for stable identifiers, this enables features like live account balance updates to propagate automatically through the component hierarchy.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →