duxt_signals
Reactive signals for Dart & Jaspr - lightweight state management inspired by Solid.js, Angular, and Preact Signals.
Features
- Signals - Reactive state primitives with simple read/write API
- Computed - Derived state with automatic dependency tracking
- Effects - Side effects that run when signals change
- Batch - Batch multiple updates for optimal performance
- Form Signals - Form state management with validation
- SignalState - Auto-tracking state for Jaspr components
Installation
dependencies:
duxt_signals: ^0.1.0
Quick Start
import 'package:duxt_signals/duxt_signals.dart';
// Create a signal
final count = signal(0);
// Read value (call syntax)
print(count()); // 0
// Write value
count.set(5);
count.update((v) => v + 1);
// Computed signals
final doubled = computed(() => count() * 2);
print(doubled()); // 12
// Effects
final dispose = effect(() {
print('Count is: ${count()}');
});
count.set(10); // Prints: "Count is: 10"
dispose(); // Stop effect
Jaspr Integration
SignalState - Auto-Tracking Components
Extend SignalState instead of State to automatically rebuild when signals change:
import 'package:jaspr/jaspr.dart';
import 'package:duxt_signals/duxt_signals.dart';
// Define signals at top level
final count = signal(0);
final doubled = computed(() => count() * 2);
@client
class Counter extends StatefulComponent {
@override
State createState() => CounterState();
}
class CounterState extends SignalState<Counter> {
@override
Component buildComponent(BuildContext context) {
// Signals read here are auto-tracked!
return div([
text('Count: ${count()}'),
text('Double: ${doubled()}'),
button(
onClick: () => count.update((v) => v + 1),
[text('+')],
),
]);
}
}
Multi-Component Updates
One signal can update multiple components simultaneously:
// Global state
final user = signal<User?>(null);
final isLoggedIn = computed(() => user() != null);
final cartCount = signal(0);
// Header component reads user() - rebuilds on login/logout
// Sidebar component reads cartCount() - rebuilds on cart changes
// Both share the same reactive state!
Async Data Loading
final users = signal<List<User>>([]);
final isLoading = signal(false);
final error = signal<String?>(null);
Future<void> loadUsers() async {
isLoading.set(true);
error.set(null);
try {
final data = await api.fetchUsers();
users.set(data);
} catch (e) {
error.set(e.toString());
} finally {
isLoading.set(false);
}
}
Core API
signal()
Creates a reactive signal with an initial value.
final name = signal('John');
final age = signal(25);
final items = signal<List<String>>([]);
// Read
print(name()); // Call syntax (preferred)
print(name.value); // Property syntax
// Write
name.set('Jane');
name.value = 'Jane'; // Alternative
// Update based on previous
age.update((v) => v + 1);
items.update((list) => [...list, 'new']);
// Listen
final unsubscribe = name.listen((value) {
print('Name changed to: $value');
});
unsubscribe(); // Stop listening
computed()
Creates a derived signal that auto-updates when dependencies change.
final firstName = signal('John');
final lastName = signal('Doe');
final fullName = computed(() => '${firstName()} ${lastName()}');
print(fullName()); // "John Doe"
firstName.set('Jane');
print(fullName()); // "Jane Doe" - auto updated!
effect()
Runs a side effect when signals change.
final count = signal(0);
final dispose = effect(() {
print('Count: ${count()}');
// DOM updates, API calls, etc.
});
count.set(1); // Prints: "Count: 1"
dispose(); // Stop effect
batch()
Batch multiple signal updates to prevent redundant computations.
final a = signal(1);
final b = signal(2);
final sum = computed(() => a() + b());
// Without batch: sum recomputes twice
a.set(10);
b.set(20);
// With batch: sum recomputes once after both updates
batch(() {
a.set(10);
b.set(20);
});
Form Signals
Form state management with built-in validation.
final emailField = formField('', validators: [
required('Email is required'),
email('Invalid email'),
]);
final passwordField = formField('', validators: [
required('Password is required'),
minLength(8, 'At least 8 characters'),
]);
// State properties
emailField() // Current value
emailField.isValid // Validation passed
emailField.hasError // Has validation error
emailField.error // Error message string
emailField.touched // Has been blurred
emailField.dirty // Value changed from initial
// Actions
emailField.set('new@email.com') // Update value
emailField.touch() // Mark as touched
emailField.reset() // Reset to initial
Using with Jaspr
class LoginState extends SignalState<Login> {
@override
Component buildComponent(BuildContext context) {
return form([
input(
type: 'email',
value: emailField(),
onInput: (v) => emailField.set(v),
events: {'blur': (_) => emailField.touch()},
),
if (emailField.touched && emailField.hasError)
div([text(emailField.error ?? '')]),
button(
onClick: () {
emailField.touch();
passwordField.touch();
if (emailField.isValid && passwordField.isValid) {
// Submit
}
},
[text('Login')],
),
]);
}
}
FormState
Group multiple fields together.
final loginForm = FormState({
'email': formField('', validators: [required(), email()]),
'password': formField('', validators: [required()]),
});
// Access values
print(loginForm.values); // {'email': '...', 'password': '...'}
// Check validity
if (loginForm.isValid) {
loginForm.submit((data) => api.login(data));
}
// Reset all fields
loginForm.reset();
Built-in Validators
required('Field is required')
minLength(8, 'At least 8 characters')
maxLength(100, 'Maximum 100 characters')
email('Invalid email')
pattern(RegExp(r'...'), 'Invalid format')
min(0, 'Must be positive')
max(100, 'Maximum 100')
Example: Todo List
final todos = signal<List<Todo>>([]);
final filter = signal(TodoFilter.all);
final filteredTodos = computed(() {
return switch (filter()) {
TodoFilter.all => todos(),
TodoFilter.active => todos().where((t) => !t.done).toList(),
TodoFilter.completed => todos().where((t) => t.done).toList(),
};
});
final activeCount = computed(() =>
todos().where((t) => !t.done).length
);
// Add todo
todos.update((list) => [...list, Todo(title: 'New task')]);
// Toggle todo
todos.update((list) => list.map((t) =>
t.id == id ? t.copyWith(done: !t.done) : t
).toList());
// Remove completed
todos.update((list) => list.where((t) => !t.done).toList());
Documentation
Full documentation at duxt.dev/duxt-signals
License
MIT License - see LICENSE