Skip to main content

Imperative DOM Operations in Trellis

Philosophy

Trellis is a declarative UI framework - you describe what the UI should look like based on state, not how to update it. However, some DOM operations are inherently imperative:

  • Browser-driven side effects (focus, scroll)
  • Reading current DOM state (measurements, positions)
  • One-time actions that don't map to state updates
  • Integration with third-party libraries

This document describes Trellis's API for these imperative operations.

Two Approaches

1. Events - For Triggering Actions

Use events when you need to trigger a one-time imperative action (focus, scroll, play/pause).

from dataclasses import dataclass, field
from trellis.core import component, Elements
from trellis.core.state import Stateful
from trellis.core.events import FocusEvent

@dataclass(kw_only=True)
class SearchState(Stateful):
query: str = ""
focus_input: FocusEvent = field(default_factory=FocusEvent)

@component
def SearchForm(state: SearchState) -> Elements:
def reset():
state.query = ""
state.focus_input.fire() # Triggers focus imperatively

return Column(
Input(
value=state.query,
focus=state.focus_input, # Subscribe to event
on_input=lambda e: setattr(state, 'query', e.target.value)
),
Button("Reset", on_click=reset)
)

Key characteristics:

  • One-way: firing the event triggers the imperative operation
  • No state to sync back
  • Clean and declarative from the caller's perspective

2. Refs - For Reading State

Use refs when you need to read DOM state or make multiple imperative calls to the same element.

from trellis.core import component, Elements, InputRef
from trellis.core.state import Stateful

@dataclass(kw_only=True)
class FormState(Stateful):
query: str = ""

@component
def SearchForm(state: FormState) -> Elements:
input_ref = InputRef() # Create separately

def handle_submit(e):
# Read current selection
start = input_ref.selection_start
end = input_ref.selection_end

# Imperative calls
input_ref.blur()

# ... process form

def handle_key(e):
if e.key == "Escape":
input_ref.blur()

return Column(
Input(
ref=input_ref, # Pass in via ref=
value=state.query,
on_input=lambda e: setattr(state, 'query', e.target.value),
on_keydown=handle_key
),
Button("Search", on_click=handle_submit)
)

Key characteristics:

  • Two-way: can read properties and call methods
  • Direct access to the underlying DOM node
  • Type-safe methods and properties per element type

Element-Specific Ref Classes

Each DOM element type has its own ref class with appropriate methods:

InputRef

input_ref = InputRef()

# Methods
input_ref.focus()
input_ref.blur()
input_ref.select()

# Properties (read-only)
input_ref.value
input_ref.selection_start
input_ref.selection_end

VideoRef

video_ref = VideoRef()

# Methods
video_ref.play()
video_ref.pause()

# Properties
video_ref.current_time # Read/write
video_ref.paused # Read-only
video_ref.duration # Read-only

CanvasRef

canvas_ref = CanvasRef()

# Methods
ctx = canvas_ref.get_context('2d')

# Properties
canvas_ref.width
canvas_ref.height

DivRef (for measurements and scroll)

div_ref = DivRef()

# Methods
rect = div_ref.get_bounding_client_rect()
div_ref.scroll_into_view()

# Properties
div_ref.scroll_top # Read/write
div_ref.scroll_left # Read/write
div_ref.offset_width # Read-only
div_ref.client_height # Read-only

Event Types

FocusEvent

focus_event = FocusEvent()
focus_event.fire() # Triggers element.focus()

ScrollEvent

@dataclass(kw_only=True)
class ListState(Stateful):
scroll_to_top: ScrollEvent = field(default_factory=ScrollEvent)

@component
def List(state: ListState, items: list) -> Elements:
return Div(
scroll=state.scroll_to_top,
children=[Item(i) for i in items]
)

PlayEvent / PauseEvent (for media)

@dataclass(kw_only=True)
class VideoState(Stateful):
play: PlayEvent = field(default_factory=PlayEvent)
pause: PauseEvent = field(default_factory=PauseEvent)

Combining Events and Refs

Sometimes you need both:

@dataclass(kw_only=True)
class VideoPlayerState(Stateful):
is_playing: bool = False
seek_to: SeekEvent = field(default_factory=SeekEvent)

@component
def VideoPlayer(state: VideoPlayerState, src: str) -> Elements:
video_ref = VideoRef()

def toggle_play():
if state.is_playing:
video_ref.pause()
state.is_playing = False
else:
video_ref.play()
state.is_playing = True

def handle_time_update(e):
# Read current position from ref
current = video_ref.current_time
# ... update progress bar

return Column(
Video(
ref=video_ref,
seek=state.seek_to,
src=src,
on_time_update=handle_time_update
),
Button(
"Play" if not state.is_playing else "Pause",
on_click=toggle_play
)
)

Design Principles

  1. Prefer events for one-time actions - They're declarative and don't require managing ref lifecycle

  2. Use refs for reading state - Measurements, positions, computed styles, form values

  3. Events are one-way - Fire and forget. No bidirectional syncing needed.

  4. Refs are escape hatches - Use them when you need fine-grained control or must read from the DOM

  5. Type safety - Each element type has its own ref class with appropriate methods

When NOT to Use Imperative Operations

Don't use imperative operations for things that should be declarative:

# ❌ BAD - Don't use refs to set values
def update_input():
input_ref.value = "new value" # Don't do this!

# ✅ GOOD - Update state and let Trellis update the DOM
def update_input():
state.query = "new value"
# ❌ BAD - Don't manually show/hide elements
def toggle_visibility():
div_ref.style.display = "none" # Don't do this!

# ✅ GOOD - Use conditional rendering
@component
def MyComponent(state: State) -> Elements:
return Div(
style=h.Style(display="block" if state.visible else "none")
)

Integration with Third-Party Libraries

Refs are essential when integrating with libraries that expect DOM nodes:

from trellis.core import component, DivRef
import plotly.graph_objects as go

@component
def PlotlyChart(data: list) -> Elements:
container_ref = DivRef()

def on_mount():
# Pass the DOM node to Plotly
fig = go.Figure(data=data)
fig.show(container_ref.element)

return Div(
ref=container_ref,
on_mount=on_mount
)

Summary

Use CaseApproachExample
Trigger focusEventFocusEvent().fire()
Trigger scrollEventScrollEvent().fire()
Play/pause videoEventPlayEvent().fire()
Read input selectionRefinput_ref.selection_start
Measure elementRefdiv_ref.get_bounding_client_rect()
Check scroll positionRefdiv_ref.scroll_top
Third-party libraryRefplotly.show(div_ref.element)

The general rule: events for actions, refs for reading.