Skip to content

Linter Rule: Validate herb:state declarations

Rule: herb-state-valid-declaration

Description

Validates <%# herb:state (name: default, ...) %> directives. The signature must declare keyword parameters with defaults, each default must be a primitive the client can hold (true/false, an Integer, a String, a Symbol, or nil), a bare-identifier default must name a declared strict local, and a state name must be unique. It may not collide with a strict local, repeat within its scope, or appear in both an item and its region.

A default may also read other states declared before it in the same signature (busy: pending || failed), which makes the state derived. The client re-evaluates it whenever a source changes. A derived default may not mix state reads with other Ruby, read a state declared after it, or read a state from an enclosing scope.

Rationale

A state is client-owned, so both sides have to agree on its value. The default carries the type the client validates operations against, which is why it is required and why it has to be a primitive with one text form both Ruby and JavaScript agree on. Floats print differently in the two languages, an Array on the page is a collection of items, and a Hash is a grouping each leaf can express as its own state.

A derived state works because its default is the same condition grammar the client already resolves for branches, so declaring busy: pending || failed names the condition once and every read of busy stays current. An expression that mixes a state with server Ruby has no client answer, and one that reads forward has no server value yet, since declarations compile in signature order.

The naming restrictions keep every read unambiguous. A local comes from the caller and a state is client-owned, so one name cannot mean both, and a name declared in an item and its region would make a later read depend on where it sits.

The engine raises these as compile errors when the template renders. This rule reports the same findings in the editor, before a render ever runs.

Examples

✅ Good

erb
<%# herb:slots client %>
<%# herb:state (pending: false, attempts: 0, sort: "name") %>

<button data-herb-toggle="pending">Send</button>
<button data-herb-increment="attempts">Retry</button>
<button data-herb-set="sort=date">By date</button>
erb
<%# locals: (open_initially: false) %>
<%# herb:slots client %>
<%# herb:state (open: open_initially) %>

<button data-herb-toggle="open">Menu</button>
<% if open? %><nav>Menu</nav><% end %>
erb
<%# herb:slots client %>
<ul>
  <% @messages.each do |message| %>
    <%# herb:state (pending: false) %>
    <li><%= message.body %> <% if pending? %>Sending<% end %></li>
  <% end %>
</ul>
erb
<%# herb:slots client %>
<%# herb:state (pending: false, failed: false, busy: pending || failed) %>

<div><% if busy %>Working<% else %>Ready<% end %></div>
<input disabled="<%= busy %>">

🚫 Bad

erb
<%# herb:slots client %>
<%# herb:state (pending:) %>
The state `pending` has no default. Add one, like `pending: false`. The server renders a state as its default, so a state always has a value. (herb-state-valid-declaration)
<% if pending %>Sending<% end %>
erb
<%# herb:slots client %>
<%# herb:state (rate: 1.0) %>
The state `rate` has a Float default. Use an Integer or a String instead, since Ruby and JavaScript print floats differently. (herb-state-valid-declaration)
<p><%= rate %></p>
erb
<%# herb:slots client %>
<%# herb:state (selected: []) %>
The state `selected` has an Array default. Declare a per-row boolean inside the loop instead, like `selected: false`. A list on the page is a collection of items, not a state. (herb-state-valid-declaration)
<% if selected %>Selected<% end %>
erb
<%# herb:slots client %>
<%# herb:state (draft: { title: "" }) %>
The state `draft` has a Hash default. Declare each leaf as its own state, like `draft_title: ""`. (herb-state-valid-declaration)
<p><%= draft %></p>
erb
<%# herb:slots client %>
<%# herb:state (open: open_initially) %>
The state `open` defaults to `open_initially`, which is not a declared strict local. Declare it, like `<%# locals: (open_initially: false) %>`, or use a literal default. A bare name a caller never passed raises a `NameError` at render. (herb-state-valid-declaration)
<% if open? %>Open<% end %>
erb
<%# locals: (open: false) %>
<%# herb:slots client %>
<%# herb:state (open: false) %>
`open` is both a strict local and a state. Rename one of them. A local comes from the caller and a state is client-owned, so one name cannot be both. (herb-state-valid-declaration)
<% if open %>Open<% end %>
erb
<%# herb:slots client %>
<%# herb:state (pending: false, busy: pending || current_user.admin?) %>
The state `busy` defaults to `pending || current_user.admin?`, which mixes state reads with other Ruby. A derived state reads only other states, and a seed reads none, so split the two apart. (herb-state-valid-declaration)
The state `pending` is never read or written in this template. Remove it, or disable this line when only app code uses it through `stateFor` or `useState`. (herb-state-no-unused-states)
<div><% if busy %>Working<% end %></div>

References

-

Released under the MIT License.