Signals

Signals are the fundamental building blocks for rEFui. It is a lightweight, reactive signal system for building reactive applications. Signals provide a way to create reactive data that automatically updates dependent computations when the underlying data changes.

Core Concepts

Signals

Signals are reactive containers for values that can notify observers when they change. They form the foundation of the reactive system.

Effects

Effects are functions that automatically re-run when their dependencies (signals) change.

Computations

Computed signals derive their value from other signals and automatically update when dependencies change.

Important notice

Signal effects are semi-lazily computed: no matter how many times you change a signal during one tick, its effects execute once at the end of that tick. If you modify a signal and need its updated derived value, use nextTick(cb) or await nextTick(). The lower-level tick() API manually triggers a flush; prefer nextTick when you need to await the scheduler.

Derived signal propagation still flushes before user effects, but effects within the same phase should not communicate through assumed execution order. Coordinate related work through signals or nextTick() instead.

Avoiding Stale Values

Effects and computed signals flush at the end of the tick. If you need to read a fresh derived value immediately after a write, always use await nextTick().

Dependency Tracking

Signals track dependencies when they are read during the synchronous execution of a computation. Branches can discover dependencies incrementally: when a previously tracked condition changes and opens a branch, signals read in that branch become dependencies on that run.

const summary = computed(() => {
	const t = track.value
	if (!t) return 'Ready'
	return `${t.name}${metadata.value}`
})

While track.value is falsy, metadata is not observed. Once track becomes truthy, the computation reruns, reaches metadata.value, and starts observing it. A dependency that is no longer read is retired lazily after its existing subscription next fires.

Basic Usage

Creating Signals

import { signal } from 'refui/signal'

// Create a signal with an initial value
const count = signal(0)

// Get the current value
console.log(count.value) // 0

// Update the value
count.value = 5
console.log(count.value) // 5

Creating Computed Signals

import { signal, computed, nextTick } from 'refui/signal'

const count = signal(0)
const doubled = computed(() => count.value * 2)

console.log(doubled.value) // 0
count.value = 5

nextTick(() => {
		console.log(doubled.value) // 10
})

Effects

import { signal, watch } from 'refui/signal'

const count = signal(0)

// Watch for changes
const dispose = watch(() => {
	console.log('Count changed:', count.value)
})

count.value = 1 // Logs: "Count changed: 1"

nextTick(() => {
	count.value = 2 // Logs: "Count changed: 2"
})

// Clean up the effect
dispose()

Advanced Features

Custom Effects

const myEffect = () => {
	const value = mySignal.value
	console.log('Signal value:', value)
}

watch(myEffect)

Batched Updates

Updates are automatically batched and applied asynchronously:

count.value = 1
count.value = 2
count.value = 3
// Only triggers effects once with final value

Best Practices

  1. Use computed signals for derived data:

    const fullName = computed(() => `${first.value} ${last.value}`)
    
  2. Dispose of effects when no longer needed:

    const dispose = watch(() => {
        // effect logic
    })
    
    // Later...
    dispose()
    
  3. Use peek() to avoid creating dependencies:

    const currentValue = mySignal.peek() // Doesn't create dependency
    
  4. Batch related updates:

    // Updates are automatically batched
    firstName.value = 'John'
    lastName.value = 'Doe'
    // fullName updates only once
    
  5. Use untrack() for non-reactive operations:

    const result = untrack(() => {
        // This won't create dependencies or inherit lifecycle ownership
        return someSignal.value + otherSignal.value
    })
    

Examples

Counter Example

import { signal, computed, watch } from 'refui/signal'

const count = signal(0)
const doubled = computed(() => count.value * 2)

watch(() => {
	console.log(`Count: ${count.value}, Doubled: ${doubled.value}`)
})

count.value = 5 // Logs: "Count: 5, Doubled: 10"

Todo List Example

const todos = signal([])
const filter = signal('all')

const filteredTodos = computed(() => {
	const todoList = todos.value
	const currentFilter = filter.value

	switch (currentFilter) {
		case 'active':
			return todoList.filter(todo => !todo.completed)
		case 'completed':
			return todoList.filter(todo => todo.completed)
		default:
			return todoList
	}
})

// Add todo
function addTodo(text) {
	todos.value = [...todos.value, { id: Date.now(), text, completed: false }]
}

// Toggle todo
function toggleTodo(id) {
	todos.value = todos.value.map(todo =>
		todo.id === id ? { ...todo, completed: !todo.completed } : todo
	)
}

For the full API documentation, see Signal API.