void_signals_hooks 1.0.0
void_signals_hooks: ^1.0.0 copied to clipboard
Flutter hooks integration for void_signals - reactive state management
void_signals_hooks
Flutter hooks integration for void_signals - use reactive signals with flutter_hooks.
English | 简体中文
Features #
- 🪝 Hook-Based: Seamlessly integrate with flutter_hooks
- 📦 Memoized Signals: Signals persist across rebuilds
- 🔄 Auto-Cleanup: Effects automatically disposed
- 🎯 Fine-Grained: Rebuild only what changed
Installation #
dependencies:
void_signals_hooks: ^1.0.0
Quick Start #
import 'package:flutter/material.dart';
import 'package:void_signals_hooks/void_signals_hooks.dart';
class Counter extends HookWidget {
const Counter({super.key});
@override
Widget build(BuildContext context) {
// Create a signal (memoized across rebuilds)
final count = useSignal(0);
// Watch the signal (rebuilds when value changes)
final value = useWatch(count);
return Column(
children: [
Text('Count: $value'),
ElevatedButton(
onPressed: () => count.value++,
child: const Text('Increment'),
),
],
);
}
}
Core Hooks #
useSignal #
Creates and memoizes a signal.
final count = useSignal(0);
final user = useSignal<User?>(null);
final items = useSignal<List<String>>([]);
useComputed #
Creates and memoizes a computed value.
final firstName = useSignal('John');
final lastName = useSignal('Doe');
// With previous value
final fullName = useComputed((prev) => '${firstName.value} ${lastName.value}');
// Simple form (no previous value needed)
final doubled = useComputedSimple(() => count.value * 2);
useWatch #
Watches a signal and triggers rebuild on change.
final count = useSignal(0);
final value = useWatch(count); // Rebuilds when count changes
// For computed values
final computedValue = useWatchComputed(someComputed);
useReactive #
Creates a signal and watches it in one call. Returns a tuple of (value, setValue).
final (count, setCount) = useReactive(0);
// Use like useState
Text('Count: $count'),
ElevatedButton(
onPressed: () => setCount(count + 1),
child: const Text('Increment'),
),
useSignalEffect #
Creates an effect that re-runs when dependencies change.
final count = useSignal(0);
useSignalEffect(() {
print('Count changed to: ${count.value}');
});
// With keys (re-creates effect when keys change)
useSignalEffect(() {
fetchData(userId);
}, [userId]);
useEffectScope #
Creates an effect scope for grouping effects.
final scope = useEffectScope(() {
// Setup effects here
});
// Effects are automatically disposed when widget unmounts
Selection Hooks #
useSelect #
Selects part of a signal's value. Only rebuilds when selected value changes.
final user = useSignal(User(name: 'John', age: 30));
// Only rebuilds when name changes, not age
final name = useSelect(user, (u) => u.name);
useSelectComputed #
Same as useSelect, but for computed values.
final users = useComputed((_) => fetchUsers());
final count = useSelectComputed(users, (list) => list.length);
Utility Hooks #
useBatch #
Batch multiple signal updates.
final a = useSignal(0);
final b = useSignal(0);
// Updates both signals, effect runs once
useBatch(() {
a.value = 10;
b.value = 20;
});
useUntrack #
Read signals without creating dependencies.
final other = useUntrack(() => someSignal.value);
useSignalFromStream #
Creates a signal from a stream.
final messages = useSignalFromStream(
messageStream,
initialValue: [],
);
useSignalFromFuture #
Creates a signal from a future.
final user = useSignalFromFuture(
fetchUser(),
initialValue: null,
);
Time-Based Hooks #
useDebounced #
Creates a debounced signal that updates after a delay.
final searchQuery = useSignal('');
final debouncedQuery = useDebounced(searchQuery, Duration(milliseconds: 300));
// Use debouncedQuery for API calls
useSignalEffect(() {
fetchSearchResults(debouncedQuery.value);
});
useThrottled #
Creates a throttled signal that updates at most once per duration.
final scrollPosition = useSignal(0.0);
final throttled = useThrottled(scrollPosition, Duration(milliseconds: 100));
Combinator Hooks #
useCombine2 / useCombine3 #
Combines multiple signals into a computed value.
final firstName = useSignal('John');
final lastName = useSignal('Doe');
final fullName = useCombine2(
firstName,
lastName,
(first, last) => '$first $last',
);
usePrevious #
Tracks current and previous values of a signal.
final count = useSignal(0);
final (current, previous) = usePrevious(count);
// current.value: 5
// previous.value: 4 (or null if first value)
Async Hooks #
useAsync #
Hook for handling async operations with manual execution control.
final (state, execute, reset) = useAsync<User>();
// Execute async operation
void loadUser() async {
await execute(() async {
await Future.delayed(Duration(seconds: 1));
return User(name: 'John', age: 30);
});
}
// Use state with pattern matching
state.when(
idle: () => const Text('Press button to load'),
loading: () => const CircularProgressIndicator(),
success: (user) => Text('Hello, ${user.name}'),
error: (error) => Text('Error: $error'),
);
// Reset to idle state
reset();
useAsyncData #
Hook for auto-executing async operations with dependency keys.
// Auto-executes when userId changes
final state = useAsyncData(
() async {
final response = await api.fetchUser(userId);
return response;
},
keys: [userId],
);
// Use maybeWhen for partial handling
state.maybeWhen(
success: (user) => UserCard(user: user),
orElse: () => const LoadingPlaceholder(),
);
useLatest #
Get a reference to the latest value without subscribing to changes.
final count = useSignal(0);
final latestRef = useLatest(count);
// Access latest value in callbacks without causing rebuilds
void handleClick() {
print('Current count: ${latestRef.value}');
}
useListener #
Listen to signal changes for side effects.
final count = useSignal(0);
useListener(
count,
(value) {
print('Count changed to: $value');
analytics.log('count_changed', value);
},
fireImmediately: true, // Fire with current value immediately
);
State Hooks #
useToggle #
Simple boolean toggle hook.
final (isOn, toggle, setOn, setOff) = useToggle(false);
// Toggle the value
toggle();
// Set specific values
setOn(); // Set to true
setOff(); // Set to false
useCounter #
Counter hook with increment, decrement, reset, and set.
final (count, increment, decrement, reset, setValue) = useCounter(
initialValue: 0,
step: 1,
min: 0,
max: 100,
);
increment(); // count + step
decrement(); // count - step
reset(); // back to initial value
setValue(50); // set specific value
Timer Hooks #
useInterval #
Runs a callback periodically.
// Run every second
useInterval(
() {
fetchNewMessages();
},
Duration(seconds: 1),
);
// Pass null callback to pause
useInterval(
isPaused ? null : () => tick(),
Duration(seconds: 1),
);
useTimeout #
Runs a callback after a delay.
final (isActive, cancel, restart) = useTimeout(
() {
showNotification('Time\'s up!');
},
Duration(seconds: 5),
);
// Cancel the timeout
cancel();
// Restart the timeout
restart();
Collection Hooks #
useSignalList #
Creates a reactive list.
final items = useSignalList<String>(['a', 'b', 'c']);
items.add('d');
items.remove('a');
items.clear();
useSignalMap #
Creates a reactive map.
final settings = useSignalMap<String, dynamic>({
'theme': 'dark',
'fontSize': 14,
});
settings['language'] = 'en';
settings.remove('theme');
useSignalSet #
Creates a reactive set.
final selected = useSignalSet<int>({1, 2, 3});
selected.add(4);
selected.toggle(1); // Adds if absent, removes if present
Example: Todo App #
class TodoApp extends HookWidget {
const TodoApp({super.key});
@override
Widget build(BuildContext context) {
final todos = useSignalList<Todo>([]);
final filter = useSignal<Filter>(Filter.all);
final filteredTodos = useComputed((prev) {
return switch (filter.value) {
Filter.all => todos.value,
Filter.active => todos.where((t) => !t.done).toList(),
Filter.completed => todos.where((t) => t.done).toList(),
};
});
final activeCount = useSelectComputed(
filteredTodos,
(list) => list.where((t) => !t.done).length,
);
final watchedActiveCount = useWatchComputed(activeCount);
final watchedFilter = useWatch(filter);
return Column(
children: [
Text('$watchedActiveCount items left'),
SegmentedButton(
selected: {watchedFilter},
onSelectionChanged: (s) => filter.value = s.first,
segments: Filter.values.map((f) =>
ButtonSegment(value: f, label: Text(f.name))).toList(),
),
Expanded(
child: ListView.builder(
itemCount: filteredTodos.value.length,
itemBuilder: (context, index) {
final todo = filteredTodos.value[index];
return TodoTile(
todo: todo,
onToggle: () => todos[index] = todo.copyWith(done: !todo.done),
onDelete: () => todos.remove(todo),
);
},
),
),
],
);
}
}
Best Practices #
- Use useSignal for local state that needs to persist across rebuilds
- Use useWatch to trigger rebuilds when you need the widget to update
- Use useSelect for partial updates to minimize rebuilds
- Use useDebounced for user input to avoid excessive updates
- Prefer useComputed over useSignalEffect for derived values
- Use useBatch for related updates to run effects only once
🔍 Lint Support #
Install void_signals_lint for comprehensive static analysis:
dev_dependencies:
void_signals_lint: ^1.0.0
custom_lint: ^0.8.0
Available hooks-specific rules:
| Rule | Severity | Description |
|---|---|---|
hooks_outside_hook_widget |
🔴 Error | Ensures hooks are in HookWidget.build() |
conditional_hook_call |
🔴 Error | Prevents hooks in conditionals/loops |
hook_in_callback |
🔴 Error | Prevents hooks inside callbacks |
use_signal_without_watch |
⚠️ Warning | Warns when useSignal is not watched |
use_select_pure_selector |
⚠️ Warning | Ensures useSelect selector is pure |
prefer_use_computed_over_effect |
ℹ️ Info | Suggests useComputed for derived values |
Related Packages #
- void_signals - Core library
- void_signals_flutter - Flutter widgets
License #
MIT License - see LICENSE for details.