# ViewComponent Patterns for Reusable UI Components in Maybe Finance

> Discover reusable UI component patterns at Maybe Finance. Explore layered architecture, namespace separation, and slot-based composition for modular, Turbo-ready interfaces.

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

---

**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](https://github.com/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`](https://github.com/maybe-finance/maybe/blob/main/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`](https://github.com/maybe-finance/maybe/blob/main/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`](https://github.com/maybe-finance/maybe/blob/main/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:
- [`app/components/DS/tabs.rb`](https://github.com/maybe-finance/maybe/blob/main/app/components/DS/tabs.rb) – Tab container with navigation and panels
- [`app/components/DS/button.rb`](https://github.com/maybe-finance/maybe/blob/main/app/components/DS/button.rb) – Variant-aware button with Turbo support
- [`app/components/DS/menu.rb`](https://github.com/maybe-finance/maybe/blob/main/app/components/DS/menu.rb) – Dropdown menu with header and items

## 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`](https://github.com/maybe-finance/maybe/blob/main/app/components/UI/account_page.rb), line 4 defines:

```ruby
renders_one :activity_feed

```

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

```erb
<%= 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`](https://github.com/maybe-finance/maybe/blob/main/app/components/DS/tabs.rb) uses this for both navigation buttons and content panels:

```ruby
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`](https://github.com/maybe-finance/maybe/blob/main/app/components/DS/tabs/nav.rb)) further uses `renders_many :btns` to accept multiple tab buttons, creating a deeply composable structure:

```erb
<%= 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`](https://github.com/maybe-finance/maybe/blob/main/app/components/DS/tabs/nav.rb), the template is defined inline:

```ruby
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.