Components are the building blocks of a Funicular UI. If you have used React, the model will feel familiar: a component is a class with a render method, some local state, and props passed in from its parent. This page walks through building components from scratch, then serves as a reference for the component API.
Everything here lives under app/funicular/components/ and is compiled into app.mrb — see Setup if you have not wired that up yet.
Tutorial: a counter, then a list
A component with state
A component inherits from Funicular::Component, defines its initial state in initialize_state, and describes its UI in render using a Ruby DSL where HTML tags are methods:
# app/funicular/components/counter_component.rb
class CounterComponent < Funicular::Component
def initialize_state
{ count: 0 }
end
def increment
patch(count: state.count + 1)
end
def render
div do
p { "Current count: #{state.count}" }
button(onclick: :increment) { "Increment" }
end
end
end
stateis read-only. Read values with dot notation:state.count.patch(...)does a shallow merge into state and triggers a re-render. Assigning to state directly (state.count = 5) raises an error — always go throughpatch.- An event handler is wired by passing a Symbol naming a method (
onclick: :increment). The method receives the DOM event object.
Props and child-to-parent callbacks
Split UI into smaller components and pass data down as props. Props are immutable inside the child. To let a child talk back to its parent, pass a callback as a prop:
# app/funicular/components/todo_item_component.rb
class TodoItemComponent < Funicular::Component
def handle_delete
props[:on_delete].call(props[:todo][:id])
end
def render
li do
span { props[:todo][:text] }
button(onclick: :handle_delete) { "Delete" }
end
end
end
# app/funicular/components/todo_list_component.rb
class TodoListComponent < Funicular::Component
def initialize_state
{ todos: [{ id: 1, text: "Learn Funicular" }] }
end
def handle_delete(id)
patch(todos: state.todos.reject { |t| t[:id] == id })
end
def render
ul do
state.todos.each do |todo|
component(TodoItemComponent, todo: todo, on_delete: method(:handle_delete))
end
end
end
end
Embed a child with component(ChildClass, key: value, ...). Here the parent owns the list state and passes method(:handle_delete) down; the child invokes it with props[:on_delete].call(...). This “state lives in the parent, children report up” pattern is how siblings coordinate too: lift the shared state to the nearest common parent.
Reference
Event handlers
Handlers accept a Symbol, a Method, or a Proc/Lambda:
button(onclick: :handle_click) { "Symbol (most common)" }
component(Child, on_x: method(:handle_x)) # Method: passes a bound callback down
button(onclick: -> { patch(open: true) }) { "Proc: inline logic" }
button(onclick: ->(e) { p e.target[:value] }) { "Proc with event" }
With a Symbol or Method, the event object is passed automatically. With a Proc, receive it only if you declare the parameter. Use a Symbol for ordinary handlers, method(:name) when handing a callback to a child, and a lambda for short inline logic or to capture a value at render time.
State
- Define initial state in
initialize_state(returns a Hash). - Read with
state.key. - Update with
patch(key: value)— shallow merge, then re-render. - For nested state, build the new structure yourself with
merge:
patch(user: state.user.merge(profile: state.user[:profile].merge(name: new_name)))
Prefer flat, normalized, descriptively named state over deep nesting.
Lifecycle hooks
new(props) -> initialize_state -> render -> component_mounted
-> [state changes -> render] ...
-> component_unmounted
component_mountedruns after the component is inserted into the DOM — the place to start subscriptions, load data, or focus an element.component_unmountedruns just before removal — the place to clean up (unsubscribe, clear timers). Always pair setup inmountedwith teardown inunmounted.
Refs
Attach ref: :name to an element and reach the real DOM node via refs[:name] once mounted — for focus management, text selection, or handing the node to a JavaScript library:
class SearchBoxComponent < Funicular::Component
def component_mounted
refs[:search]&.focus
end
def render
input(ref: :search, type: "text", placeholder: "Search...")
end
end
See Styling & Advanced for using refs to drive JS libraries such as Chart.js.
In the demo
funicular-demo shows these patterns at scale:
chat_component.rbcomposes child components and lifts shared chat state.chat_components/holds the child components (message list, input, single message) that report back to the parent via callbacks.blog_post_component.rbusescomponent_mountedto load data when it was not server-rendered.