Skip to content
forma.
v3.0
☰

Browser lesson 3 / 5 · 6–10 minutes

Derive, don’t synchronize

Move a seat slider and show the matching monthly price, including a team discount.

Open exercise Open solution All lessons

Forma 3.0 · Browser TSX + CSS. Open the exercise, review its source, then choose Run to see the preview. These lesson links load the exact files shown below and wait for you to run them.

Keep one writable source

The preview starts with four seats at $12 each. Moving the slider updates the seats signal in browser memory; createMemo calculates the total from seats(), and the price display reads that result. You do not need a second setter for the price.

The exercise changes the pricing rule: each seat costs $12, and a team with ten or more seats receives 10% off its entire subtotal. This is a fictional example with no checkout, payment or network request.

Check a boundary, not just a happy path

Nine, ten and eleven seats test the two sides of the discount threshold. Nine and ten happen to have the same total under this rule; that equality is expected. Eleven verifies that the discounted calculation continues to scale.

Keep the calculation numeric inside the memo; use toFixed(2) only to display two decimal places. This illustrates a pricing rule, not a billing system: it uses ordinary JavaScript numbers and saves no purchase or team settings.

Your exercise

  1. In createMemo, apply a 10% discount when seats() is at least 10.
  2. Keep the total derived instead of adding another signal or updating it from onInput.
  3. Display the price with exactly two decimal places.

The starter already works. Make the requested changes in app.tsx, then choose Run again. A successful run starts a fresh preview with your new code and its initial values. A compiler error leaves the last successful preview in place; check Diagnostics before judging the result.

What Run, Reset and refresh keep

The values you change in these previews live only in browser memory. Running again or refreshing the page discards them. Your source edits are separate: the editor keeps them while this page is open, but does not autosave them.

Share link puts the current source files in the URL; Download ZIP saves those files as a local project. Neither saves the current count, checkbox states or typed notes. Refreshing a lesson link reloads the source in its current URL and waits for Run; edits not captured in that URL are lost.

On a lesson link, the playground’s Reset restores the source originally loaded by that link and clears the preview. Choose Run to start it again. This is separate from an application button such as Reset count, which changes a value inside a running preview.

Read the complete starter files
app.tsx
import { createSignal, createMemo, h, mount } from '@getforma/core';
import './styles.css';

function TeamPlan() {
  const [seats, setSeats] = createSignal(4);
  // Exercise: apply 10% off at 10 seats and format the output to two decimals.
  const total = createMemo(() => seats() * 12);
  return <main>
    <p class="eyebrow">02 / Derived state</p>
    <h1>Room for your team.</h1>
    <p>Move the slider: five seats gives $60. Only seats is writable; the total is calculated from it. This is a local estimate, with no checkout or saved order.</p>
    <div class="card">
      <label>Team seats
        <input type="range" min="1" max="20" value={() => seats()}
          onInput={event => setSeats(Number(event.currentTarget.value))} />
      </label>
      <p>{() => seats()} seats × $12 / month</p>
      <output aria-label="Monthly total">{() => '$' + total()}</output>
    </div>
  </main>;
}

mount(TeamPlan, '#app');
styles.css
:root { font-family: Inter, ui-sans-serif, system-ui, sans-serif; color: #162225; background: #f2f5f1; }
* { box-sizing: border-box; }
body { margin: 0; padding: 32px; }
main { max-width: 540px; margin: 0 auto; }
h1 { font-size: clamp(28px, 7vw, 40px); line-height: 1.1; letter-spacing: -.05em; }
p { line-height: 1.6; color: #52615d; }
button, input { font: inherit; }
button { cursor: pointer; border: 1px solid #1a3530; border-radius: 8px; padding: 10px 15px; background: #173e34; color: #fff; }
button:hover { background: #24624f; }
button:focus-visible, input:focus-visible { outline: 3px solid #cb7bff; outline-offset: 3px; }
button:disabled { opacity: .5; cursor: default; }
input { min-width: 0; padding: 10px; border: 1px solid #a5b4aa; border-radius: 8px; background: white; }
label { display: grid; gap: 8px; }
output { display: inline-block; font-variant-numeric: tabular-nums; font-size: 42px; font-weight: 700; }
.eyebrow { text-transform: uppercase; letter-spacing: .15em; font-size: 11px; font-weight: 700; color: #326b58; }
.controls { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; margin: 24px 0; }
.card { padding: 22px; border: 1px solid #cbd8ce; border-radius: 14px; background: #fff; }
ul { list-style: none; padding: 0; display: grid; gap: 10px; }
li { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; padding: 14px; border: 1px solid #cbd8ce; border-radius: 10px; background: white; }
li > span:first-child { flex: 1; }
.inventory-row { display: grid; grid-template-columns: 1fr auto; }
.inventory-row label { grid-column: 1 / -1; }
@media (max-width: 420px) { body { padding: 20px; } }

Verify the behavior

ActionExpected result
Set Team seats to 9$108.00: no discount.
Set Team seats to 10$108.00: $120 less 10%.
Set Team seats to 11$118.80: $132 less 10%.

These checks exercise the visible result. Running without a compiler error is only the first step.

A working solution

The memo reads seats(), so Forma knows to update its result when the slider changes. At ten seats it calculates 10 × $12 × 0.9 = $108.00; at eleven it produces $118.80. One source value keeps the seat label and price consistent.

Open solution in playground

Read the complete solution files
app.tsx
import { createSignal, createMemo, h, mount } from '@getforma/core';
import './styles.css';

function TeamPlan() {
  const [seats, setSeats] = createSignal(4);
  // Exercise: apply 10% off at 10 seats and format the output to two decimals.
  const total = createMemo(() => {
    const count = seats();
    const subtotal = count * 12;
    return count >= 10 ? subtotal * 0.9 : subtotal;
  });
  return <main>
    <p class="eyebrow">02 / Derived state</p>
    <h1>Room for your team.</h1>
    <p>Move the slider: five seats gives $60. Only seats is writable; the total is calculated from it. This is a local estimate, with no checkout or saved order.</p>
    <div class="card">
      <label>Team seats
        <input type="range" min="1" max="20" value={() => seats()}
          onInput={event => setSeats(Number(event.currentTarget.value))} />
      </label>
      <p>{() => seats()} seats × $12 / month</p>
      <output aria-label="Monthly total">{() => '$' + total().toFixed(2)}</output>
    </div>
  </main>;
}

mount(TeamPlan, '#app');
styles.css
:root { font-family: Inter, ui-sans-serif, system-ui, sans-serif; color: #162225; background: #f2f5f1; }
* { box-sizing: border-box; }
body { margin: 0; padding: 32px; }
main { max-width: 540px; margin: 0 auto; }
h1 { font-size: clamp(28px, 7vw, 40px); line-height: 1.1; letter-spacing: -.05em; }
p { line-height: 1.6; color: #52615d; }
button, input { font: inherit; }
button { cursor: pointer; border: 1px solid #1a3530; border-radius: 8px; padding: 10px 15px; background: #173e34; color: #fff; }
button:hover { background: #24624f; }
button:focus-visible, input:focus-visible { outline: 3px solid #cb7bff; outline-offset: 3px; }
button:disabled { opacity: .5; cursor: default; }
input { min-width: 0; padding: 10px; border: 1px solid #a5b4aa; border-radius: 8px; background: white; }
label { display: grid; gap: 8px; }
output { display: inline-block; font-variant-numeric: tabular-nums; font-size: 42px; font-weight: 700; }
.eyebrow { text-transform: uppercase; letter-spacing: .15em; font-size: 11px; font-weight: 700; color: #326b58; }
.controls { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; margin: 24px 0; }
.card { padding: 22px; border: 1px solid #cbd8ce; border-radius: 14px; background: #fff; }
ul { list-style: none; padding: 0; display: grid; gap: 10px; }
li { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; padding: 14px; border: 1px solid #cbd8ce; border-radius: 10px; background: white; }
li > span:first-child { flex: 1; }
.inventory-row { display: grid; grid-template-columns: 1fr auto; }
.inventory-row label { grid-column: 1 / -1; }
@media (max-width: 420px) { body { padding: 20px; } }

Need the API details? Read the Forma 3.0 core reference. This playground compiles TSX and runs browser code. It does not run a Rust server or demonstrate server rendering; the later native lessons cover that path.

Find your next answer.

Search Forma 3.0 documentation, guides and examples.