Skip to main content

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​

  1. Open the screen in the designer (Screens → select one → Design).
  2. Click the screen frame itself — the outermost box — and open the Screen tab in the inspector on the right.
  3. Under Screen state, press Add a state field.
  4. 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.

Why state lives on the screen

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​

  1. Select any object on the canvas.
  2. In the Screen tab, under Bindings, choose a property from Bind a property.
  3. An editor appears with an example in it. Replace it with your expression.

Four properties can be bound:

PropertyDrivesOn
Textwhat it says — a heading's words, a button's label, a box's placeholderanything
Shownwhether it is on the screen at allanything
Enabledwhether a control may be usedcontrols
Valuewhat a control holdscontrols

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​

  1. Select the button.
  2. Under When this happens, choose Pressed from Handle an event.
  3. Write what should happen — one instruction per line:
notify("Ticket raised for " + getState("requester"));
setState("requester", "");
setState("showDetails", false);

What a handler can call:

CallDoes
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.

To change only how something looks, draw a state

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.

Say something afterwards

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.

What a screen may do is decided on the server

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.

To send somebody to a page, draw a link

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.

Not on a portal

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 nameMeans
A green dot, name in boldWritten, and it makes sense
A red dot, name in boldWritten, and it has a mistake in it — open it and the editor underlines where
A hollow dot, name dimmedNothing 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 messageWhat to do
Nothing is selected — or more than one thing isSelect exactly one object
Code is written on the component itselfYou 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 graphicSay 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.

  1. 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.

  2. 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.

  3. Show it from a handler, by the component's own name:

    CallWhere it appearsGood for
    showModal("Confirm card")centred over the screen, with a backdropa question that has to be answered before anything else
    openDropdown("Menu card")attached to the thing that was presseda small menu, a hint, a short form
    openStartDrawer("Filters card")full height, sliding in from the start edgefilters, a menu on a narrow window
    openEndDrawer("Details card")full height, from the end edgea detail panel
  4. Close it with closeModal(), closeDropdown(), closeStartDrawer() or closeEndDrawer() — 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"):

PlacementWhere 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​

NameIs
statethe screen's own state, from step 1
screenscreen.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
uiui.theme (light or dark), ui.locale (en, fa, ar) and ui.dir (ltr or rtl) — the reader's own settings
userwho 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.paramswhat 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:

FunctionAnswers
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 setState on 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 showModal cannot 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.