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.