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:
app/components/DS/tabs.rb– Tab container with navigation and panelsapp/components/DS/button.rb– Variant-aware button with Turbo supportapp/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, 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 throughoutDS::*components to handle variants and custom classes.dom_id: Generates stable DOM identifiers for Turbo stream targeting.- Turbo Streams:
ApplicationComponentincludes methods likebroadcast_refresh!, allowing any component to trigger real-time updates without explicit channel code.
Summary
- Inheritance hierarchy:
ApplicationComponent→DesignSystemComponentprovides Turbo helpers and Tailwind utilities to all UI elements. - Namespace separation:
UI::*components handle domain logic whileDS::*components provide styled, reusable primitives. - Slot composition:
renders_oneandrenders_manyenable declarative, nested component trees with automatic helper generation (with_*). - Inline templates:
erb_templatekeeps 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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →