labb 0.5.0 is out

Signals

Declare browser state with c-lbr.signals, then use it in reactive props, bindings, and server actions.

A signal is a named value in the browser. Form components bind to signals, data- attributes read and update them, and server actions send them to Django. Declare signals with <c-lbr.signals>, usually near the top of the page body.

<c-lbr.signals $count="0" $open="false" />

The tag also loads the runtime. Leave it out of pages that do not need reactivity.

This example uses one signal for two buttons and a label.

<c-lbr.signals :$count="0" />

<div class="flex items-center gap-4">
    <c-lb.button btnStyle="outline" size="sm" data-on:click="$count--">-</c-lb.button>
    <span class="text-2xl font-mono tabular-nums w-10 text-center" data-text="$count"></span>
    <c-lb.button btnStyle="outline" size="sm" data-on:click="$count++">+</c-lb.button>
</div>

Use $ whenever you name a signal: in a declaration, data- expression, reactive prop, or form binding.

Naming signals

Signal names use camelCase JavaScript identifiers. Group related values with dots.

<c-lbr.signals $filters.q="" $filters.status="all" $page="1" />

This creates a filters object with q and status, plus a top-level page. Read them as $filters.q and $page.

Paths bound from the DOM must use lowercase or snake_case because browsers lowercase attribute names. Use filters.q instead of filters.firstName. JavaScript expressions can use camelCase.

Declaration forms

Choose a declaration form based on the value you need.

Scalar props with $

A $ prop declares a signal inline. labb treats the value as a JSON literal rather than an evaluated expression.

<c-lbr.signals $name="" $open="false" $status="active" />

Only "true" and "false" become booleans. Other quoted values stay strings, so $page="1" is text rather than the number 1.

Numbers, lists, and objects with :$

Use a Cotton : binding for numbers, lists, and objects. It evaluates the value and encodes it as JSON.

<c-lbr.signals
    :$count="0"
    :$tags="['new', 'urgent']"
    :$order="{'variant': 'primary', 'label': 'Pending'}"
/>

:$count="0" creates the number 0, so $count++ performs arithmetic. Use an object when related values change together. Patterns shows that approach.

A typed schema from the view

Define a Signals class for state that needs typed, validated server access. It gives the signal paths one home.

from labb.signals import Signals, Str, Int

class TodoSignals(Signals):
    q = Str(path="filters.q", default="")
    page = Int(default=1, min_value=1)

def index(request):
    s = TodoSignals(request)
    return render(request, "todos/index.html", {"signals": s})
<c-lbr.signals :schema=signals />

Bind each form field through its descriptor so the path comes from the schema.

<c-lb.input :bind=signals.fields.q />

Use a schema when the state has several paths or needs validation. Props suit a small number of local values. You can use both on the same page.

Keep signal state through morphs

A server action re-renders the whole page, including the <c-lbr.signals> declaration. :schema and $ declarations behave differently after that morph.

A :schema declaration seeds signals the browser does not have, then sends back only fields the view changed. Suppose an input sends a request at atla and you type atlas before its response arrives. If the view did not assign q, labb leaves q alone.

A $ declaration cannot identify fields the view changed, so labb reapplies its declared value with each morph. Add ifmissing for a value the browser owns after the first render:

<c-lbr.signals $open="false" ifmissing />

Without it, opening the dropdown and then triggering any server action closes it again.

A view changes a schema signal by assigning to it:

def index(request):
    s = TodoSignals(request)
    s.page = min(s.page, total_pages)   # a clamp the browser must accept
    return render(request, "todos/index.html", {"signals": s})

An assignment sends a field back only when its value differs. s.page = 1 on a page that was already 1 sends nothing.

Two cases need mark_changed, because the value alone does not show the intent:

s.mark_changed("page")          # overwrite even though the value matches
ui.selected["7"] = False        # in-place mutation of a Dict field
ui.mark_changed("selected")     # ...never passes through the assignment

Datastar reapplies a changed signal declaration only when its rendered attribute changes. Two consecutive responses that set a signal to the same value apply it once. Use Signals.patch() in an SSEResponse when the server must apply the same value more than once.

Bound fields and morphing

A bound field's value belongs to its signal. labb marks <c-lb.input>, <c-lb.checkbox>, <c-lb.toggle>, and <c-lb.range> so Datastar leaves their values intact while it morphs the page. To change one from the server, change its signal.

Datastar updates a <c-lb.select> through its <option> attributes and a <c-lb.textarea> through its child text, so the same marker cannot protect them. Render their selected state or text from the bound signal rather than from the record:

<c-lb.select :bind=edit_signals.fields.status>
    
</c-lb.select>

Read edit_signals.status rather than customer.status so an unsaved choice survives the next morph.

A search box, step one

The next three pages build a search control one step at a time. It starts with two unused signals.

Two signals are declared. Nothing reads them yet.

filters.q = ""
status = ""
<c-lbr.signals $filters.q="" $status="idle" />

<div class="flex flex-col gap-3 w-full max-w-sm">
    <p class="text-sm text-base-content/60">
        Two signals are declared. Nothing reads them yet.
    </p>
    <div class="rounded-lg bg-base-200/50 p-3 font-mono text-xs">
        filters.q = "<span data-text="$filters.q"></span>"<br>
        status = "<span data-text="$status"></span>"
    </div>
</div>

Continue to Events & bindings to connect the signals to the search control. The Reactivity reference lists every prop.