State Declarations
FQL state is declared in a special HTML comment in the document head.
<!-- {STATE}
state = {
"email": "#email.oninput.target.value",
"displayName": "$query.name || 'Guest'"
}
-->
The value after state = is parsed as a Frontbacked state object. JSON-compatible syntax is recommended. Comment state also accepts single quotes, unquoted identifier keys, and unquoted FQL values such as $now. Commas, balanced brackets, and valid expressions are required; malformed state fails visibly instead of being guessed.
You can also declare the same state object in JavaScript:
Frontbacked.init({
state: {
"email": "#email.oninput.target.value",
"displayName": "$query.name || 'Guest'"
}
});
Frontbacked checks init({ state }) first. If no state is passed to init, it falls back to the {STATE} comment in the page.
List Selector State
Use state objects to define lists. The markup then points to that state value with f-list="$state.name".
{
"pendingTransactions": {
"$list": "$transactions",
"$where": { "status": "pending", "expiresOn.$gt": "$now" },
"$order": { "expiresOn": "asc", "createdOn": "desc" },
"$limit": 20
},
"packages": {
"$list": "$posts.packages",
"$where": { "visibility": "published" },
"$order": { "sortOrder": "asc", "name": "asc" },
"$limit": 24
}
}
State selector definitions use special keys such as $list, $where, $order, $limit, and $page so they are not confused with ordinary post, settings, or local state data fields.
$list is intentionally a literal string. Values inside $where can still use FQL roots such as $query, $auth, $state, and $now. Quote keys that contain dots, such as "expiresOn.$gt", for portability; the {STATE} comment parser also accepts clear unquoted operator keys such as expiresOn.$gt. Plain keys like source, where, order, limit, and page remain ordinary state data.
Monetary values belong on posts, not editable settings. Query a package post and read its money field with the normal money helpers:
<!-- {STATE}
state = {
"packages": {
"$list": "$posts.packages",
"$where": { "visibility": "published" },
"$limit": 24
}
}
-->
<div f="true" f-list="$state.packages">
<strong f="true" f-text="$.minInvestment.moneyFormat()">From USD 500.00</strong>
<button f="true" f-attr-data-package-id="$.slug">Invest</button>
</div>
<tbody id="pendingTransactions" f="true" f-list="$state.pendingTransactions"></tbody>
<div f-insert-pagination="#pendingTransactions"></div>
State from Imported HTML
When a page uses f-insert or f-replace, imported files can also declare state in a {STATE} comment inside their own <head>.
<!-- /components/account-link.html -->
<!doctype html>
<html>
<head>
<!-- {STATE}
state = {
"accountLabel": "$siteInfo.name || 'Dashboard'"
}
-->
</head>
<body>
<a href="/dashboard" f="true" f-text="$state.accountLabel">Dashboard</a>
</body>
</html>
Frontbacked resolves imports before serving the page. If an imported file is a full HTML page, only its <body> content is inserted, but its head state is merged into the main page's single {STATE} comment. Imported state entries are placed before the main page state entries, so the main page can override a duplicate key.
State from DOM Events
The most common state source is a DOM event.
{
"email": "#email.oninput.target.value",
"accepted": "#terms.onchange.target.checked",
"file": "#avatar.onchange.target.files[0]"
}
The syntax is:
selector.onevent.path.to.value
Examples:
| Source | Meaning |
|---|---|
#email.oninput.target.value | On input, read event.target.value. |
#terms.onchange.target.checked | On change, read checkbox checked state. |
#form.onsubmit | On submit, pass the event object. |
#file.onchange.target.files[0] | On change, read the first selected file. |
Event names are written as oninput, onchange, onclick, onsubmit, and so on. FQL removes the on prefix when binding the actual DOM event.
One binding for multiple elements
A selector-based event source is attached to every element that matches the selector, not only the first match. This includes controls added by f-list, so newly loaded options and refreshed lists keep their event bindings. Use it for option buttons, segmented controls, and repeated inputs:
<button data-transfer-type="internal" value="internal">To this site</button>
<button data-transfer-type="external" value="external">External bank account</button>
<button data-transfer-type="wire" value="wire">Wire transfer</button>
<!-- {STATE}
state = {
"transferType('internal')": "[data-transfer-type].onclick.currentTarget.value"
}
-->
Use currentTarget when the value should come from the element matched by the selector—the button whose listener is handling the event. Use target when the value should come from the element that originated the event; these can differ when a matched element contains a nested icon or label. In the example above, clicking any of the three buttons updates transferType with that button's value.
Default Values
Put a literal value in parentheses after a state key to set its default.
{
"terms(true)": "#terms.onchange.target.checked",
"quantity(1)": "#quantity.oninput.target.value.Number()",
"displayName('Guest')": "#name.oninput.target.value",
"coupon(null)": "#coupon.oninput.target.value"
}
Supported default literals are:
| Literal | Example |
|---|---|
| Boolean | "terms(true)" |
| Number | "quantity(1)" |
| String | "name('Guest')" |
| Null | "coupon(null)" |
The actual state key is the part before the default. "terms(true)" creates $state.terms with an initial value of true.
Prefill editable state from saved data
Use an expression inside the state key's parentheses to prefill a form when its data arrives. Bind the control to that editable state:
<!-- {STATE}
state = {
"product": "products{id:$params.id}",
"name($state.product.data.name ?? '')": "#productName.oninput.target.value"
}
-->
<input id="productName" f="true" f-attr-value="$state.name">
The saved value supplies the default until the visitor edits it. Input events and Frontbacked.state.set() then keep the edited value, including an intentionally empty string, false, or 0. Use the same state in labels, swatches, and selection controls so the whole form stays in sync.
Multiple Value Sources
Use a comma to declare multiple possible event sources for the same state value.
{
"terms(true)": "#terms.onchange.target.checked, #termsBtn.onclick.target.invertTermsClick()"
}
This means $state.terms starts as true, then it can be updated by either:
- The hidden checkbox change event.
- The visible checkbox button click event.
Commas are only treated as source separators at the top level. Commas inside function calls, arrays, objects, strings, or filters stay inside that expression.
Live State
Add .live to a state key when that value should update after its saved data changes.
{
"messages.live": {
"$list": "messages",
"$where": { "roomId": "$params.roomId" },
"$order": { "createdOn": "asc" },
"$limit": 50
},
"product('No product').live": "$posts.products.{id: $params.productId}",
"productName('No product').live": "$state.product.data.name",
"productHasImage(false).live": "$state.product.data.images.count() > 0"
}
The state path does not include the modifier: "messages.live" is read as $state.messages. When a live value depends on another state path, Frontbacked follows that dependency back to the saved data it needs. This lets a derived value such as productName stay live without duplicating its product query.
Frontbacked shares one live connection for the page and combines overlapping selections automatically. If several live values depend on the same post type or list, one saved-data change is turned into one fresh request for the combined selection. Theme code does not need to create sockets, subscribe to channels, or coordinate duplicate listeners.
Live values respond to saved-data changes. Browser-only values such as $query and $params still change through navigation or state updates, and $now does not become a ticking clock merely because a key is live. Every fresh live request uses the visitor's current authentication and the same FRL read permissions as the initial request.
Defaults must appear before .live:
{
"productName('No product').live": "$state.product.data.name"
}
"productName.live('No product')", repeated or unknown modifiers, and $live inside a selector are rejected.
Read lifecycle state through $status.<statePath>:
{
"messagesLoading": "$status.messages.loading",
"messagesConnected": "$status.messages.live",
"messagesReconnecting": "$status.messages.reconnecting",
"messagesError": "$status.messages.error"
}
The same information is available in JavaScript with Frontbacked.live.status("messages"). Use Frontbacked.live.isConnected() when you only need the page-level connection state. Frontbacked reconnects automatically, reloads only when a saved-data change was missed during the interruption, and keeps the last rendered value while reconnecting.
Fallback Expressions
Use || for a truthy fallback and ?? when only null or undefined should fall back.
{
"displayName": "$state.form.name || $query.name || $params.username || 'Guest'",
"title": "$state.product.data.title || $settings.site.name || 'Untitled'",
"count": "$state.resultCount ?? 0"
}
|| is different from comma-separated value sources.
| Syntax | Purpose |
|---|---|
| Comma | Multiple events can write to the same state key. |
| ` | |
?? | Falls back only when the left value is null or undefined. |
Use commas for "these are possible sources for this state." Use || for ordinary display fallbacks and ?? when false, 0, or an empty string must be preserved.
Logical AND with &&
Use && when one value should depend on another value being truthy.
{
"canSubmit": "$state.form.email && $state.form.terms"
}
&& evaluates from left to right. It returns the first falsy value it finds. If every value is truthy, it returns the last value. It has higher precedence than ||, so this:
{
"canSubmit": "$state.form.email && $state.form.terms || false"
}
is read like:
{
"canSubmit": "($state.form.email && $state.form.terms) || false"
}
Use && for truthy gating. Parentheses can make a longer expression's intent explicit.
Operators
FQL supports arithmetic (+, -, *, /, %), comparisons (==, !=, >, >=, <, <=), unary operators (!, unary +, unary -), &&, ||, ??, parentheses, and conditional expressions.
{
"subtotal": "$state.price * $state.quantity",
"eligible": "$state.subtotal >= 100 && $auth.exists",
"label": "$state.eligible ? 'Free delivery' : 'Delivery calculated at checkout'"
}
== and != are type-strict in FQL: 1 == '1' is false.
Use parentheses when ?? appears in the same expression as || or &&. That keeps fallbacks readable and avoids relying on precedence in a line another theme developer may scan quickly.
Formatter and Event Functions
Functions used in FQL must be registered explicitly before Frontbacked.init().
<script>
function toNumber(value) {
return Number(value || 0);
}
function formatCurrency(value) {
return "NGN " + Number(value || 0).toLocaleString();
}
function invertTermsClick() {
return !Frontbacked.state.get("form.terms");
}
function projectedReturn(percent, amount) {
return amount + amount * percent / 100;
}
Frontbacked.functions.define({
toNumber,
formatCurrency,
invertTermsClick,
projectedReturn
});
</script>
Use functions after a path segment:
{
"amount": "#amount.oninput.target.value.toNumber()",
"formattedAmount": "$state.amount.formatCurrency()",
"projected": "$state.roi.projectedReturn($state.amount)",
"category": "#categories.onchange.target.value.load().post().parseResponse()",
"terms(true)": "#terms.onchange.target.checked, #termsButton.onclick.target.invertTermsClick()"
}
The resolved value before .functionName(...) is always the first argument. The expressions written inside the parentheses are resolved and passed after it. FQL never adds a hidden surrounding list item; pass $ explicitly when a formatter needs the complete current record:
<strong f="true" f-text="$.roi.projectedReturn($.amount)"></strong>
<span f="true" f-text="$.status.statusLabel($)"></span>
Custom function names must be single JavaScript-style identifiers. Built-in functions cannot be replaced or removed.
For event bindings, the first argument is the selected event value. For a binding like #termsButton.onclick.target.invertTermsClick(), the function can ignore the passed target and return a computed value.
You can pass a value through multiple functions before it is written to state. In #categories.onchange.target.value.load().post().parseResponse(), Frontbacked reads event.target.value, calls load(value), passes that result into post(...), then passes that result into parseResponse(...). The final return value becomes the state value.
Nested State
State objects can be nested.
{
"form": {
"name": "#name.oninput.target.value",
"email": "#email.oninput.target.value",
"terms(false)": "#terms.onchange.target.checked"
},
"canSubmit": "$state.form.email && $state.form.terms",
"summary": {
"name": "$state.form.name",
"email": "$state.form.email",
"tags": ["$state.form.name", "$state.form.email"]
}
}
Nested state is declared like a JSON object with extra FQL powers. Keep object keys quoted and FQL expressions as quoted string values when you can; the {STATE} parser reports malformed syntax with its location, while Frontbacked.init({ state }) uses normal JavaScript object syntax. Nested paths are read with $state.form.name, $state.form.email, $state.summary.name, $state.summary.tags[0], and so on.
Reading and Writing State from JavaScript
const allState = Frontbacked.state.get();
const email = Frontbacked.state.get("form.email");
const missing = Frontbacked.state.get("some.deep.value.in.an.undefined.object.0.r.an.array");
Frontbacked.state.set("form.status", "Saved");
Frontbacked.state.set({
form: {
loading: false
}
});
Frontbacked.state.get(path) returns undefined when the path moves through a missing object or array. It does not throw for missing data.
const deepValue = Frontbacked.state.get("some.deep.value.in.an.undefined.object.0.r.an.array");
// deepValue is undefined
It does throw when a path tries to keep reading through a defined non-object value.
Frontbacked.state.set("ages", {
obj: { user1: 24 },
array: [24]
});
Frontbacked.state.get("ages.obj.user1.throws");
// Cannot access property 'throws' of non-object value, '24'
Frontbacked.state.get("ages.array[0].throws");
// Cannot access property 'throws' of non-object value, '24'
Frontbacked.state.get("ages.array.0.throws");
// Cannot access property 'throws' of non-object value, '24'
Array indexes can be read or written with square brackets or dot indexes. These point to the same state path:
Frontbacked.state.get("sample.array[0]");
Frontbacked.state.get("sample.array.0");
Frontbacked.state.set("sample.array[0]", 1);
Frontbacked.state.set("sample.array.0", 2);
Frontbacked.state.set(path, value) writes one path. Frontbacked.state.set(object) merges an object into the current state and re-renders FQL bindings.
Full Form State Example
<head>
<!-- {STATE}
state = {
"form": {
"name": "#name.oninput.target.value",
"email": "#email.oninput.target.value",
"password": "#password.oninput.target.value",
"rememberMe(true)": "#rememberMe.onchange.target.checked, #rememberButton.onclick.target.toggleRemember()",
"status": "#signupForm.onsubmit.handleSignup()"
},
"buttonLabel": "$state.form.status || 'Create account'",
"welcome": "$state.form.name || 'New user'"
}
-->
</head>
This combines input events, a defaulted checkbox state, multiple sources, submit handling, and derived display state.