Common Errors

A guide to common errors and how to fix them when using Tilia.

Orphan Computation Error

If you’re seeing this error, it means you tried to use a computation (computed, source, or store) that was created outside of a reactive object.

The Error Messages

You may encounter one of these errors:

What Are Orphan Computations?

An “orphan computation” is a computation definition that exists in the “void”—it was created outside of any reactive object (tilia, carve, or derived). It is a computation definition that looks like the returned type but is actually an object containing that definition (shadow type pattern).

Think of it this way:

Without a reactive object, the computation is just a description floating in memory—it can’t actually compute anything.

The Problem Pattern

const [count, setCount] = signal(0)

// ❌ WRONG: Creating an orphan computation
// This creates a computation definition in the void
const trouble = computed(() => count.value * 2)

// ❌ ERROR: Trying to use the orphan computation
// This will throw: "Cannot access value of an orphan computation"
const result = trouble * 2
let (count, setCount) = signal(0)

// ❌ WRONG: Creating an orphan computation
// This creates a computation definition in the void
let trouble = computed(() => count.value * 2)

// ❌ ERROR: Trying to use the orphan computation
// This will throw: "Cannot access value of an orphan computation"
let result = trouble * 2

In this example:

  1. trouble is assigned a computation definition (not a value)
  2. When you try to use trouble in trouble * 2, JavaScript tries to access its value
  3. The proxy intercepts this and throws an error because the computation was never attached to a reactive graph

The Correct Pattern

Define computations directly inside reactive objects:

const [count, setCount] = signal(0)

// ✅ CORRECT: Define computed directly in a reactive object
const p = tilia({
  double: computed(() => count.value * 2)
})

// ✅ WORKS: Access the computed value through the reactive object
console.log(p.double)  // Returns the computed value
const result = p.double * 2  // Works perfectly
let (count, setCount) = signal(0)

// ✅ CORRECT: Define computed directly in a reactive object
let p = tilia({
  double: computed(() => count.value * 2)
})

// ✅ WORKS: Access the computed value through the reactive object
Js.log(p.double)  // Returns the computed value
let result = p.double * 2  // Works perfectly

Why Does This Restriction Exist?

This safety feature was introduced in Tilia v4.0 to prevent a common class of runtime errors called “zombie computations” or “orphan computations.”

Without This Protection (Pre-v4.0)

In earlier versions, orphan computations would:

  1. Silently fail or return undefined
  2. Cause obscure JavaScript errors deep in the stack
  3. Make it very difficult to debug what went wrong

With This Protection (v4.0+)

Now you get:

  1. Immediate, clear error at the point of misuse
  2. Descriptive message explaining the problem
  3. Link to this documentation with examples

The error message guides you to write correct code from the start, eliminating a whole class of hard-to-debug issues.

The Three Reactive Contexts

Computations must be created inside one of these three contexts:

1. tilia({ ... })

The primary way to create reactive objects:

const app = tilia({
  count: signal(0),
  double: computed(() => app.count * 2),
  triple: computed(() => app.count * 3)
})
let app = tilia({
  count: signal(0),
  double: computed(() => app.count * 2),
  triple: computed(() => app.count * 3)
})

2. carve({ derived } => { ... })

For creating objects that reference themselves during construction:

const app = carve(({ derived }) => ({
  count: signal(0),
  double: derived(self => self.count * 2),
  quadruple: derived(self => self.double * 2)  // References self.double
}))
let app = carve(({ derived }) => {
  count: signal(0),
  double: derived(self => self.count * 2),
  quadruple: derived(self => self.double * 2)  // References self.double
})

3. derived(() => value)

For creating standalone reactive values:

const double = derived(() => count.value * 2)
console.log(double.value)  // Access via .value
let double = derived(() => count.value * 2)
Js.log(double.value)  // Access via .value

Common Scenarios

Scenario 1: Helper Functions

Wrong:

function makeFullName(firstName, lastName) {
  // Orphan created in the void!
  // Also: business logic polluted with reactive framework concepts (signals, computed)
  return computed(() => `${firstName.value} ${lastName.value}`)
}

const name = makeFullName(first, last)
console.log(name)  // ERROR: Cannot access value of an orphan computation
let makeFullName = (firstName, lastName) => {
  // Orphan created in the void!
  // Also: business logic polluted with reactive framework concepts (signals, computed)
  computed(() => `${firstName.value} ${lastName.value}`)
}

let name = makeFullName(first, last)
Js.log(name)  // ERROR: Cannot access value of an orphan computation

Still Wrong (even if used in tilia):

function makeFullName(firstName, lastName) {
  // Creates orphan! Also mixes business logic with framework concerns
  return computed(() => `${firstName.value} ${lastName.value}`)
}

const user = tilia({
  fullName: makeFullName(first, last)  // Works, but wrong architecture
})
let makeFullName = (firstName, lastName) => {
  // Creates orphan! Also mixes business logic with framework concerns
  computed(() => `${firstName.value} ${lastName.value}`)
}

let user = tilia({
  fullName: makeFullName(first, last)  // Works, but wrong architecture
})

Correct - Separate business logic from reactive wiring:

// Pure function: business logic (testable, reusable)
function formatFullName(firstName, lastName) {
  return `${firstName} ${lastName}`
}

// Reactive wiring: done inside tilia
const user = tilia({
  firstName: signal("Alice"),
  lastName: signal("Smith"),
  fullName: computed(() => formatFullName(user.firstName, user.lastName))
})

console.log(user.fullName)  // Works! "Alice Smith"
// Pure function: business logic (testable, reusable)
let formatFullName = (firstName, lastName) => {
  `${firstName} ${lastName}`
}

// Reactive wiring: done inside tilia
let user = tilia({
  firstName: signal("Alice"),
  lastName: signal("Smith"),
  fullName: computed(() => formatFullName(user.firstName, user.lastName))
})

Js.log(user.fullName)  // Works! "Alice Smith"

Why? This pattern:

Scenario 2: Reusable Computations

Wrong:

const sharedComputed = computed(() => expensiveCalculation())  // Orphan!

const obj1 = tilia({ value: sharedComputed })
const obj2 = tilia({ value: sharedComputed })  // Won't share!
let sharedComputed = computed(() => expensiveCalculation())  // Orphan!

let obj1 = tilia({ value: sharedComputed })
let obj2 = tilia({ value: sharedComputed })  // Won't share!

Correct:

// Create a standalone reactive value with derived()
const shared = derived(() => expensiveCalculation())

// Reference it from other objects
const obj1 = tilia({ 
  result: computed(() => shared.value * 2) 
})

const obj2 = tilia({
  result: computed(() => shared.value + 10)
})
// Create a standalone reactive value with derived()
let shared = derived(() => expensiveCalculation())

// Reference it from other objects
let obj1 = tilia({ 
  result: computed(() => shared.value * 2) 
})

let obj2 = tilia({
  result: computed(() => shared.value + 10)
})

Scenario 3: Conditional Computations

Wrong:

const comp = condition 
  ? computed(() => a.value)
  : computed(() => b.value)  // Creates orphans!

const obj = tilia({ value: comp })  // Might work, might not
let comp = if condition {
  computed(() => a.value)
} else {
  computed(() => b.value)  // Creates orphans!
}

let obj = tilia({ value: comp })  // Might work, might not

Correct (inside tilia):

const obj = tilia({
  value: computed(() => condition ? a.value : b.value)
})
let obj = tilia({
  value: computed(() => if condition { a.value } else { b.value })
})

Correct (standalone with derived):

const value = derived(() => condition ? a.value : b.value)

// Use it anywhere
console.log(value.value)
let value = derived(() => if condition { a.value } else { b.value })

// Use it anywhere
Js.log(value.value)

The “Glue Zone” Should Not Exist

The “Glue Zone” is the space between creating a computation definition and inserting it into a reactive object. This zone should not exist at all.

❌ Glue Zone Exists (Dangerous)

// Computation definition created
const trouble = computed(() => ...)  // ← GLUE ZONE: computation floats in the void
// ... potentially many lines of code ...
// ... risk of using it as a value or passing it to wrong place ...

const app = tilia({ 
  value: trouble  // ← Finally inserted
})
// Computation definition created
let trouble = computed(() => ...)  // ← GLUE ZONE: computation floats in the void
// ... potentially many lines of code ...
// ... risk of using it as a value or passing it to wrong place ...

let app = tilia({ 
  value: trouble  // ← Finally inserted
})

Problem: The computation exists as an orphan between creation and insertion. It can be accidentally used as a value or passed to the wrong place.

✅ No Glue Zone (Correct)

const app = tilia({ 
  value: computed(() => ...)  // ← Created inline, no glue zone
})
let app = tilia({ 
  value: computed(() => ...)  // ← Created inline, no glue zone
})

Solution: The computation is created directly inside the reactive object. No opportunity for misuse.

Preferred Pattern: Keep related computations together in a single reactive object using tilia() or carve(). This makes your business logic easier to reason about compared to fragmenting it into many separate signal() or derived() values.

The safety proxy catches cases where a glue zone exists, ensuring computation definitions don’t escape into places where they’d be treated as values.

Technical Details

Under the hood, when you create a computed(), source(), or store() outside a reactive object:

  1. It returns a SafeProxy wrapping the computation definition.
  2. The proxy allows Tilia’s internal properties to be accessed.
  3. Any other property access throws the error.

This proxy is transparent when used correctly (inside tilia/carve/derived), but protects you from misuse.

Still Having Issues?

If you’re still encountering this error and believe it’s a false positive, please:

  1. Check that you’re using the latest version of Tilia
  2. Review the patterns above to ensure your usage matches
  3. Open an issue on GitHub with a minimal reproduction

The Tilia community is here to help!