Views and composition
A view includes CryKit::View and declares its HTML/ECR file with template "welcome". Declare mutable, view-owned values that affect rendering with reactive. See the complete counter example for a starting point.
The examples below are contextual snippets; layout and component definitions belong alongside your application views, and mount calls belong inside routes.
Reactive local state
Use reactive for values such as counts, form input, loading flags, and displayed results that change during the view's lifetime. Keep dependencies such as @app, resource handles, stable references to child views, and internal bookkeeping in ordinary instance variables. Compute derived values from state when possible instead of storing another copy. Reactive getters return ordinary typed values and record dependencies while rendering; writes schedule only their current rendering consumers. Reads in event handlers and background callbacks do not subscribe.
Declare fields with reactive, then read and assign them through reactive.field in methods and templates. For an ordinary Person class with a writable name property:
class Profile
include CryKit::View
reactive count : Int32 = 0
reactive person : Person = Person.new("Alice")
reactive items : Array(String) = [] of String
private def update_profile : Nil
reactive.count += 1
mutate reactive.person do |person|
person.name = "Bob"
end
mutate reactive.items do |items|
items << "New item"
end
nil
end
template "profile"
endSave profile.ecr beside the Crystal source file declaring the template:
<p><%= reactive.count %></p>
<p><%= reactive.person.name %></p>
<p><%= reactive.items.size %></p>
<button on_click=<%~ -> { update_profile } %>>Update profile</button>Each view instance owns its values. Assign through the generated setter to replace a value, such as reactive.person = Person.new("Carol"). Assignments and mutate reactive.field blocks invalidate the bindings that read that field. Each text, attribute, or property binding tracks its reactive reads, including reads inside ordinary helper methods. Changing reactive.count above reevaluates its text expression; the other paragraphs remain untouched. The template method and ordinary static HTML are constructed once for that template instance.
The update cycle coalesces invalidations during a click, input callback, or job.notify delivery. A field with no rendering consumers schedules no update. Dependencies follow the latest successful evaluation: a field read only in an inactive conditional branch stops triggering that consumer. Removing a rendered region detaches its consumers; retaining the view preserves its stored values.
Use explicit ECR regions for changing structure:
<% if reactive.items.empty? %>
<p>No items yet.</p>
<% else %>
<ul>
<% reactive.items.each do |item| %>
<li key=<%= item %>><%= item %></li>
<% end %>
</ul>
<% end %>if and unless retain the selected branch while its selection stays the same. Each iteration declares an explicit key on its repeated element; this example assumes unique strings. The compiler consumes this metadata without emitting a key HTML attribute. A region can contain zero, one, or several siblings. Reordering keyed items moves their existing DOM ranges. Replacing an item at an existing key refreshes its captured readers and callbacks; a compatible template shape preserves its DOM identities. See conditional and keyed regions and rendering for details.
Keep derived calculations inside expressions or helper methods called by those expressions. A local computed before the template is constructed retains its initial value. Arbitrary executable ECR statements are unsupported; state changes belong in Crystal methods and callbacks.
Mounted fragments use dependency-driven updates. Declare a field with reactive when changes to it should update the bindings that read it, including fields on shared models. Ordinary instance variables remain non-reactive. A callback or worker delivery that changes no reactive rendering dependency performs no rendering work.
Field names must be unique and cannot collide with an existing getter or setter. Conflicting declarations fail at compile time.
Getters return the actual typed values, so custom objects and collections need no wrapper when read. Mutating them directly, such as reactive.items << "New item", does not request a render; use mutate reactive.items do |items| ... end to track an in-place change. The target must be one of this object's reactive fields. A mutation block keeps the stored object, returns its block result, and invalidates the field's consumers after the block finishes, including when it raises after a partial mutation. State changes are not rolled back on failure.
Each reactive declaration generates a typed getter and setter. The reactive receiver provides access to that state inside the object; other callers can use model.field and model.field = value. No field-specific updater methods are generated. mutate also accepts a named callback, such as mutate reactive.items, &append_item.
String properties support form bindings:
<input binds=<%= reactive.name %>>
<textarea binds=<%= reactive.notes %>></textarea>These bindings read the reactive string getter and assign input through its notifying setter. Attribute expressions also occupy their whole unquoted value, for example disabled=<%= reactive.loading %>. Callbacks use on_click=<%~ -> { save } %>. Ordinary scalar output uses <%= expression %> and reaches the browser as text, including strings containing HTML.
State also works before mounting and during standalone rendering. Standalone serialization closes its temporary binding subscriptions when it finishes. A field can be read by another mounted view; assigning it schedules those readers even when the view that stores it is unmounted. Fields without mounted readers change without scheduling rendering. reactive does not synchronize mutable values. Background work must still deliver changes through job.notify, on the view's UI update queue.
Reactive shared models
Include CryKit::ReactiveState in an application-owned model to declare reactive fields with the same reactive syntax. The model needs no view or mount methods:
class Playback
include CryKit::ReactiveState
reactive paused : Bool = false
def paused? : Bool
reactive.paused
end
def toggle_pause : Nil
reactive.paused = !reactive.paused
nil
end
endA view can read @playback.paused? inside an ECR expression. The helper's state read subscribes that rendering consumer automatically, and @playback.toggle_pause updates every mounted reader. The application owns the model's lifetime; unmounting a reader removes its subscriptions without discarding the model's values. Deliver mutations on the UI context, just as for view-owned state. Plain model instance variables remain ordinary values and do not publish changes. A replaceable model reference must also be reactive when replacing it should update readers. The demo's shared clock uses this mixin for its Pause / Resume status.
Layouts and composition
Routes use mount(view) with an optional layout:. A document layout includes CryKit::Layout and declares its ECR file with template "main". It renders once and includes the supplied content through <% yield %> exactly once. Layouts have no view lifecycle methods; subsequent events update the mounted view.
class AppLayout
include CryKit::Layout
template "main"
private def document_title : String
"Counter app"
end
end
# Inside a route:
mount Counter.new, layout: AppLayout.newThe adjacent main.ecr supplies the document markup and yield slot:
<!doctype html>
<html lang="en">
<head><title><%= document_title %></title></head>
<body><% yield %></body>
</html>The HTML title accepts scalar values and escapes their text. It is evaluated once per rendered layout, alongside the rest of the document shell.
Template filenames resolve relative to the declaring Crystal source file, independently of the CLI working directory. The .ecr extension is optional. ECR expressions can access the declaring object's state and helper methods.
Inside an ECR file, <% render @child %> composes a retained child view. render accepts a view or a template and preserves a view's lifecycle owner and render context internally. Store the child instance where its state should live:
class Welcome
include CryKit::View
@counter = Counter.new
template "welcome"
end<section class="card">
<h1>Welcome</h1>
<% render @counter %>
<p>Ready to begin? <a href="/counter">Open the counter</a>.</p>
</section>A reusable wrapper ECR file can also contain <% yield %>; see custom template construction for passing its child template. Static composition runs once. For a reactive view reference that may change, use <% mount reactive.selected_child %>; selecting a different view replaces its template and lifetime. See composition and lifetime.
Retained state and standalone rendering
Composed children share their parent's mount and update cycle, while keeping their own update scopes. Store a child instance in an instance variable to retain its state across removal and later reinsertion. Document layouts containing <!doctype html>, <html>, and <body> belong in mount(..., layout: ...), outside the retained fragment.
Views expose to_html(layout: ...) for standalone HTML generation. Handlers expose render(view, layout: ...) for response-scoped HTML without retaining a mount or starting background jobs and lifecycle hooks. The temporary template session is closed after serialization. mount requires a navigation owner and document; standalone HTML uses render.
A view instance can belong to only one live mounting. Create separate instances for separate windows. Keep state that should survive navigation on the application and inject it into views; see Application.
View stylesheets
Declare stylesheet asset dependencies in the view class, alongside the template declaration:
class Profile
include CryKit::View
stylesheet "profile.css"
stylesheet "components/avatar.css", "components/badge.css"
template "profile"
endNames are literal asset names relative to the application's configured assets directory, using the same names as asset_path. The CSS files stay in that directory and crykit build embeds them through the existing asset packaging. Multiple declarations and inherited declarations compose; the parent's declarations come first. Shared document styles, such as a reset or theme, can stay in the layout's ordinary <link rel="stylesheet"> elements. The demo's page views declare their own stylesheets, while its layout loads base.css.
A mount includes dependencies from the views it currently renders, including composed children. Adding a child loads its stylesheet; removing that child releases it. The browser keeps one managed link per asset URL across mounts in the same document and removes it when its last consuming mount releases it. Removing one instance therefore leaves CSS needed by another instance active. Navigation and document disposal release these links automatically. Existing CSS asset hotreload also refreshes declared stylesheets.
Initial styles follow the layout's styles in view render order and declaration order, with duplicate URLs included once. Active links keep their position when views move or dependencies change; newly needed styles append after them. Stylesheet loading is asynchronous and mount hooks do not wait for CSS to load.
The scope here is the stylesheet's lifetime. Its selectors still apply to the whole document, so use ordinary CSS classes to avoid unwanted overlap. CryKit does not rewrite selectors or create a Shadow DOM boundary.
Standalone to_html and handler render include the currently composed views' dependencies as ordinary stylesheet links. They create no retained mount or stylesheet cleanup lifecycle, just as standalone rendering runs no view hooks.
View JavaScript
Declare JavaScript asset dependencies on the view that needs them:
class Greeting
include CryKit::View
script "rolling-name.js"
reactive name : String = "stranger"
template "greeting"
end<rolling-name value=<%= reactive.name %>>stranger.</rolling-name>Names are literal asset names relative to the configured assets directory, just like stylesheet and asset_path. Repeated and inherited declarations compose. A mount collects dependencies from its current rendered view graph, including conditional children, and publishes changes with the corresponding DOM updates. Standalone rendering includes ordinary script tags too. Existing asset packaging embeds the files without a separate JavaScript build step.
Scripts are ES modules (type="module"), so they have their own scope, support relative imports, and are deferred automatically. There is no defer: option. Use import to express ordering dependencies between modules. Dynamically added modules load asynchronously; view mount hooks and mutation acknowledgements do not wait for them. Custom elements automatically upgrade existing instances and observe their latest attributes when the module arrives.
Each module URL executes once per document, including when several views need it, a child is removed and added again, or the layout loads the same URL. Removing a view cannot undo JavaScript evaluation. Modules should register components or other reusable behavior; component instances should acquire resources in connectedCallback and release them in disconnectedCallback.
Use native custom elements for browser interactions. Crystal can bind their attributes, and JavaScript can observe those attributes and own a shadow root. This keeps animation frames, browser measurements, timers, and third-party widgets in JavaScript while form validation and application state stay in Crystal. Scripts must preserve CryKit's managed nodes, identities, and region anchors; a component's shadow DOM belongs entirely to the component. Existing click, input, submit, keydown, and keyup callbacks remain available through ordinary DOM events.
Layouts load scripts directly in HTML:
<script type="module" src=<%= asset_path("application.js") %>></script>Loading failures are logged and emit a window crykit:script-error event with event.detail.src. Exceptions during execution use the browser's normal error reporting. Failed modules are not retried automatically. A fresh document is needed to retry a failed load or evaluate changed module code; CSS hotreload does not reload JavaScript. Add JavaScript globs to development.watch to rebuild and restart the app on script edits.
Current route
Inside a route-rendered view, current_route : CryKit::Route exposes the matched route's path and optional name. Both inline routes and handler declarations support names with as::
<a href=<%= route_path(:home) %> class=<%= current_route.name == :home ? "active" : "" %>>Home</a>
<p>Current path: <%= current_route.path %></p>The route is available in layouts and composed children. Mounted views retain it through event callbacks, lifecycle hooks, and binding updates; children added during an update receive the same route. Each mount keeps its own route. Handler render also supplies it to views and layouts while rendering the response. A standalone view rendered without a route raises a clear exception when it calls current_route. Unnamed routes have current_route.name == nil.
DOM effects
Views expose a protected dom object for one-time browser interactions. Call its methods directly from view events, native callbacks, mount hooks, or workers; CryKit schedules delivery automatically. All methods return Nil:
| Method | Behavior |
|---|---|
dom.focus(id : String, *, prevent_scroll : Bool = false) | Focus the element, optionally preserving scroll position |
dom.select_text(id : String) | Select all text in a selectable text input or textarea, independently of focus |
dom.reanimate(id : String, *, subtree : Bool = false) | Restart the element's currently associated CSS animations, optionally including descendants |
For example, the notes editor requests focus and text selection when creating a note:
private def new_note : Nil
select_note(@app.notebook.create)
dom.focus("note-title")
dom.select_text("note-title")
endCryKit queues these commands and applies them in call order after the resulting DOM mutation batch. The target may therefore be an element created by that same update. The commands run once; later renders do not repeat them.
reanimate replays CSS animations from their beginning, including animations on the target's pseudo-elements. With subtree: true, it also replays animations on light DOM descendants in the same mount and document. It excludes CSS transitions, animations created through the Web Animations API, shadow roots, and nested mounts. It preserves the element and its text nodes; no replacement markup or changed animation name is needed.
Only currently associated CSS animation objects can be restarted. Use animation-fill-mode: forwards or both when a finished animation must remain available for replay. CSS still controls whether an animation exists: this operation does not override animation: none, including reduced-motion styles.
Pass an exact HTML id, without # or another CSS selector. Lookup stays within the owning mount, including its composed views. If a requested ID occurs more than once in that mount, CryKit raises an error. A missing target, or one that disappears before delivery, is ignored. Commands are scoped to the requesting view's lifetime and are canceled when it unmounts.
Effects requested during a view event join its mutation batch. Calls made outside an active delivery are queued on the view's UI update host, including calls from on_mount, native callbacks, and workers. A worker job requesting an effect retains cancellation ownership across views; a cancelled job's effects are ignored. Queued effects require an update-capable host and share its bounded callback queue with worker deliveries. A full live queue raises an error so the caller can throttle requests. Calling effects while evaluating a template or on an unmounted view also raises an error. Effects are best effort; unsupported controls are ignored.
Lifecycle
Views can define on_mount to start work and on_unmount to release resources. Named streams can manage source startup and cleanup directly. For example, using the demo's ordinary market source:
class BackgroundView
include CryKit::View
reactive last_sequence : Int64 = 0_i64
stream updates : MockMarketFeed::Tick, from: MockMarketFeed.new do |tick|
reactive.last_sequence = tick.sequence
end
template "background"
end<p>Latest snapshot: <%= reactive.last_sequence %></p>The declaration evaluates its from: expression and subscribes before on_mount, then closes the stream automatically on unmount. It composes with custom lifecycle hooks and inherited declarations. updates.pause temporarily removes its listener while source iteration continues; updates.resume receives future values. A declaration without from: starts explicitly with updates.start(source), replacing any previous stream. The controller is always available, so updates.close needs no nil check.
Declare a named job for an operation that is started repeatedly:
job save do |worker|
if worker.sleep(500.milliseconds)
worker.notify { flush_pending }
end
endsave.start or save.restart starts a fresh run and cancels the previous run's pending deliveries. save.cancel is safe before its first run and after unmount. A job declaration does not start work automatically. Omit the body with job checksum to supply work using checksum.start { |worker| ... }; typed stream declarations likewise allow a handler on start(source). Capture mutable inputs before starting a job, and deliver UI state changes with worker.notify. Controllers are operated from the owning view's context and release their active work when the mount ends. See named jobs and streams for the full API and worker rules.
on_mount runs after the template instance and its initial values are prepared, before the registry publishes it. It runs once per view instance in that mount, including composed children. Binding updates or moving a retained child do not restart it. A new child added during an update is also prepared before its hook runs; removing an instance runs its on_unmount. Keep children in instance variables to retain their state when they leave and later rejoin a mount. Startup follows render order, and cleanup runs in reverse order. Document layouts and standalone to_html calls have no lifecycle. At initial startup, reactive writes made by a hook are flushed before the next hook and before initial HTML publication. If a hook changes which children are present, startup adjusts their lifecycles before publishing. Closing during startup releases started scopes and aborts startup. Hooks must not reenter a mount or dispatcher while an update is in progress. Reactive setters schedule binding updates; DOM effect methods schedule browser commands automatically.
Initialize visible state in reactive declaration defaults or initialize when it belongs in the first render. When initialization needs a mounted view, a reactive setter can update it from on_mount:
class MountedCounter
include CryKit::View
reactive count : Int32 = 0
private def on_mount
reactive.count = 1
end
template "mounted_counter"
end<p><%= reactive.count %></p>For an initial mount, startup flushes this setter and publishes 1. A child introduced by a later update is prepared before its hook; reactive writes during that hook can remain pending for a followup binding update. Ordinary instance variable assignments do not invalidate bindings. See background jobs for work that must run off the UI context.
cleanup_on_unmount accepts a Proc(Nil) or a block containing any cleanup the application needs. It is a convenience alongside on_unmount: the supplied code can close resources, cancel external work, or coordinate several operations. Register cleanup while the view is mounted, usually in on_mount. Each function runs once when its view leaves the mount, including when mounting fails. A later mount starts with a fresh set. Use on_unmount when cleanup needs to inspect or change mutable view state. Registered functions run after on_unmount, in reverse registration order, interleaved with automatic subscription cleanup in that order.
Subscriptions to CryKit windows, window close hooks, menus, tray menus, and tray clicks created in on_mount are removed automatically when that view unmounts. The same behavior applies to registrations made in its UI callbacks and job.notify blocks. No matching cleanup_on_unmount call is required. Each subscription still returns an idempotent unsubscribe function for stopping or switching listeners while the view remains mounted.
stream(source) wraps an ordinary Enumerable(T) as a view-owned CryKit::Stream(T). CryKit reads it on a worker and delivers subscription handlers on the UI context. Unsubscribing suppresses queued values from that subscription; closing the stream suppresses all delivery and stops reading at the source's next yield. Streams close automatically when the view unmounts. This imperative API remains available alongside typed stream declarations. The demo's Market view uses its named controller to pause its listener while its source continues, then resume future values. See external streams.
Subscriptions made directly to third-party libraries or custom application services are not adopted automatically. Save their unsubscribe function and call it in on_unmount, or pass it to cleanup_on_unmount. Stop an external service when the view owns its lifetime; a shared service can keep running for other consumers.
Event callbacks mutate view state directly. State writes schedule the affected rendering consumers through the mount queue. Unmount also suppresses pending callbacks and queued updates. Native subscriptions created outside these view-owned contexts remain until explicitly removed or their window, menu, or tray closes. Handlers declared while building or registering a menu or tray stay attached to that registered object, including when creation happens inside a view callback. Assign reactive fields directly from UI callbacks. Use async with job.notify { ... } when computation needs to run off the UI context and deliver state changes back to it.
Navigation, reload, window closure, and registry release run cleanup once. A failed mount hook cleans up the hooks already entered, including the failing hook, and propagates the startup error. A terminal callback or binding evaluation failure in the dispatcher retires the affected mount's lifecycle immediately: on_unmount and registered cleanup run once, and pending updates are discarded. Its committed fragment and failure marker remain while unaffected sibling mounts keep running. Cleanup exceptions are logged and remaining cleanup still runs.
These hooks follow Crystal resource ownership, and do not indicate that the browser DOM is ready. For hosted app:// documents, hooks execute synchronously on the owning UI context, as do event updates and navigation cleanup. Keep hooks short and do not yield inside a hook or reenter its mount/dispatcher. For background operations that deliver view updates, use background jobs and return state changes through job.notify. Plain spawned fibers are not automatically cancelled or marshaled onto the view's UI context. Initialize visible state before constructing the template instance. In debug builds, the first tracked read pins each reactive field to its owning UI thread. Later writes, in-place mutate blocks, and subscription changes on another thread raise before changing state. Return worker results through job.notify; release builds omit these assertions.
Application notifications
Declare a typed channel dependency with subscribe_to in the view class body:
class ClockView
include CryKit::View
reactive time : Time = Time.local
subscribe_to :current_time, call: ->(now : Time) { reactive.time = now }
template "clock"
end<p><%= reactive.time.to_s("%H:%M:%S") %></p>The declaration describes what this view consumes. It creates no active listener until the view mounts. The mount registers the dependency with its hosting application's event source before on_mount, using this view's own lifetime and UI update queue. Declarations compose with custom lifecycle hooks and those inherited from a parent view class. Standalone rendering creates no subscription.
Publish with app.broadcast_to(:current_time, Time.local). Only live mounted views listening to that channel receive delivery. Callbacks run through each view's UI update queue, and state writes schedule their rendering consumers. Unmount removes the view's dependencies and suppresses its queued deliveries; mounting failure does the same. The application and its publishers can keep running after all listening views have unmounted.
Add once: true to consume only the first executed delivery in this mounting. Rejected delivery does not consume it. Remounting registers a fresh dependency.
subscribe_to :current_time, once: true,
call: ->(now : Time) { reactive.time = now }Initialize render state before mounting, since channels do not replay old values. See application jobs and channels for publisher lifetime, payload types, and queue overload behavior.