Examples#
Real-world patterns for using duxt_signals effectively.
Counter#
The classic example:
final count = signal(0);
void increment() => count.update((v) => v + 1);
void decrement() => count.update((v) => v - 1);
void reset() => count.set(0);
// In UI
div([
button(onClick: decrement, [text('-')]),
span([text('${count()}')]),
button(onClick: increment, [text('+')]),
]);
Todo List#
// State
final todos = signal<List<Todo>>([]);
final filter = signal<TodoFilter>(TodoFilter.all);
// Computed
final filteredTodos = computed(() {
final list = todos();
switch (filter()) {
case TodoFilter.active:
return list.where((t) => !t.completed).toList();
case TodoFilter.completed:
return list.where((t) => t.completed).toList();
default:
return list;
}
});
final activeCount = computed(() =>
todos().where((t) => !t.completed).length
);
// Actions
void addTodo(String text) {
todos.update((list) => [...list, Todo(text: text)]);
}
void toggleTodo(int id) {
todos.update((list) => list.map((t) =>
t.id == id ? t.copyWith(completed: !t.completed) : t
).toList());
}
void removeTodo(int id) {
todos.update((list) => list.where((t) => t.id != id).toList());
}
Shopping Cart#
// State
final cartItems = signal<List<CartItem>>([]);
// Computed
final itemCount = computed(() =>
cartItems().fold(0, (sum, item) => sum + item.quantity)
);
final subtotal = computed(() =>
cartItems().fold(0.0, (sum, item) => sum + item.price * item.quantity)
);
final tax = computed(() => subtotal() * 0.1);
final total = computed(() => subtotal() + tax());
// Actions
void addItem(Product product) {
cartItems.update((items) {
final existing = items.indexWhere((i) => i.productId == product.id);
if (existing >= 0) {
return items.map((i) => i.productId == product.id
? i.copyWith(quantity: i.quantity + 1)
: i
).toList();
}
return [...items, CartItem(product: product, quantity: 1)];
});
}
void removeItem(int productId) {
cartItems.update((items) =>
items.where((i) => i.productId != productId).toList()
);
}
void updateQuantity(int productId, int quantity) {
if (quantity <= 0) {
removeItem(productId);
return;
}
cartItems.update((items) => items.map((i) =>
i.productId == productId ? i.copyWith(quantity: quantity) : i
).toList());
}
User Authentication#
// State
final currentUser = signal<User?>(null);
final authToken = signal<String?>(null);
final isLoading = signal(false);
// Computed
final isAuthenticated = computed(() => currentUser() != null);
final userDisplayName = computed(() => currentUser()?.name ?? 'Guest');
// Actions
Future<void> login(String email, String password) async {
isLoading.set(true);
try {
final response = await Api.post('/auth/login', body: {
'email': email,
'password': password,
});
authToken.set(response['token']);
currentUser.set(User.fromJson(response['user']));
} finally {
isLoading.set(false);
}
}
void logout() {
batch(() {
currentUser.set(null);
authToken.set(null);
});
}
// Persist auth token
effect(() {
final token = authToken();
if (token != null) {
localStorage.setItem('auth_token', token);
} else {
localStorage.removeItem('auth_token');
}
});
Search with Debounce#
final searchQuery = signal('');
final searchResults = signal<List<Result>>([]);
final isSearching = signal(false);
// Debounced search effect
Timer? _debounceTimer;
effect(() {
final query = searchQuery();
_debounceTimer?.cancel();
if (query.isEmpty) {
searchResults.set([]);
return;
}
_debounceTimer = Timer(Duration(milliseconds: 300), () async {
isSearching.set(true);
try {
final results = await Api.get('/search', query: {'q': query});
searchResults.set(results);
} finally {
isSearching.set(false);
}
});
});
Theme Switcher#
final isDarkMode = signal(false);
// Computed
final theme = computed(() => isDarkMode() ? darkTheme : lightTheme);
final backgroundColor = computed(() => theme().backgroundColor);
final textColor = computed(() => theme().textColor);
// Toggle
void toggleTheme() => isDarkMode.update((v) => !v);
// Persist preference
effect(() {
localStorage.setItem('dark_mode', isDarkMode().toString());
});
// Apply to DOM
effect(() {
document.body?.setAttribute(
'data-theme',
isDarkMode() ? 'dark' : 'light',
);
});
Form with Server Validation#
final usernameField = formField('', validators: [
required('Username is required'),
minLength(3, 'At least 3 characters'),
]);
final serverError = signal<String?>(null);
final isChecking = signal(false);
// Check username availability
effect(() {
final username = usernameField();
if (username.length < 3) {
serverError.set(null);
return;
}
isChecking.set(true);
Api.get('/users/check', query: {'username': username})
.then((response) {
if (!response['available']) {
serverError.set('Username is taken');
} else {
serverError.set(null);
}
})
.whenComplete(() => isChecking.set(false));
});
// Combined error
final usernameError = computed(() =>
usernameField.error ?? serverError()
);
Pagination#
final currentPage = signal(1);
final pageSize = signal(10);
final totalItems = signal(0);
// Computed
final totalPages = computed(() =>
(totalItems() / pageSize()).ceil()
);
final hasNextPage = computed(() => currentPage() < totalPages());
final hasPrevPage = computed(() => currentPage() > 1);
// Actions
void nextPage() {
if (hasNextPage()) {
currentPage.update((p) => p + 1);
}
}
void prevPage() {
if (hasPrevPage()) {
currentPage.update((p) => p - 1);
}
}
void goToPage(int page) {
if (page >= 1 && page <= totalPages()) {
currentPage.set(page);
}
}
// Load data on page change
effect(() {
final page = currentPage();
final size = pageSize();
loadItems(page: page, size: size);
});
Undo/Redo#
final history = signal<List<State>>([initialState]);
final historyIndex = signal(0);
// Current state
final currentState = computed(() => history()[historyIndex()]);
// Can undo/redo
final canUndo = computed(() => historyIndex() > 0);
final canRedo = computed(() => historyIndex() < history().length - 1);
// Actions
void pushState(State newState) {
final index = historyIndex();
final current = history();
// Remove any future states
final newHistory = current.sublist(0, index + 1);
newHistory.add(newState);
batch(() {
history.set(newHistory);
historyIndex.set(newHistory.length - 1);
});
}
void undo() {
if (canUndo()) {
historyIndex.update((i) => i - 1);
}
}
void redo() {
if (canRedo()) {
historyIndex.update((i) => i + 1);
}
}