Learn Tutuca

From a small control to a document workspace

Build up your understanding of state, events, composition and host services through five guided lessons. Each has a working component you can use, read and change.

Before you begin

You need a browser and a little familiarity with variables and functions. There is nothing to install: the cards below run here. Follow the lessons in order; each adds one idea to the previous lesson.

Use the preview first, then find the code responsible for what happened. Editing the source rebuilds the card and resets its state, so make one change at a time. To explore fixtures and run the included tests, follow the card-playground link under each lesson. Copy your edits somewhere safe before leaving the page.

  1. Quantity picker — Fields, events, derived values and invariants.
  2. Notification preferences — Bindings, conditional fields and draft state.
  3. Task list — Child components, messages and keyed collections.
  4. Contact directory — Search, pagination, selection and owner-held state.
  5. Document workspace — Composition, alternate views and asynchronous intents.

How to read a .tutu file

A component has state and a view of that state. An event sends a message to a handler; the handler changes state, and the view updates. Follow that path through these sections:

spec:
Names the components, declares their fields and types, and can state contracts such as a valid quantity range.
logic:
Contains receive handlers that change state, compute functions that derive values, and pred predicates that answer true or false.
view:
Describes elements, displayed values and event bindings. it.quantity reads a field; @(it.quantity) inserts its value into the view.
fixtures: and tests:
Provide useful starting states and repeatable interactions with expected results.

You will also see := for assigning a field, ~on_click: for handling a click, and e.value for an input's value. Indentation groups declarations and nested elements.

Step 1: Quantity picker

A shopping cart needs a quantity that stays within its allowed range. This component stores quantity and unit_cents; the total comes from those fields whenever the view reads it.

Follow a click through the code

Find the Increase button in view:. Its ~on_click: increase binding sends a message to receive increase in logic:. That handler updates it.quantity. The output reads the new quantity, and total() derives the new price.

The minimum and maximum predicates disable the buttons at the limits. The handlers also clamp the value, so a message sent without clicking a button still respects the range. The invariant in spec: declares the same rule as a contract.

Try it

  1. Click Increase once. The quantity becomes 2 and the total becomes €10.
  2. Increase to 10. The Increase button becomes disabled and the total is €50.
  3. Decrease to 1. The Decrease button becomes disabled.

Your turn: change the maximum from 10 to 6 in the invariant, the Increase handler and the maximum predicate. Update the maximum fixture and the upper-bound test to match. The button should stop at 6, and the total should be €30. This exercise shows why a UI limit, a state rule and its tests need to agree.

Next, use the same event-to-state path in a form whose edits are applied only when the user chooses.

Open this lesson in the card playground

Step 2: Notification preferences

A settings form needs to distinguish what the user is editing from what they have accepted. Here enabled, email and frequency are draft fields. The corresponding saved_ fields hold the applied values.

Separate editing from applying

The email input binds value: it.email and assigns e.value on input. Typing changes the draft immediately. The Apply handler checks the address before copying the draft into the saved fields; Reset copies the saved fields back. The address check is deliberately small: enabled email updates require an address containing @.

show(it.enabled) includes the email and frequency controls only while updates are enabled. Removing those elements from the view does not clear their state fields.

Try it

  1. Enable email updates and click Apply with an empty address. Read the validation message.
  2. Enter alex@example.test, choose Daily digest, and apply.
  3. Type a different address, then click Reset draft. The applied address and frequency return.
  4. Disable updates and enable them again. The draft address is still there.

Your turn: add a Monthly digest option with the value monthly. Apply it, choose a different frequency, and reset. Monthly should return without changing either handler: both already copy the frequency field.

These saved fields belong to this component's session. The final lesson adds a host service for saving outside the form.

Open this lesson in the card playground

Step 3: Task list

A task list introduces composition. TaskList owns the draft and a map of child instances; each TaskItem owns its text and completion state. The map key identifies a task even when filtering changes its position on screen.

Give the parent and child distinct jobs

The Add handler trims the draft, ignores empty input, creates a child with a new ID, and clears the draft. In the view, each walks the map and render(value) renders each visible child.

When a child is toggled, it updates its own done field and notifies task_done with its ID and new value. The dynamic route, ~route: dyn, lets the containing component answer. The parent keeps a completion index for filtering and counting. Removing a child similarly asks its parent to remove the map entry.

Try it

  1. Use Add task with a blank draft. No task is created.
  2. Add “Return library books” and “Book a dentist appointment”. The count is 2 remaining.
  3. Complete the first task. Active shows only the second; Completed shows only the first.
  4. Remove the completed task, then choose All. The second task remains, with its original text and state.

Your turn: add a displayed total using it.items.length(). With two tasks and one completed, it should show 2 total and 1 remaining. Switching filters should change the visible rows without changing either count.

The next lesson uses stable identity again, this time to keep an editor attached to a contact while search results change.

Open this lesson in the card playground

Step 4: Contact directory

A directory separates the full collection from the current results. contacts owns the data, order defines its order, and keys holds the current page of matching contact IDs. The selected ID and child editor are separate fields.

Search first, then choose a page

The Search handler stores the query, resets the page to zero, and sends init. That handler asks the host for contact_page. The demo service matches names or email addresses across the collection before taking a page of three results. Filtering only the visible page would miss contacts on other pages.

Selecting a contact creates an editor with that contact's ID and details. Save validates the fields and notifies update_contact; the directory writes the result into the entry with that ID and refreshes the search.

Try it

  1. Go to page 2, then search for Alex Chen. Alex appears on page 1 of 1, even though the search started on another page.
  2. Select Alex, change the name to Avery Chen, and save. The row disappears from the current results because the full-name query no longer matches.
  3. The editor stays attached to the same contact. Search for Avery to see the updated row.
  4. Search for nobody. The empty-results message replaces the rows.

Your turn: repeat the rename while searching for alex@example.test. The row should remain visible, because changing a name does not change the email address. Explain the difference before checking the result.

The host here answers immediately. The workspace introduces requests whose answers arrive later, after the user may have continued editing.

Open this lesson in the card playground

Step 5: Document workspace

A workspace combines child components with asynchronous services. DocumentWorkspace owns editors keyed by document ID. Changing selected changes which editor is rendered; each editor keeps its own draft.

Keep a draft safe while a request is running

An editor stores both source and saved_source. Their difference determines whether to show “Unsaved changes”. Preview renders the same source as Markdown; it does not create another copy of the document.

Save sets busy and asks the host to store the document ID and current source. When the answer arrives, save_document_ok updates the saved copy from that answer. If the user typed more while waiting, the newer draft still differs from the saved copy and stays marked as unsaved.

Reload also records the editing revision when it starts. Its success handler only replaces an unchanged, clean draft. Failure handlers clear the busy state and show how to retry. An _unhandled handler explains which host service is missing when nobody answers the request.

Try it

  1. Write a Markdown heading in Welcome. Switch to Meeting notes and back. Your Welcome draft is still there.
  2. Choose Preview to see the heading rendered, then return to Edit.
  3. Check “Simulate the next request failing” and save. The draft remains and the message invites you to retry.
  4. Save again. After the response, the confirmation appears and the unsaved indicator disappears.
  5. Edit the text without saving, then reload. The component keeps your unsaved draft.

Your turn: save Welcome and immediately switch to Meeting notes. Return to Welcome after the response. Its confirmation belongs to Welcome; the other document's draft should be unchanged. The keyed child path keeps the response connected to the editor that made the request.

The demo host delays responses and stores documents in memory. Restarting the example resets that storage. To make a durable editor, connect the same intent names to your application's storage service.

Open this lesson in the card playground

Turn an interaction into a test

Open the quantity picker in the card playground and run its Tests pane. Find quantity changes the total in the source: it selects the fresh fixture, clicks Increase, and checks both the quantity and the total. The selector button.increase identifies the button by its element and class.

  1. Read the expected total, then use the preview to produce that result yourself.
  2. Temporarily change the expected total to a different amount and run the tests. The scenario should report the mismatch.
  3. Restore the expected value and run again. It should pass.

When you extend a component, add a scenario for its user-visible result. Test the edge cases too: an empty task, a quantity at its limit, a search with no matches, and a save that fails. Intent fixtures let a test supply a successful or failed service response without a network request.

Use these ideas in an application

Start with the smallest component that covers your task. Keep draft fields close to the form that edits them, let a parent own collection membership, and use stable keys when rendering children. Derive totals and counts from the fields that determine them.

The directory's contact_page handler and the workspace's load_document and save_document handlers are demo services supplied by this page's host. Their data lasts for the demo session. A standalone host must register its own implementations; saving to a server belongs there. Handle success, failure and an unanswered request in the component.

For a MoonBit application, the same .tutu format generates compiled components. The MoonBit playground shows the module assembly and any adapters needed by that backend. Continue with the source walkthrough and integration guide, inspect the same components in Storybook, or use the playgrounds' Reference groups for a specific feature.