Make a Screen React
Goal
Make a drawn screen do something: a heading that says the name somebody typed, a panel that appears when a box is ticked, a Save button that turns on only when the form is filled in.
None of that is a picture, and none of it is code you write in a file. It is state — the handful of things that change while somebody is using the screen — and bindings, which are one-line expressions that read it.
Step 1 — Give the screen some state
- Open the screen in the designer (Screens → select one → Design).
- Click the screen frame itself — the outermost box — and open the Screen tab in the inspector on the right.
- Under Screen state, press Add a state field.
- Type a name — say
requester— and a starting value.
The starting value is an expression, not a typed-in value, so an empty box starts as "" (two
quote marks), a tick box as false, and a counter as 0. It is the same language as everything
else on this screen, which is why there is only one thing to learn.
Add as many as the screen needs. Most screens need two or three.
Because two controls read the same field. If state belonged to the box you happened to add it from, deleting that box would take the field with it — and everything else reading it would quietly stop working.
Step 2 — Bind a property
- Select any object on the canvas.
- In the Screen tab, under Bindings, choose a property from Bind a property.
- An editor appears with an example in it. Replace it with your expression.
Four properties can be bound:
| Property | Drives | On |
|---|---|---|
| Text | what it says — a heading's words, a button's label, a box's placeholder | anything |
| Shown | whether it is on the screen at all | anything |
| Enabled | whether a control may be used | controls |
| Value | what a control holds | controls |
Everything else about an object — where it is, how big, what colour — you drew, and drawing is the better way to say all of it.
Examples that do real work:
state.requester the Text of a heading
"Hello, " + state.requester the same, with words around it
state.showDetails the Shown of a panel
!state.showDetails the Shown of the line that says it is empty
state.requester != "" the Enabled of a Save button
screen.width > 700 the Shown of something a narrow window has no room for
The editor is the platform's BS Lang editor, so it underlines what it does not understand and offers completions as you type. If the underline is there, hover it to read why.
Step 3 — Let a control write back
Bind a text box's Value to state.requester, and typing in it sets state.requester.
Everything else reading that field follows immediately — the greeting, the Save button, all of it.
That is the whole of what makes a screen feel alive, and it needs no handler.
One rule: write-back happens only when the Value is a plain state path — state.requester,
or state.form.name. If it is computed (state.first + " " + state.last), the control shows the
value and is read-only, because there is no way to work backwards from what somebody typed to
the two fields it came from.
Step 4 — Make something happen when a button is pressed
- Select the button.
- Under When this happens, choose Pressed from Handle an event.
- Write what should happen — one instruction per line:
notify("Ticket raised for " + getState("requester"));
setState("requester", "");
setState("showDetails", false);
What a handler can call:
| Call | Does |
|---|---|
setState("name", value) | changes the screen's state — everything reading it follows |
getState("name") | reads it back, so you can compute from what is there |
loadRecords("table", "field") | loads rows from a table into a state field — see below |
getRecord("table", id, "field") | loads one record by its id |
createRecord("table", { … }, "field") | stores a new record; the third argument is optional |
updateRecord("table", id, { … }) | changes the fields you name on one record |
deleteRecord("table", id) | deletes one record |
callApi("GET", "psl/v1/overview", { … }, "field") | asks a Custom API and keeps its answer — see Figures from a Custom API |
notify("…") / notifyError("…") | tells the person using the screen |
showModal("Card") / closeModal() | shows an overlay card over the screen — see step 5 |
openStartDrawer("Card") / closeStartDrawer() | slides one in from the start edge |
openEndDrawer("Card") / closeEndDrawer() | and from the end edge |
openDropdown("Card") / closeDropdown() | opens one beside whatever was pressed |
setVisualState("name") | puts the nearest object above this handler with a drawn state into it; "" goes back to Normal — see Reuse a design with components |
openUrl("…", "newTab") | goes to a web address; leave the second argument off to stay in this window |
navigate("Screen") | goes to another screen of this app — by the name on its frame |
reloadSurfaces() | shows every embedded table or form on this screen again, with fresh data |
A handler is a little program, not an expression. It may change things — that is the whole point of it — and it can have as many lines as it needs. That is the difference from a binding, which only reads.
The other events are there when you need them: Pressed twice, Value changed (on a control), Takes focus, Loses focus, Pointer arrives, Pointer leaves, and — on the screen frame itself — Screen opens, which runs once, after the state exists.
A handler that just recolours something costs work on every pointer move. A drawn state costs nothing at all, because the browser does it. See Reuse a design with components.
Going to another screen of the app
An app holds several screens, and navigate moves between them:
navigate("Details");
navigate("Details", { id: getState("chosen") });
The name is the name on the screen's frame, exactly as you typed it there — rename the frame
and every navigate naming the old name stops working, which the screen tells you when it is
pressed rather than failing silently.
What survives the move, and what does not. The screen you leave is closed: its state is gone,
so coming back gives you a fresh one — a half-filled form should not still be half-filled, and a
screen that loaded rows on arrival loads them again. What travels is what you hand over: the second
argument arrives as screen.params, so screen.params.id on the next screen reads what you sent.
It is always screen.params. There is no shorter params, and the code editor underlines one.
The address bar follows you. Each screen has its own URL, so the browser's Back button returns
to the screen before it, a reload comes back to the same one, and a link you copy opens there.
Handed-over values do not travel that way — arriving by Back, by reload or by a pasted link
opens the screen with no screen.params — so treat them as a hand-off between two screens rather
than as something a link can carry.
Reading and writing records
Five calls, and between them they are what turns a drawing into an application.
Read a list:
loadRecords("par_ticket", "tickets");
loadRecords("par_ticket", "tickets", 20);
loadRecords("par_ticket", "tickets", { top: 20, where: { par_status: "Open" }, orderBy: "par_name" });
The rows land in the state field you named, and everything else on the screen already knows what to
do with them — a table repeats over state.tickets, a caption reads state.tickets.length(). See
Draw one row, get one per record.
The third argument is either how many at most, or the whole question: top, where, orderBy
and descending. where matches on equals only, and every pair you write has to match. When
you need more than that — a range, "contains", an or — build a view with the query designer and
draw the screen against that instead of squeezing it into one line here.
Read one:
getRecord("par_ticket", screen.params.id, "ticket");
That is the pair to navigate("Details", { id: row.id }) — the list screen hands over the id, and
the details screen fetches the record behind it. If there is no such record any more, the field is
left as it was rather than emptied, so the bindings under it keep working.
Write:
createRecord("par_ticket", { par_name: getState("title"), par_status: "Open" });
createRecord("par_ticket", { par_name: getState("title") }, "created");
updateRecord("par_ticket", getState("ticket").id, { par_status: "Closed" });
deleteRecord("par_ticket", getState("ticket").id);
createRecord's third argument is optional and often worth writing: the platform answers with
the record it stored, id and all, so getState("created").id is how the screen then opens or
updates what it just made. Without it there is no way to learn that id.
The field names are the logical names of the table's columns — par_name, not Name — the
same ones the Table designer shows.
Nothing tells the person using the screen that a write worked, so say so yourself:
createRecord(…); notify("Ticket raised");. If the platform refuses the write — a required field, a
permission — the screen shows the reason it gave, which is why the message is worth reading rather
than replacing.
And the rest of that click then stops, so a notify or navigate after the write happens only when it worked.
Every one of these is checked against the permissions of whoever is using the app, on every call. Hiding a Delete button hides a button; it does not stop anything.
openUrl is for when the screen decides where to go — after saving, or based on what was
picked. When the destination is simply known, draw a link instead: middle-click, right-click →
Open in new tab, and a screen reader's list of links all work on a real link and on nothing else.
See Draw a Studio app.
Figures from a Custom API
A figure — today's bookings, this month's revenue — is a range and a sum, often across several
tables, which loadRecords cannot ask for. Work it out in a Custom API and call it from the
screen:
callApi("GET", "psl/v1/overview", { date: "2026-10-06" }, "figures");
The address is the API's own below the environment: its publisher prefix, its version and its
route. For GET the inputs are sent as the query string; for POST and the others as the body.
The answer lands in the state field you name, so a text bound to state.figures.bookingsToday shows
it. The API runs as whoever is using the screen, with their permissions.
A portal page reads the tables its portal lists and never names an API, so callApi there shows a
message instead of an answer.
When the box is too small: the code page
A binding is one line and belongs beside the thing it drives. A handler grows — and after the third
if, the little editor in the panel is a letterbox.
Select the object first, then press Write code in the toolbar (the </> button). The
designer is covered — the page around it stays — by a list of everything scriptable on that
object, down the side, and one editor with room to write in. The list holds its bindings, its
handlers, the screen's state fields, and a component's own properties. Unwritten ones are listed too,
which is the quickest answer to "what could I put on this?".
The list says which of them are written, so you never have to open one to find out:
| Beside the name | Means |
|---|---|
| A green dot, name in bold | Written, and it makes sense |
| A red dot, name in bold | Written, and it has a mistake in it — open it and the editor underlines where |
| A hollow dot, name dimmed | Nothing written yet |
While the code page is up, the keyboard is the editor's. Typing t types a t; it does not
reach for the Text tool behind the page.
To go back, press Design — the same button that brings you back from a preview, in the same place at the top of the page. The drawing is exactly as you left it: same selection, same zoom. And it is the same document either way, so a handler written here shows up in the panel there.
If nothing happens when you press it, a message says why, and there are only three reasons:
| The message | What to do |
|---|---|
| Nothing is selected — or more than one thing is | Select exactly one object |
| Code is written on the component itself | You have a copy (an instance) or a variant selected. Right-click → Go to main component and write it there — every copy gets it |
| This object is part of the screen's graphic | Say what it is on the Screen tab first — a button, a text box — and then it has handlers to write |
A component is written on its main copy. Select the frame the component is made of — not a copy of it on a screen — and the list offers Pressed and the rest, exactly as for anything else. That is where a component's behaviour belongs: write it once, and every copy on every screen does it.
Step 5 — Ask before doing something, in a modal, a dropdown or a drawer
An overlay card is a piece of screen that is not there until a handler shows it.
-
Draw its contents once, as a component. A card with a question and two buttons is a component like any other — see Reuse a design with components — so the same confirmation can be used on every screen that needs one. Draw it beside your screen, where there is room.
-
Select the component and tick An overlay card on the Graphic tab. That is the whole of the setup: it stops being drawn on the screen, and starts being something a handler can show.
-
Show it from a handler, by the component's own name:
Call Where it appears Good for showModal("Confirm card")centred over the screen, with a backdrop a question that has to be answered before anything else openDropdown("Menu card")attached to the thing that was pressed a small menu, a hint, a short form openStartDrawer("Filters card")full height, sliding in from the start edge filters, a menu on a narrow window openEndDrawer("Details card")full height, from the end edge a detail panel -
Close it with
closeModal(),closeDropdown(),closeStartDrawer()orcloseEndDrawer()— from a button inside it, say. A drawer has no close button of its own: draw one in the card, the way the samples' menus do. Escape and a click beside the drawer still close it.
A card that has to be answered
Under the An overlay card tick there is a second: It has to be answered. With it on, clicking outside the card, pressing Escape and the close button all stop working while it is open — and the close button is not shown at all, because a control that is visibly there and does nothing reads as a frozen page to whoever presses it twice.
Leave it off unless the card is asking something walking away cannot answer. An overlay somebody opened by accident should close the way it opened; the one kind that must not is the one that exists to get an answer — "delete these four records?", "this record was changed by somebody else, keep which?" — where clicking the backdrop is not an answer and yet reads as one.
Draw your own way out. A mandatory card refuses every dismissal the platform provides, so it
needs a button of its own running closeModal(), closeStartDrawer() or closeEndDrawer(). Put it
first and leave it unemphasised, the way a Cancel sits beside a Delete.
A dropdown ignores it. A menu that cannot be closed by clicking away has trapped the page, and a dropdown is never how a question gets asked.
A dropdown opens against whatever ran the handler. There is nothing to pick: put
openDropdown("Menu card") on the button, and the menu appears beside that button.
Where against it, you can say — openDropdown("Menu card", "bottom-end"):
| Placement | Where the card goes |
|---|---|
"bottom" (the default) | under the button, centred on it |
"bottom-start" / "bottom-end" | under it, lined up with its leading / trailing edge |
"top", "top-start", "top-end" | above it — for a control near the bottom of a screen |
end, not right. A card opened bottom-end hangs from the button's right edge in English
and from its left edge in Persian and Arabic, which is the same rule the drawers follow: one script
puts the menu on the correct side in every language. Writing "bottom-right" would be a screen
that is backwards in Persian with nothing to say so — so it is not a placement, and a handler that
asks for one gets told.
You are stating a preference, not a promise: if there is no room below, the menu opens above anyway. A menu that runs off the screen is worse than a menu on the other side.
How wide it is, is how wide you drew it. The card's own width is the drawer's width and the modal's — so make the card the size you want the panel to be.
There is one of each, and showing a second card replaces the first. A screen can only have one modal open at a time, which is why there is nothing to declare: the hosts are always there and your handler says what goes in one.
A drawer's edge follows the language, not the drawing. Start is the left for someone reading English and the right for someone reading Persian or Arabic — the whole screen mirrors, because in an app what you drew is the chrome.
Esc, the backdrop and a click outside all close an overlay, and focus goes back to whatever opened it. None of that is something you set.
Step 6 — Preview it
Press Preview. The preview is the real runtime — the same component the Shell mounts, not an approximation of it — so what you see is what somebody using the app sees.
Type in the boxes. Watch the rest of the screen follow.
Rules worth knowing
A binding only reads. It runs again whenever what it reads changes, so a binding that changed something would keep restarting itself. The designer refuses one at Save and tells you which object and which property — anything that changes something belongs in a handler.
"Shown" removes, it does not hide. An element whose Shown is false is not on the page at all: nothing tabs into it, no screen reader announces it, and Ctrl+F does not find it. That is almost always what you meant.
Nothing here is security. A hidden button is a hidden button. What a person may actually do is decided on the server, every time, whatever the screen shows.
What a binding can see, today
| Name | Is |
|---|---|
state | the screen's own state, from step 1 |
screen | screen.width and screen.height — the surface, so a screen can react to a narrow window — and screen.name, the screen you are on, so a bar placed on every screen can mark its own page |
ui | ui.theme (light or dark), ui.locale (en, fa, ar) and ui.dir (ltr or rtl) — the reader's own settings |
user | who is signed in: user.name, user.email, and user.photo (empty when they have none, so bind an image's Shown to user.photo != "") — enough for a greeting or a top bar, and deliberately not their roles |
screen.params | what the screen that sent you here handed over — navigate("Details", { id }) |
Records get there through a handler, not through a name of their own. loadRecords and
getRecord put them in the screen's own state, so a binding reads state.tickets like anything
else — there is no second place to look. What is genuinely not here yet is the platform's saved
views: a screen names a table and a where, not a view.
What a binding can compute
A binding is an expression, so it cannot loop or call setState. Four small functions cover what
a caption usually needs beyond arithmetic:
| Function | Answers |
|---|---|
text(value) | the value as words, so a number can go inside a sentence — "of " + text(state.total) |
num(value) | the words as a number — what a dropdown hands back, ready for arithmetic; 0 if it is not one |
first(list, { field: value, … }) | the first row of a loaded list whose fields hold those values, or null — first(state.budgets, { par_department: state.department, par_category: state.category }) |
format(value, "integer" | "number" | "percent") | a number as your language writes it — format(174500, "integer") is 174,500 in English and Persian digits in Persian |
translate({ en: "…", fa: "…", ar: "…" }) | the words for the reader's language, falling back to English — see Two languages |
first is how a caption follows a picker to the row it names. Load the table once, on
Screen opened; then "Available: £" + format(first(state.budgets, { … }).par_remainingbudget, "integer")
follows two dropdowns wherever they go, with no handler. A loaded row carries a choice column as
its label, and a dropdown hands back the label it offered, so the two agree. Guard the case
where nothing matches with a conditional — first(…) == null ? "Choose a line" : … — because
reading a field off null fails the binding, and && does not guard it: both sides of an &&
are always evaluated.
A range is a handler's job. first matches values exactly; "the band whose minimum and
maximum bracket this amount" is a loop, and a loop belongs in the control's Value changed
handler, writing what it found to state for the caption to read. The
studio-capital-planning sample in samples/solutions does both on one screen.
Two languages on one screen
Bind a text's Text to translate(...), with every language beside the English:
translate({ en: "Opening times", fa: "ساعات کار" })
and give the page a button that switches: if (ui.locale == "fa") { setLanguage("en"); } else { setLanguage("fa"); }.
setLanguage changes the platform's language, not just the screen's: it is remembered, and in
Persian or Arabic the whole page mirrors, because every position a screen draws is start and end
rather than left and right. ui.dir says which way it reads, so an arrow can point the right way.
A language the object has not got falls back to English, so a page half-way through being translated reads in English rather than as blanks. The portal-pages-and-navigation sample is written this way, every word of it.
Something that happens by itself: the timer
Give an object a Timer ticks handler and bind its Timer interval (ms) — it runs that handler every so many milliseconds while the object is on the page:
Timer interval (ms): state.held ? 0 : 5000
Timer ticks: setState("slide", (getState("slide") + 1) % 3);
Zero, or nothing, is stopped — so pausing is part of the binding, and a carousel that holds while
you point at it sets held in its Pointer arrives and Pointer leaves handlers. Less than 250 is
raised to 250. An object that is hidden does not tick. The carousel in the component library, and on
the portal-pages-and-navigation home page, is built this way.
Troubleshooting
- "A binding on … could not run" — the expression is valid but something it names is not there. The commonest cause is a state field spelled differently from the binding: check the screen frame's Screen state list.
- The box will not let me type in it. — its Value is a computed expression, so it is read-only by design. Bind it to a plain state field instead, and compute the other thing somewhere else.
- Nothing happens when I type. — the box has no Value binding. A box with no binding keeps what is typed and tells nobody.
- The Save button never turns on. — check its Enabled expression against an empty value:
state.requester != ""is true only once something is typed. - Pressing the button does nothing. — a disabled control receives no press at all, so check its Enabled binding first; then check that the handler is on Pressed and not on an event that never fires here (Screen opens belongs to the screen frame, Value changed to a control).
- "A handler on … could not run" — the handler is valid BS Lang but something it names is not
there. The commonest cause is
setStateon a field the screen never declared. - "createRecord("…") failed: …" — the platform refused the write, and what follows the colon is its own reason: a required field with nothing in it, a value that is not one of the choices, or a permission you do not have. Check the field names against the Table designer first — a misspelled logical name is the commonest cause.
- Nothing loads, and nothing complains. — you are in Preview, which has no environment behind it. The record calls do nothing there, on purpose. Open the app from the Shell to see real records.
showModal("…")does nothing. — the name has to match the component's name exactly, and the component has to have An overlay card ticked. A card that was deleted, or renamed, leaves handlers naming it, and those quietly do nothing rather than breaking the screen.- The card shows on the screen instead of in a modal. — An overlay card is not ticked. Until it is, the component is drawn where you put it, like any other component.
- "Two components are both called …" — a card is shown by its component's name, so two of them
sharing one name is a question
showModalcannot answer. Rename one.
Try it
Import the Ticket Triage sample solution. It is this page, built: three state fields, bound properties throughout, a state the sample named itself, a modal that asks before it raises the ticket, and a popover that opens from its own button.