Client-Side UI Patterns
Reepolee renders pages on the server and adds browser behaviour only where an interaction needs it. There is no framework-specific DOM-binding layer. The current code uses four small building blocks:
| Need | Current approach |
|---|---|
| Disclosure and expandable navigation | Native <details> and <summary> |
| Confirmation and modal UI | Native <dialog> plus dialog-confirm.js where needed |
| Standard application behaviour | Focused scripts such as form-controller.js and dialog-confirm.js |
| Stateful custom controls | alien-deepsignals directly with native DOM updates |
This page shows how those pieces fit together. For the complete deepSignal(), watchEffect(), and watch() API, see Signals.
Start With Native HTML
Use the browser's built-in elements before adding JavaScript. The application sidebar, for example, uses <details> for collapsible module groups:
<details open>
<summary>Administration</summary>
<nav>
<a href="/admin/users">Users</a>
<a href="/admin/modules">Modules</a>
</nav>
</details>
The browser owns the open state, keyboard interaction, and accessibility semantics. Reepolee's layout adds a small toggle listener only to remember collapsed navigation groups in a cookie.
Use native <dialog> for modal content. Reepolee's confirmation dialogs use invoker commands:
<button type="button" command="show-modal" commandfor="delete-dialog">
Delete
</button>
<dialog id="delete-dialog">
<p>Delete this record?</p>
<button type="button" command="close" commandfor="delete-dialog">Cancel</button>
<button type="button" command="--confirm" commandfor="delete-dialog">Delete</button>
</dialog>
Pages using this pattern load /dialog-confirm.js. The script makes show-modal reliably call showModal(). Application code listens for the dialog's command event to perform a custom --confirm action. See Dialogs for the full pattern.
Focused Scripts
Browser code in static/ is split by responsibility rather than hidden behind one client framework:
| File | Responsibility |
|---|---|
helpers-client.js | Theme handling, $/$ query helpers, bulk-selection helpers, and query-string navigation |
form-controller.js | Form values, field validation, error rendering, and guarded submission |
dialog-confirm.js | Native dialog opening and custom confirmation commands |
checkbox-group.js | Select-all and bulk-action state for record tables |
web-components/*.js | Focused custom elements such as validation errors, toasts, and title display |
Load only the script a page needs. The main layout already loads helpers-client.js and the shared web components. Form and dialog scripts are included by the templates that use them.
Reactive Widgets With alien-deepsignals
When a control has several related pieces of client-side state, import deepSignal and watchEffect directly from the vendored bundle:
<input id="counter-input" value="100" disabled />
<button id="plus-button" type="button">Add 1</button>
<script type="module">
import { deepSignal, watchEffect } from "/alien-deepsignals.min.js";
const state = deepSignal({
count: parseInt(document.getElementById("counter-input").value, 10),
});
const counter_input = document.getElementById("counter-input");
const plus_button = document.getElementById("plus-button");
plus_button.onclick = () => state.count++;
watchEffect(() => (counter_input.value = state.count));
</script>
The bundle is tracked at static/alien-deepsignals.min.js. The bun get:signals command refreshes it from the pinned 0.4.3 esm.sh URL. No bundler or runtime npm dependency is involved.
Real Component Patterns
Two shipped form components demonstrate the intended approach.
Tag Input
components/input-tags.ree keeps its tag array in a deep signal. Native keyboard handlers add tags, and one watchEffect() synchronizes the hidden form value and redraws the chips. The hidden input remains the value submitted to the server.
The important shape is:
const state = deepSignal({ tags: initial_tags });
draft.addEventListener("keydown", (event) => {
// Validate the key and update state.tags.
});
watchEffect(() => {
hidden.value = state.tags.join(",");
// Redraw the tag chips from state.tags.
});
Star Rating
components/star-rating.ree tracks the chosen value and the temporary hover value:
const state = deepSignal({ value: initial_value, hover: 0 });
stars.forEach((button) => {
const value = parseInt(button.dataset.ratingStar, 10);
button.onclick = () => (state.value = value);
button.onmouseenter = () => (state.hover = value);
button.onmouseleave = () => (state.hover = 0);
});
watchEffect(() => {
const visible_value = state.hover || state.value;
// Update the hidden input, label, and star classes.
});
These examples keep the server-facing form value in a normal hidden input and use signals only for immediate browser feedback.
Passing Server State to JavaScript
Ree templates can initialize browser state from server-rendered values. Emit strings with escaped output and structured data as a JSON literal with raw output:
<script type="module">
import { deepSignal } from "/alien-deepsignals.min.js";
const state = deepSignal({
count: {= props.initial_count },
name: "{= props.user.name }",
record: {~ JSON.stringify(props.record) },
});
</script>
Use {~ } for the serialized object because JSON.stringify() already produces the JavaScript literal. Escaping it again would make the expression invalid.
Forms and Signals
Do not replace FormController merely to make an ordinary form reactive. It already reads input values, performs server-backed validation on focus changes, renders errors, and guards submission.
Use deepsignals when the interface has derived client-only state, such as a live total, tag editor, rating preview, or configurator. A component can still write its final value to a normal form input so FormController and native form submission continue to work.
When to Add JavaScript
Keep the default server-rendered:
- Render record lists, headings, navigation, and validation responses on the server.
- Use
<details>and<dialog>when native HTML already owns the interaction. - Use an existing focused script or web component for standard application behaviour.
- Reach for
alien-deepsignalsonly when state must update immediately in the browser and several DOM values derive from it.
That keeps the application usable as ordinary server-rendered HTML while allowing richer controls where they earn their complexity.