Skip to main content
Version: 0.1.0

State Management & Mutators

Immutable Data in a Loop

If you have used Typst for a while, you are likely used to state() and counter() to manage values. In Loom, you must be careful with these tools. Because Loom is a Time Travel engine (running your code multiple times until convergence), standard state management can behave unpredictably.

Loom uses a different model: Immutable Dictionaries.

The Problem: The "Double-Count" Trap​

Standard Typst state is based on side effects. When you write counter.step(), it modifies a global register. Since Loom runs the Weave Loop multiple times, these side effects can accumulate.

The "Old" Way (Dangerous in Loom): If Loom runs 3 passes to converge, a standard counter.step() might execute 3 times for a single item, resulting in the wrong number.

The Solution: Functional Updates​

Loom relies on Immutable Context. You don't "change" a variable; you calculate a new version of it.

  • Pass 1: Input 0 → Output 1
  • Pass 2: Input 0 → Output 1 (Stable!)

This is safe, predictable, and debuggable.


The Mutator Module (loom.mutator)​

Managing deep dictionaries in Typst (e.g., ctx + (config: (theme: (color: red)))) is often messy and verbose. Loom provides the mutator module as a general-purpose utility to handle any dictionary modification cleanly.

It isn't just for Context; it's for any data transformation.

How it Works​

The mutator.batch(target, ops) function takes a dictionary and applies a list of operations to it, returning a new dictionary.

#import "@preview/loom:0.1.0": mutator

Use Case 1: Complex Scope Updates​

This is the most common usage: safely modifying the Context for children.

#let my-component(body) = motif(
scope: (ctx) => mutator.batch(ctx, {
import mutator: *
// Safely update nested values without overwriting the whole 'theme'
nest("theme", (
put("primary", blue)
update("scale", s => s * 1.2)
)),
// Ensure a flag exists
ensure("debug-mode", false)
}),
body
)

Use Case 2: General Data Processing​

You can use mutator to clean up raw data (e.g., from a JSON file or CSV) before displaying it. This has nothing to do with Scope; it's just powerful data manipulation.

#let raw-user-data = (
name: " Alice ",
role: "admin",
login-count: 5
)

// Clean up the data for display
#let clean-user = mutator.batch(raw-user-data, {
import mutator: *
// 1. Trim whitespace
update("name", n => n.trim())
// 2. Remove sensitive info
remove("login-count")
// 3. Add derived fields
put("display-name", "Admin Alice")
// 4. Merge default settings
merge((active: true, group: "staff"))
})

// Result: (name: "Alice", role: "admin", display-name: "...", active: true, ...)

Use Case 3: Constructing Signals (Measure Phase)​

When emitting signals in the measure phase, you often want to build a rich data packet.

measure: (ctx, children-signals) => {
// Start with a base signal
let signal = (source: "section")

// Conditionally add fields based on logic
let final-signal = mutator.batch(signal, {
import mutator: *
put("id", ctx.my-id)
// Only add 'total' if we actually have children
if children-signals.len() > 0 {
put("total", children-signals.len())
}
})

(final-signal, none)
}

Available Operations​

OperationDescription
put(key, value)Sets a value, overwriting if it exists.
ensure(key, default)Sets a value only if it is currently missing (none).
derive(key, value, default)Sets a value, deriving from dictionary if value is auto, with fallback default.
update(key, fn)Transforms a value: fn(old_value) => new_value.
remove(key)Deletes a key.
nest(key, ops)Applies a batch of operations to a sub-dictionary.
merge(dict)Merges another dictionary into the current one.

Pro Tip: mutator functions are pure. They never modify the original variable; they always return a new, modified copy.