Forms

Marko renders HTML, and HTML forms work without any JavaScript. Starting from a native <form> keeps submissions functional before scripts load, and Marko's reactivity can then layer on richer behavior where it helps.

Server Submission

In a Marko Run app, a form posts to a route, and a +handler beside the page receives the submission. The handler reads the submitted fields with the standard FormData API and redirects when finished.

src/routes/feedback/+page.marko
<h1>Send Feedback</h1>
<form method="POST">
  <label for="message">Message</label>
  <textarea id="message" name="message" required/>
  <button type="submit">Send</button>
</form>
<h1>Send Feedback</h1>
<form method="POST">
  <label for="message">Message</label>
  <textarea id="message" name="message" required/>

  <button type="submit">Send</button>
</form>
h1 -- Send Feedback
form method="POST"
  label for="message" -- Message
  textarea id="message" name="message" required
  button type="submit" -- Send
h1 -- Send Feedback
form method="POST"
  label for="message" -- Message
  textarea id="message" name="message" required

  button type="submit" -- Send
src/routes/feedback/+handler.ts
export async function POST(context) {
  const data = await context.request.formData();
  const message = data.get("message");

  if (typeof message !== "string" || !message.trim()) {
    return new Response("A message is required.", { status: 400 });
  }

  await saveFeedback(message);
  return context.redirect("/feedback/thanks", 303);
}

GET requests continue to the page as usual, while the POST export handles submissions from the form above.

Note

Redirecting after a successful POST (the Post/Redirect/Get pattern) prevents a page refresh from resubmitting the form. See Preventing Duplicate Form Submissions for making repeated requests safe on the server.

Validating

Native validation attributes such as required, min, and pattern block invalid submissions in the browser without any additional code.

<form method="POST">
  <label for="age">Age</label>
  <input id="age" name="age" type="number" min="18" required>
  <button type="submit">Continue</button>
</form>
<form method="POST">
  <label for="age">Age</label>
  <input id="age" name="age" type="number" min="18" required>

  <button type="submit">Continue</button>
</form>
form method="POST"
  label for="age" -- Age
  input id="age" name="age" type="number" min="18" required
  button type="submit" -- Continue
form method="POST"
  label for="age" -- Age
  input id="age" name="age" type="number" min="18" required

  button type="submit" -- Continue
Warning

Browser validation is a convenience, not a boundary. Requests can be constructed without the form, so handlers must validate submitted values again on the server.

Reacting to Input

Binding form controls to tag variables enables live behavior such as previews, character counts, or dependent fields. The change handler shorthand (:=) keeps a variable in sync with a control.

<let/message="">
<form method="POST">
  <label for="message">Message</label>
  <textarea id="message" name="message" maxlength="280" value:=message/>
  <p>${280 - message.length} characters left</p>
  <button type="submit">Send</button>
</form>
<let/message="">

<form method="POST">
  <label for="message">Message</label>
  <textarea id="message" name="message" maxlength="280" value:=message/>
  <p>${280 - message.length} characters left</p>

  <button type="submit">Send</button>
</form>
let/message=""
form method="POST"
  label for="message" -- Message
  textarea id="message" name="message" maxlength="280" value:=message
  p -- ${280 - message.length} characters left
  button type="submit" -- Send
let/message=""

form method="POST"
  label for="message" -- Message
  textarea id="message" name="message" maxlength="280" value:=message
  p -- ${280 - message.length} characters left

  button type="submit" -- Send

Because the form still posts natively, this enhancement degrades gracefully: without JavaScript the character counter stays static, but the form submits all the same.

Warning

Deriving disabled on the submit button from bound state (for example, disabling it until a field is filled in) renders the button disabled in the server HTML, locking out visitors whose JavaScript has not loaded. Prefer validation attributes, which the browser enforces on its own.

Binding Controls

Every stateful form control has a Change handler in Marko, so the := shorthand from the previous section is not limited to text. Radio groups and checkboxes bind through the checkedValue attribute, which holds a string for radios and an array for checkbox groups, and <select> binds through its enhanced value attribute.

<let/format="html">
<let/topics=[]>
<let/frequency="weekly">
<form method="POST" action="/newsletter">
  <fieldset>
    <legend>Format</legend>
    <label>
      <input type="radio" name="format" value="html" checkedValue:=format>
      HTML
    </label>
    <label>
      <input type="radio" name="format" value="text" checkedValue:=format>
      Plain text
    </label>
  </fieldset>
  <fieldset>
    <legend>Topics</legend>
    <label>
      <input
        type="checkbox"
        name="topics"
        value="releases"
        checkedValue:=topics
      >
      Releases
    </label>
    <label>
      <input
        type="checkbox"
        name="topics"
        value="community"
        checkedValue:=topics
      >
      Community
    </label>
  </fieldset>
  <label for="frequency">Frequency</label>
  <select id="frequency" name="frequency" value:=frequency>
    <option value="daily">Daily</option>
    <option value="weekly">Weekly</option>
    <option value="monthly">Monthly</option>
  </select>
  <p>
    ${topics.length} topic${topics.length === 1 ? "" : "s"}, delivered
    ${frequency}.
  </p>
  <button type="submit">Subscribe</button>
</form>
<let/format="html">
<let/topics=[]>
<let/frequency="weekly">

<form method="POST" action="/newsletter">
  <fieldset>
    <legend>Format</legend>
    <label>
      <input type="radio" name="format" value="html" checkedValue:=format>
      ${" "}HTML
    </label>
    <label>
      <input type="radio" name="format" value="text" checkedValue:=format>
      ${" "}Plain text
    </label>
  </fieldset>

  <fieldset>
    <legend>Topics</legend>
    <label>
      <input
        type="checkbox"
        name="topics"
        value="releases"
        checkedValue:=topics
      >
      ${" "}Releases
    </label>
    <label>
      <input
        type="checkbox"
        name="topics"
        value="community"
        checkedValue:=topics
      >
      ${" "}Community
    </label>
  </fieldset>

  <label for="frequency">Frequency</label>
  <select id="frequency" name="frequency" value:=frequency>
    <option value="daily">Daily</option>
    <option value="weekly">Weekly</option>
    <option value="monthly">Monthly</option>
  </select>

  <p>
    ${topics.length} topic${topics.length === 1 ? "" : "s"}, delivered
    ${frequency}.
  </p>

  <button type="submit">Subscribe</button>
</form>
let/format="html"
let/topics=[]
let/frequency="weekly"
form method="POST" action="/newsletter"
  fieldset
    legend -- Format
    label
      input type="radio" name="format" value="html" checkedValue:=format
      -- HTML
    label
      input type="radio" name="format" value="text" checkedValue:=format
      -- Plain text
  fieldset
    legend -- Topics
    label
      input type="checkbox" name="topics" value="releases" checkedValue:=topics
      -- Releases
    label
      input type="checkbox" name="topics" value="community" checkedValue:=topics
      -- Community
  label for="frequency" -- Frequency
  select id="frequency" name="frequency" value:=frequency
    option value="daily" -- Daily
    option value="weekly" -- Weekly
    option value="monthly" -- Monthly
  p --
    ${topics.length} topic${topics.length === 1 ? "" : "s"}, delivered
    ${frequency}.
  button type="submit" -- Subscribe
let/format="html"
let/topics=[]
let/frequency="weekly"

form method="POST" action="/newsletter"
  fieldset
    legend -- Format
    label
      input type="radio" name="format" value="html" checkedValue:=format
      -- ${" "}HTML
    label
      input type="radio" name="format" value="text" checkedValue:=format
      -- ${" "}Plain text

  fieldset
    legend -- Topics
    label
      input type="checkbox" name="topics" value="releases" checkedValue:=topics
      -- ${" "}Releases
    label
      input type="checkbox" name="topics" value="community" checkedValue:=topics
      -- ${" "}Community

  label for="frequency" -- Frequency
  select id="frequency" name="frequency" value:=frequency
    option value="daily" -- Daily
    option value="weekly" -- Weekly
    option value="monthly" -- Monthly

  p --
    ${topics.length} topic${topics.length === 1 ? "" : "s"}, delivered
    ${frequency}.

  button type="submit" -- Subscribe

Successful, named controls still submit their values natively; the bound variables exist so the rest of the template can react, as the summary line does here.

Caution

value bindings report strings. Binding a numeric input directly (value:=quantity) turns the variable into a string on the first edit. In contrast, checked bindings produce booleans, while checkedValue may produce an array for checkbox groups. Add a refining function to convert each value change before it is assigned:

<let/quantity=1>
<input type="number" name="quantity" min="1" value:parseFloat:=quantity>
<let/quantity=1>

<input type="number" name="quantity" min="1" value:parseFloat:=quantity>
let/quantity=1
input type="number" name="quantity" min="1" value:parseFloat:=quantity
let/quantity=1

input type="number" name="quantity" min="1" value:parseFloat:=quantity

Reusable Fields

Repeated label-and-input markup can move into a custom tag, discovered from a tags/ directory. The <id> tag generates a unique id per instance to associate the label with its control, and binding the native input to input.value forwards both the value and its change handler to the parent, so the tag is bound with := exactly like a native control.

tags/labeled-input.marko
<id/fieldId>
<div class="field">
  <label for=fieldId>${input.label}</label>
  <input id=fieldId name=input.name value:=input.value>
</div>
<id/fieldId>

<div class="field">
  <label for=fieldId>${input.label}</label>
  <input id=fieldId name=input.name value:=input.value>
</div>
id/fieldId
div class="field"
  label for=fieldId -- ${input.label}
  input id=fieldId name=input.name value:=input.value
id/fieldId

div class="field"
  label for=fieldId -- ${input.label}
  input id=fieldId name=input.name value:=input.value
profile-form.marko
<let/displayName="">
<form method="POST">
  <labeled-input label="Display name" name="displayName" value:=displayName/>
  <p hidden=!displayName>Previewing as ${displayName}</p>
  <button type="submit">Save</button>
</form>
<let/displayName="">

<form method="POST">
  <labeled-input label="Display name" name="displayName" value:=displayName/>
  <p hidden=!displayName>Previewing as ${displayName}</p>

  <button type="submit">Save</button>
</form>
let/displayName=""
form method="POST"
  labeled-input label="Display name" name="displayName" value:=displayName
  p hidden=!displayName -- Previewing as ${displayName}
  button type="submit" -- Save
let/displayName=""

form method="POST"
  labeled-input label="Display name" name="displayName" value:=displayName
  p hidden=!displayName -- Previewing as ${displayName}

  button type="submit" -- Save

Components that hold their own state can offer the same interface by making that state controllable; Controllable Components covers the pattern in depth.


Contributors

Helpful? You can thank these awesome people! You can also edit this doc if you see any issues or want to improve it.