Signal
Signal is the foundation of the reactive system. It can be modified at any time, and reactive nodes in the system can subscribe to its updates. When a Signal's value changes, all reactive nodes that subscribe to it (such as Computed and Effect) automatically update.
import 'package:jolt/jolt.dart';
void main() {
// Create a signal
final count = Signal(0);
// Subscribe to signal changes
Effect(() {
print('Count: ${count.value}');
});
// Modify the signal's value
count.value = 5; // Output: "Count: 5"
}Creating Signals
Standard Creation
Create a signal using the Signal constructor, providing an initial value:
final count = Signal(0);
final name = Signal('Alice');
final items = Signal<List<int>>([]);Lazy Initialization
Use Signal.lazy() to create a lazily initialized signal with an initial value of null:
final data = Signal.lazy<String>();
// Set value later
data.value = 'loaded data';Lazy initialization is suitable for scenarios where the initial value cannot be determined at creation time, such as asynchronous data loading.
final userData = Signal.lazy<Map<String, dynamic>>();
// Load data asynchronously
loadUserData().then((data) {
userData.value = data;
});Read-Only View
Get a read-only view of a writable signal through the .readonly() extension method. This is only a compile-time restriction—the underlying signal remains the same:
final counter = Signal(0);
final readonlyCounter = counter.readonly();
print(readonlyCounter.value); // OK
// readonlyCounter.value = 1; // Compile error
// But can still modify through the original signal
counter.value = 1; // OK, readonlyCounter.value also becomes 1This is useful when you need to expose a signal but restrict write access:
class Counter {
final _count = Signal(0);
ReadonlySignal<int> get count => _count.readonly();
void increment() => _count.value++;
}Note: The read-only view returned by .readonly() is essentially the same signal, just with compile-time write restrictions. If you modify the value through the original signal, the read-only view will also see the change.
Constant Signals
Use the ReadonlySignal constructor to create constant read-only signals. Constant signals are simple instances that implement the ReadonlyNode interface—they have no reactive capability, cannot be written to, and do not need disposal:
final constant = ReadonlySignal(42);
print(constant.value); // Always 42
// constant.value = 100; // Compile error, constant signals cannot be modifiedCharacteristics of constant signals:
- No reactivity: Constant signals do not trigger any reactive updates because they are just simple value wrappers
- Cannot be written: The value is fixed at creation and cannot be modified
- No disposal needed: Constant signals have no resources to clean up—
dispose()is a no-op
final constant = ReadonlySignal(42);
// Does not establish reactive dependencies
Effect(() {
print(constant.value); // Executes only once, does not react to changes
});
// Constant signal values never change
// constant.value = 100; // Compile error
// No need to call dispose()
// constant.dispose(); // Can be called, but it's a no-opConstant signals are suitable for scenarios where you need to wrap regular values into ReadonlySignal type to maintain API consistency:
class Config {
// Use constant signals to provide fixed configuration
static final apiVersion = ReadonlySignal('v1.0');
static final maxRetries = ReadonlySignal(3);
}
// Use in places that require ReadonlySignal type
void processConfig(ReadonlySignal<String> version) {
print('Version: ${version.value}');
}
processConfig(Config.apiVersion); // OKDifference from .readonly():
.readonly(): Returns a read-only view of the original signal, still reactive, can be modified through the original signalReadonlySignal(): Creates a constant signal, no reactivity, value never changes, cannot be modified
Reading Values
.value
Use the .value property to read values—this creates reactive dependencies. When the signal's value changes, any reactive nodes that access it will automatically update.
final count = Signal(0);
Effect(() {
print(count.value); // Using .value
});
count.value = 10; // Effect will updateYou can also use the call() extension method for a function-like syntax:
final count = Signal(0);
Effect(() {
print(count()); // Using call() extension, equivalent to .value
});
count.value = 10; // Effect will update.peek
Use the .peek property to read values without creating reactive dependencies. This is useful when you only need to read the current value without subscribing to updates.
final signalA = Signal(0);
final signalB = Signal(0);
Effect(() {
final tracked = signalA.value; // Establishes dependency
final untracked = signalB.peek; // Does not establish dependency
print('Tracked: $tracked, Untracked: $untracked');
});
signalB.value = 10; // No output, because peek did not establish dependency
signalA.value = 10; // Output: "Tracked: 10, Untracked: 10"Common use cases:
// Read but don't subscribe in Effect
Effect(() {
if (someCondition.value) {
// Use peek to avoid creating unnecessary dependencies
print('Other value: ${otherSignal.peek}');
}
});
// Read current value in event handlers
button.onTap = () {
final current = count.peek;
print('Current count: $current');
};Writing Values
.value
Assign directly to the .value property to update the signal's value. This updates the value and notifies all subscribers.
final count = Signal(0);
count.value = 10; // Update value
count.value = 20; // Update value againUpdate Function
For scenarios that need to update based on the current value, you can use the .update() extension method:
final count = Signal(5);
count.update((value) => value + 1); // count.value is now 6
count.update((value) => value * 2); // count.value is now 12This is equivalent to:
count.value = count.peek + 1;
count.value = count.peek * 2;Manual Notification
If you need to manually tell subscribers that it has updated, you can use the notify() method. This notifies all subscribers even if the value hasn't changed.
final count = Signal(0);
Effect(() {
print('Count updated: ${count.value}');
});
count.value = 10; // First output: "Count updated: 10"
// Don't change value, but manually notify subscribers
count.notify(); // Output again: "Count updated: 10"This is useful in certain scenarios, such as when an object's internal properties change but the object reference itself hasn't changed:
final user = Signal(User(name: 'Alice', age: 30));
Effect(() {
print('User: ${user.value.name}, Age: ${user.value.age}');
});
user.value.age = 31; // Object reference didn't change, need manual notification
user.notify(); // Triggers Effect updateLifecycle Management
dispose
When a signal is no longer needed, you should call the dispose() method to release resources:
final count = Signal(0);
// Use signal...
// Release when no longer needed
count.dispose();Signal.dispose() is mainly an eager lifecycle boundary for the reactive graph. A Signal usually does not own external resources by itself, but it can still be linked to subscribers, computed values, and effects. Calling dispose() explicitly breaks those reactive links immediately, marks the signal as unusable, and avoids keeping the graph alive longer than intended.
Disposed signals can no longer be used:
count.dispose();
// count.value = 10; // Runtime error: Signal is disposedIf a signal simply becomes unreachable, Dart's garbage collector can eventually reclaim it. dispose() is still useful when you want deterministic teardown instead of waiting for GC. This is different from Effect and Watcher: disposing a signal does not run side-effect cleanup callbacks, because a signal is state, not an active side effect.
isDisposed
Check if a signal has been disposed:
final count = Signal(0);
print(count.isDisposed); // false
count.dispose();
print(count.isDisposed); // trueType System
Signal Interface
Signal<T> is a writable interface that implements:
Writable<T>- Writable interfaceWritableNode<T>- Writable node interfaceReadonlyNode<T>- Read-only node interfaceReadonlySignal<T>- Read-only signal interface
ReadonlySignal Interface
ReadonlySignal<T> is a read-only interface that implements:
Readonly<T>- Read-only interfaceReadonlyNode<T>- Read-only node interface
Use Cases
State Management
Signals are most commonly used for managing application state:
class TodoApp {
final todos = Signal<List<Todo>>([]);
final filter = Signal<TodoFilter>(TodoFilter.all);
void addTodo(String text) {
todos.value = [...todos.value, Todo(text: text)];
}
void toggleTodo(int id) {
todos.value = todos.value.map((todo) {
if (todo.id == id) {
return todo.copyWith(completed: !todo.completed);
}
return todo;
}).toList();
}
}Form State
Signals are perfect for managing form state:
class LoginForm {
final email = Signal('');
final password = Signal('');
final isLoading = Signal(false);
Future<void> submit() async {
isLoading.value = true;
try {
await login(email.value, password.value);
} finally {
isLoading.value = false;
}
}
}Configuration and Settings
Use Signals to manage configuration:
class AppConfig {
final theme = Signal('light');
final language = Signal('zh');
final notifications = Signal(true);
}Important Notes
Reactive Dependencies: Using
.valueorcall()in reactive contexts (such asComputed,Effect) establishes dependencies. Using.peekdoes not establish dependencies.Lifecycle: Signals that are no longer used should call
dispose()to release resources and avoid memory leaks.Read-Only Views: When you need to expose a signal but restrict writes, use
.readonly()to get a read-only view.Object Internal Changes: If you modify an object's internal properties, you need to manually call
notify()to notify subscribers.Lazy Initialization: When using
Signal.lazy()to create a lazily initialized signal, the initial value isnull. Ensure the type allowsnullor set the value before use.
Related APIs
- Computed - Computed properties based on Signals
- Effect - Reactive side effects
- Extensions - Signal extension methods
- ReadonlyNode - Learn about Signal's underlying interfaces