Reactive Records

Reactive records are a special type of record that support automatic change tracking and parent/child introspection. Unlike regular immutable records, reactive records have mutable fields and can notify callbacks when their values change.

Defining Reactive Records

Use the reactive keyword instead of type to define a reactive record. All fields are automatically mutable.

# Regular record (immutable)
type Point = { x: Int, y: Int }

# Reactive record (mutable, with change tracking)
reactive Point = { x: Int, y: Int }
reactive Line = { start: Point, end: Point }

Creating and Modifying

Reactive records are created just like regular records. The difference is you can directly assign to their fields.

reactive Counter = { value: Int, name: String }

main() = {
    counter = Counter(value: 0, name: "clicks")

    # Direct field assignment (not possible with regular records)
    counter.value = counter.value + 1
    counter.value = counter.value + 1
    counter.name = "total clicks"

    println(counter.value)  # 2
    println(counter.name)   # "total clicks"
}

Note: Regular records require the mutable keyword on specific fields. Reactive records make all fields mutable by default and add change tracking capabilities.

Change Callbacks with onChange

Register callbacks that fire synchronously whenever a field changes. Callbacks receive the field name, old value, and new value.

reactive Point = { x: Int, y: Int }

main() = {
    p = Point(x: 0, y: 0)

    # Register a change callback
    p.onChange((fieldName, oldValue, newValue) => {
        println(fieldName ++ " changed: " ++ show(oldValue) ++ " -> " ++ show(newValue))
    })

    # These assignments trigger the callback immediately
    p.x = 10
    p.y = 20
}
# Output:
# x changed: 0 -> 10
# y changed: 0 -> 20

Callbacks are synchronous - they execute immediately when a field changes, before the next line of code runs. You can register multiple callbacks; they execute in registration order.

reactive Counter = { value: Int }

mvar log: List[String] = []

main() = {
    c = Counter(value: 0)

    # Multiple callbacks
    c.onChange((f, old, new) => { log = log ++ ["first: " ++ show(new)] })
    c.onChange((f, old, new) => { log = log ++ ["second: " ++ show(new)] })

    c.value = 42

    println(log)  # ["first: 42", "second: 42"]
}

Read Callbacks with onRead

In addition to change callbacks, you can register callbacks that fire whenever a field is read. This is useful for lazy loading, access tracking, or debugging which parts of your code access which fields.

reactive Config = { apiKey: String, endpoint: String }

main() = {
    config = Config(apiKey: "secret-123", endpoint: "https://api.example.com")

    # Register a read callback
    config.onRead((fieldName, value) => {
        println("Field '" ++ fieldName ++ "' was accessed")
    })

    # These reads trigger the callback
    x = config.apiKey      # Field 'apiKey' was accessed
    y = config.endpoint    # Field 'endpoint' was accessed
}

Read callbacks receive the field name and its current value. Like change callbacks, they are synchronous and execute immediately when the field is accessed.

# Practical use case: Access tracking for debugging
reactive UserData = { name: String, email: String, password: String }

mvar accessLog: List[String] = []

main() = {
    user = UserData(name: "Alice", email: "alice@example.com", password: "secret")

    # Track all field accesses
    user.onRead((field, value) => {
        accessLog = accessLog ++ [field]
    })

    # Some code that accesses fields...
    println("Hello, " ++ user.name)
    sendEmail(user.email)

    # See which fields were accessed
    println("Fields accessed: " ++ show(accessLog))
    # Output: Fields accessed: ["name", "email"]
    # Note: password was never accessed!
}

Tip: Use onRead sparingly in production code as it adds overhead to every field access. It's most useful for debugging, auditing, and implementing lazy-loading patterns.

Parent/Child Introspection

When reactive records are nested, parent-child relationships are automatically tracked. Use .parents and .children for introspection.

reactive Point = { x: Int, y: Int }
reactive Line = { start: Point, end: Point }

main() = {
    p1 = Point(x: 0, y: 0)
    p2 = Point(x: 10, y: 10)
    line = Line(start: p1, end: p2)

    # .parents returns List[(ReactiveRecord, String)]
    # Each tuple is (parent, fieldName)
    match p1.parents {
        [(parent, fieldName)] -> println("p1 is in field: " ++ fieldName)
        _ -> println("no parent")
    }
    # Output: p1 is in field: start

    # .children returns List[ReactiveRecord]
    println("line has " ++ show(length(line.children)) ++ " children")
    # Output: line has 2 children
}

Use Cases

When to Use Reactive Records

  • UI State Management - Track form fields, component state with automatic change detection
  • Observable Data Models - Build reactive data layers where changes propagate automatically
  • Undo/Redo Systems - Use onChange to capture state changes for history
  • Validation - Trigger validation logic whenever a field changes
  • Debugging - Log all state changes during development

Practical Example: Form Validation

reactive FormData = {
    username: String,
    email: String,
    isValid: Bool
}

validate(form) = {
    hasUsername = length(form.username) >= 3
    hasEmail = form.email.contains("@")
    form.isValid = hasUsername && hasEmail
}

main() = {
    form = FormData(username: "", email: "", isValid: false)

    # Auto-validate on any change
    form.onChange((field, old, new) => {
        if field != "isValid" {
            validate(form)
            println("Valid: " ++ show(form.isValid))
        }
    })

    form.username = "jo"      # Valid: false (too short)
    form.username = "john"    # Valid: false (no email)
    form.email = "john@x.com" # Valid: true
}

Practical Example: Change Log

reactive Document = { title: String, content: String }

type Change = { field: String, oldVal: String, newVal: String }

mvar history: List[Change] = []

main() = {
    doc = Document(title: "Untitled", content: "")

    # Track all changes
    doc.onChange((field, old, new) => {
        history = history ++ [Change(field: field, oldVal: show(old), newVal: show(new))]
    })

    doc.title = "My Document"
    doc.content = "Hello, world!"
    doc.content = "Hello, Nostos!"

    # Print change history
    history.each(change => {
        println(change.field ++ ": " ++ change.oldVal ++ " -> " ++ change.newVal)
    })
}
# Output:
# title: "Untitled" -> "My Document"
# content: "" -> "Hello, world!"
# content: "Hello, world!" -> "Hello, Nostos!"

Reactive vs Regular Records

Feature Regular Record Reactive Record
Keyword type reactive
Field mutability Immutable by default All fields mutable
Change callbacks Not supported .onChange(callback)
Read callbacks Not supported .onRead(callback)
Parent tracking Not supported .parents
Child tracking Not supported .children
Memory overhead Minimal Higher (tracking data)
Best for Immutable data, DTOs Stateful objects, UI

Performance note: Reactive records have slightly higher memory overhead due to parent tracking and callback storage. Use regular records for simple data transfer objects and reactive records when you need change tracking.