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

Libraries

duxt_signals