jolt_setup 3.2.3
jolt_setup: ^3.2.3 copied to clipboard
Setup Widget API and Flutter hooks for building reactive widgets with Jolt signals, featuring automatic resource cleanup and lifecycle management.
Jolt Setup #
Setup Widget API and Flutter hooks for Jolt. Provides a composition API similar to Vue's Composition API for building Flutter widgets, along with hooks for managing Flutter resources such as controllers, focus nodes, and lifecycle states with automatic cleanup.
Why Setup Widget? #
Setup Widget uses a composition-style API for Flutter widgets. It runs setup once at creation time and handles hook cleanup automatically.
Key Features #
- Composition-based logic
- Automatic resource cleanup
setupruns once instead of on every rebuild- Built on Jolt signals
- Hook APIs for controllers, focus nodes, animations, and lifecycle
- Works with
SetupWidget,SetupMixin, andSetupBuilder
Comparison #
The example below shows the same widget implemented with SetupWidget and with a StatefulWidget.
With Setup Widget:
class HookExample extends SetupWidget<HookExample> {
HookExample({super.key});
@override
setup(context, props) {
useAutomaticKeepAlive(true);
final scrollController = useScrollController();
useListenable(scrollController, () {
print('scrollController.offset: ${scrollController.offset}');
});
final loadingFuture =
useFuture(Future.delayed(Duration(seconds: 3), () => true));
useAppLifecycleState(
onChange: (state) {
if (state == AppLifecycleState.resumed) {
print('app resumed');
} else if (state == AppLifecycleState.paused) {
print('app paused');
}
},
);
return () => SingleChildScrollView(
controller: scrollController,
child: switch (loadingFuture.hasData) {
false => Center(child: CircularProgressIndicator()),
true => Column(
children: [
for (var i = 0; i < 100; i++) Text('Item $i'),
],
),
});
}
}
Traditional StatefulWidget:
class NormalExample extends StatefulWidget {
const NormalExample({super.key});
@override
State<NormalExample> createState() => _NormalExampleState();
}
class _NormalExampleState extends State<NormalExample>
with AutomaticKeepAliveClientMixin, WidgetsBindingObserver, RouteAware {
late final ScrollController scrollController;
late final Future<bool> loadingFuture;
@override
bool get wantKeepAlive => true;
@override
void initState() {
super.initState();
scrollController = ScrollController();
loadingFuture = Future.delayed(Duration(seconds: 3), () => true);
scrollController.addListener(_listener);
WidgetsBinding.instance.addObserver(this);
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
print('app resumed');
} else if (state == AppLifecycleState.paused) {
print('app paused');
}
}
@override
void dispose() {
scrollController.removeListener(_listener);
scrollController.dispose();
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
void _listener() {
print('scrollController.offset: ${scrollController.offset}');
}
@override
Widget build(BuildContext context) {
super.build(context);
return SingleChildScrollView(
controller: scrollController,
child: FutureBuilder(
future: loadingFuture,
builder: (context, snapshot) {
return switch (snapshot.hasData) {
false => Center(child: CircularProgressIndicator()),
true => Column(
children: [
for (var i = 0; i < 100; i++) Text('Item $i'),
],
),
};
}));
}
}
Differences in this example:
- Fewer lifecycle methods
- No manual listener disposal in the widget code
- Logic is grouped inside
setup
Quick Start #
import 'package:flutter/material.dart';
import 'package:jolt_setup/jolt_setup.dart';
class MyWidget extends SetupWidget {
@override
setup(context) {
final textController = useTextEditingController('Hello');
final focusNode = useFocusNode();
final count = useSignal(0);
return () => Scaffold(
body: Column(
children: [
TextField(
controller: textController,
focusNode: focusNode,
),
Text('Count: ${count.value}'),
ElevatedButton(
onPressed: () => count.value++,
child: Text('Increment'),
),
],
),
);
}
}
Use with jolt_lint #
jolt_lint adds static checks and assists for setup and hook usage:
# analysis_options.yaml
plugins:
jolt_lint: ^3.0.0
jolt_lint includes:
- Hook rule checks
- Compile-time diagnostics for invalid async/callback hook usage
- Code assists for common conversions
Without jolt_lint, some hook placement errors are only detected at runtime.
See jolt_lint documentation for setup and configuration.
Setup Widget #
⚠️ Important Note
Setup Widget and its hooks are not part of the
flutter_hooksecosystem. If you needflutter_hooks-compatible APIs, use thejolt_hookspackage instead.Key Execution Difference:
- Setup Widget: The
setupfunction runs once when the widget is created (like Vue / SolidJS), then rebuilds are driven by the reactive system- flutter_hooks: Hook functions run on every build (like React Hooks)
These are different execution models. Mixing them in the same component usually makes hook behavior harder to reason about.
Setup Widget provides a composition API similar to Vue's Composition API for building Flutter widgets. The key difference from React hooks is that setup executes only once when the widget is created, not on every rebuild.
SetupBuilder #
SetupBuilder is the smallest entry point for the API:
import 'package:jolt_setup/setup.dart';
SetupBuilder(
setup: (context) {
final count = useSignal(0);
return () => Column(
children: [
Text('Count: ${count.value}'),
ElevatedButton(
onPressed: () => count.value++,
child: Text('Click'),
),
],
);
},
)
SetupWidget vs SetupMixin #
Before diving into each API, understand their differences:
| Feature | SetupWidget | SetupMixin |
|---|---|---|
| Base class | Extends Widget |
Mixin for State<T> |
| Mutability | Like StatelessWidget, immutable |
Mutable State class |
this reference |
❌ Not available | ✅ Full access |
| Instance methods/fields | ❌ Should not use | ✅ Can define freely |
| Setup signature | setup(context, props) |
setup(context) |
| Reactive props access | props().property |
props.property |
| Non-reactive props access | props.peek.property |
widget.property |
| Lifecycle methods | Via hooks only | Both hooks + State methods |
| Use case | Simple immutable widgets | Need State capabilities |
SetupWidget #
Create custom widgets by extending SetupWidget:
class CounterWidget extends SetupWidget<CounterWidget> {
final int initialValue;
const CounterWidget({super.key, this.initialValue = 0});
@override
setup(context, props) {
// Use props.peek for one-time initialization (non-reactive)
final count = useSignal(props.peek.initialValue);
// Use props() for reactive access
final displayText = useComputed(() =>
'Count: ${count.value}, Initial: ${props().initialValue}'
);
return () => Column(
children: [
Text(displayText.value),
ElevatedButton(
onPressed: () => count.value++,
child: const Text('Increment'),
),
],
);
}
}
Important Notes:
-
setupreceives two parameters:context: Standard FlutterBuildContextprops:PropsReadonlyNode<YourWidgetType>, provides reactive access to widget instance
-
Props Access Methods:
props()/props.value/props.get()- Reactive access, establishes dependenciesprops.peek- Non-reactive access, for one-time initialization
-
Like
StatelessWidget: The widget class should be immutable and not hold mutable state or define instance methods
SetupMixin #
Add composition API support to existing StatefulWidgets:
class CounterWidget extends StatefulWidget {
final int initialValue;
const CounterWidget({super.key, this.initialValue = 0});
@override
State<CounterWidget> createState() => _CounterWidgetState();
}
class _CounterWidgetState extends State<CounterWidget>
with SetupMixin<CounterWidget> {
@override
setup(context) {
// Use widget.property for one-time initialization (non-reactive)
final count = useSignal(widget.initialValue);
// Use props.property for reactive access
final displayText = useComputed(() =>
'Count: ${count.value}, Initial: ${props.initialValue}'
);
return () => Column(
children: [
Text(displayText.value),
ElevatedButton(
onPressed: () => count.value++,
child: const Text('Increment'),
),
],
);
}
}
Key Differences:
setupreceives only one parameter:context(nopropsparameter)- Provides a
propsgetter for reactive widget property access - Compatible with traditional
Statelifecycle methods (initState,dispose, etc.)
Two Ways to Access Widget Properties:
setup(context) {
// 1. widget.property - Non-reactive (equivalent to props.peek in SetupWidget)
// For one-time initialization, won't trigger updates on changes
final initial = widget.initialValue;
// 2. props.property - Reactive (equivalent to props() in SetupWidget)
// Use inside computed/effects to react to property changes
final reactive = useComputed(() => props.initialValue * 2);
return () => Text('${reactive.value}');
}
State Context and this Reference:
Unlike SetupWidget (which is analogous to StatelessWidget), SetupMixin runs within a State class, giving you full access to this and mutable state:
class _CounterWidgetState extends State<CounterWidget>
with SetupMixin<CounterWidget> {
// ✅ Allowed: Define instance fields in State
final _controller = TextEditingController();
int _tapCount = 0;
// ✅ Allowed: Define instance methods
void _handleTap() {
setState(() => _tapCount++);
}
@override
void initState() {
super.initState();
// Traditional State initialization
}
@override
setup(context) {
final count = useSignal(0);
// ✅ Access 'this' and instance members
onMounted(() {
_controller.text = 'Initial: ${widget.initialValue}';
});
return () => Column(
children: [
TextField(controller: _controller),
Text('Taps: $_tapCount'),
ElevatedButton(
onPressed: _handleTap,
child: Text('Count: ${count.value}'),
),
],
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
}
Key Point: SetupWidget is like StatelessWidget - the widget class itself should be immutable. SetupMixin works within a State class where you can freely use this, define methods, maintain fields, and leverage the full capabilities of stateful widgets.
Choosing the Right Pattern #
SetupWidget, SetupMixin, and standard Flutter widget patterns solve different constraints. Use the one that matches the widget shape and lifecycle needs of the code you are writing.
When to Use SetupWidget:
- Creating simple, immutable widgets (like
StatelessWidget) - Want a pure composition-based API
- No need for instance methods, mutable fields, or
thisreference - All logic can be expressed through reactive hooks
When to Use SetupMixin:
- Need instance methods, fields, or access to
this - Need to use existing State mixins, special State base classes, or State extensions
- Want to combine composition API with imperative logic
- Need full control over
Statelifecycle methods (initState,dispose,didUpdateWidget, etc.) - Working with complex widget logic that benefits from both approaches
Available Hooks #
Reactive State Hooks #
Setup Widget provides hooks for all Jolt reactive primitives:
Signal(...) vs useSignal(...)
Both can be used inside setup(), but they solve different lifecycle problems:
| API | What it does | Lifecycle behavior |
|---|---|---|
Signal(...) / Computed(...) |
Directly creates a reactive node | You own the lifecycle. If the node becomes unreachable after unmount, GC can eventually reclaim it, but there is no explicit dispose() boundary and hot reload recreates a new instance. |
useSignal(...) / useComputed(...) |
Creates the node through a Setup hook | Bound to the SetupWidget / SetupMixin element lifecycle. The hook explicitly disposes the node on unmount or hook replacement, and preserves a stable reference across matching hot reloads. |
In practice, useSignal(value) is the hook-managed version of:
final count = useAutoDispose(() => Signal(value));
That explicit disposal still matters even if the widget-local object could eventually be garbage collected. It gives deterministic teardown when the widget leaves the tree, instead of waiting for GC, and it is what makes hook-owned signals hot-reload friendly.
Use useSignal by default for widget-owned state. Reach for Signal(...) directly only when you intentionally want manual lifecycle control.
Reactive State Hooks #
| Hook | Description |
|---|---|
useSignal(initial) |
Create a reactive Signal |
useSignal.lazy<T>() |
Create a lazy-loaded Signal |
useSignal.list(initial) |
Create a reactive list |
useSignal.map(initial) |
Create a reactive Map |
useSignal.set(initial) |
Create a reactive Set |
useSignal.iterable(getter) |
Create a reactive Iterable |
useSignal.async(source) |
Create an async Signal |
Computed Value Hooks #
| Hook | Description |
|---|---|
useComputed(fn) |
Create a computed value |
useComputed.withPrevious(getter) |
Create a computed value with access to previous value |
useComputed.writable(getter, setter) |
Create a writable computed value |
useComputed.writableWithPrevious(getter, setter) |
Create a writable computed value with access to previous value |
Effect Hooks #
| Hook | Description |
|---|---|
useEffect(fn) |
Create an effect |
useEffect.lazy(fn) |
Create a deferred effect (call run() to start) |
useWatcher(sourcesFn, fn) |
Create a watcher |
useWatcher.immediately(...) |
Create an immediately-executing watcher |
useWatcher.once(...) |
Create a one-time watcher |
Lifecycle Hooks #
| Hook | Description |
|---|---|
onMounted(fn) |
Callback when widget mounts |
onUnmounted(fn) |
Callback when widget unmounts |
onDidUpdateWidget(fn) |
Callback when widget updates |
onDidChangeDependencies(fn) |
Callback when dependencies change |
onActivated(fn) |
Callback when widget activates |
onDeactivated(fn) |
Callback when widget deactivates |
Utility Hooks #
| Hook | Description |
|---|---|
useContext() |
Get BuildContext |
useSetupContext() |
Get JoltSetupContext |
useEffectScope() |
Create an effect scope |
useJoltStream(value) |
Create a stream from reactive value |
useUntil(source, predicate) |
Wait for a reactive value to satisfy a condition |
useUntil.when(source, value) |
Wait for a reactive value to equal a specific value |
useUntil.changed(source) |
Wait for a reactive value to change from its current value |
useMemoized(creator, [disposer]) |
Memoize value with optional cleanup |
useAutoDispose(creator) |
Auto-dispose resource |
useHook(hook) |
Use a custom hook |
Creating Custom Hooks #
There are four common ways to create custom hooks:
class Counter {
Counter({required this.initialValue}) : raw = Signal(initialValue);
final int initialValue;
final Signal<int> raw;
void increment() => raw.value++;
void decrement() => raw.value--;
void reset() => raw.value = initialValue;
int get() => raw.value;
void set(int value) => raw.value = value;
void dispose() => raw.dispose();
}
typedef CounterCompositionHook = ({
Signal<int> counter,
void Function() increment,
void Function() decrement,
void Function() reset,
int Function() get,
void Function(int value) set,
});
1. Composition Hook
Build a reusable hook directly from other hooks:
import 'package:jolt_setup/jolt_setup.dart';
@defineHook
CounterCompositionHook useCounterHookWithoutClass([int initialValue = 0]) {
final counter = useSignal(0);
void increment() => counter.value++;
void decrement() => counter.value--;
void reset() => counter.value = initialValue;
int get() => counter.value;
void set(int value) => counter.value = value;
return (
counter: counter,
increment: increment,
decrement: decrement,
reset: reset,
get: get,
set: set,
);
}
// Usage in setup
class CounterExample extends SetupWidget<CounterExample> {
@override
setup(context, props) {
final counter = useCounterHookWithoutClass(10);
return () => Text('Count: ${counter.get()}');
}
}
2. Composition Hook Generated from Existing Logic
If you already have a reusable state object, you can wrap it with a composition hook and let setup manage disposal:
import 'package:jolt_setup/jolt_setup.dart';
@defineHook
Counter useCounterHookWithoutClass2([int initialValue = 0]) {
final counter = useMemoized(
() => Counter(initialValue: initialValue),
(counter) => counter.dispose(),
);
return counter;
}
3. Class-based Hook
For more complex hooks, extend SetupHook directly:
import 'package:jolt_setup/jolt_setup.dart';
@defineHook
CounterHook useCounterHookClass([int initialValue = 0]) =>
useHook(CounterHook(initialValue: initialValue));
class CounterHook extends SetupHook<CounterHook> {
final int initialValue;
CounterHook({required this.initialValue});
late Signal<int> raw;
void increment() => raw.value++;
void decrement() => raw.value--;
void reset() => raw.value = initialValue;
int get() => raw.value;
void set(int value) => raw.value = value;
@override
CounterHook build() {
raw = Signal(initialValue);
return this;
}
@override
void unmount() => raw.dispose();
}
4. Class-based Hook Generated from an Existing Class
If you already have a reusable class, you can keep that class as the state and wrap it with a hook:
import 'package:jolt_setup/jolt_setup.dart';
@defineHook
Counter useCounterHookClass2([int initialValue = 0]) =>
useHook(CounterHook2(initialValue: initialValue));
class CounterHook2 extends SetupHook<Counter> {
final int initialValue;
CounterHook2({required this.initialValue});
@override
Counter build() => Counter(initialValue: initialValue);
@override
void unmount() => state.dispose();
}
Using @defineHook for Lint Checking:
The @defineHook annotation is used to indicate that a function is a hook for lint checking purposes. It helps ensure proper hook usage patterns:
@defineHook
CounterHook useCounterHookClass([int initialValue = 0]) =>
useHook(CounterHook(initialValue: initialValue));
Add @defineHook to custom hook entry points so lint rules can recognize them as hooks.
What useHook does
useHook(...) is the primitive API that registers a SetupHook instance into the current setup context.
- It lets setup cache and reuse the hook across rebuilds
- It connects the hook to the setup lifecycle, including mount, unmount, and hot reload reassemble
- It returns the hook's
state
In practice, most custom class-based hooks are just a thin entry function around useHook(...).
@defineHook
CounterHook useCounterHookClass([int initialValue = 0]) =>
useHook(CounterHook(initialValue: initialValue));
What build() and state mean in class-based hooks
In a SetupHook<T>:
useHook(...)callsbuild()when the hook is first createdbuild()creates the hook'sstate- setup keeps that
statealive and reuses it until unmount
That means there are two common shapes:
class CounterHook extends SetupHook<CounterHook> {
late Signal<int> raw;
@override
CounterHook build() {
raw = Signal(0);
return this;
}
}
Here, state is the hook object itself.
class CounterHook2 extends SetupHook<Counter> {
@override
Counter build() => Counter(initialValue: 0);
}
Here, state is the separate Counter object returned by build().
So build() is the creation step, and state is the value created by build() and then kept alive by setup.
Guidelines:
- Use composition hooks when you are mostly composing existing hooks
- Use
useMemoized(..., disposer)when you already have reusable state logic and only need setup-managed ownership - Use class-based hooks when the hook itself needs explicit lifecycle structure
- Use an existing class as hook state when your domain logic is already modeled as a reusable object
- Add
@defineHookannotation to enable lint checking and enforce hook rules
Hook Rules:
Hooks must follow these rules to work correctly:
✅ DO: Call hooks synchronously
setup(context) {
final count = useSignal(0); // ✅ Correct - synchronous call
return () => Text('${count.value}');
}
❌ DON'T: Call hooks in async functions
setup(context) {
Future<void> loadData() async {
final data = useSignal([]); // ❌ Wrong - inside async function
}
return () => Text('...');
}
❌ DON'T: Call hooks in callbacks
setup(context) {
ElevatedButton(
onPressed: () {
final count = useSignal(0); // ❌ Wrong - inside callback
},
);
return () => Text('...');
}
❌ DON'T: Call hooks outside setup/hook context
void regularFunction() {
final count = useSignal(0); // ❌ Wrong - outside setup context
}
✅ DO: Call hooks at the top level of setup or inside another hook
setup(context) {
final count = useSignal(0); // ✅ Correct
final doubled = useComputed(() => count.value * 2); // ✅ Correct
onMounted(() {
// ❌ Don't call hooks here - this is a callback
print('Mounted');
});
return () => Text('${doubled.value}');
}
Usage Example:
setup: (context) {
// Signals
final count = useSignal(0);
final name = useSignal('Flutter');
// Computed values
final doubled = useComputed(() => count.value * 2);
// Reactive collections
final items = useSignal.list(['apple', 'banana']);
final userMap = useSignal.map({'name': 'John', 'age': 30});
// Effects
useEffect(() {
print('Count changed: ${count.value}');
});
// Lifecycle callbacks
onMounted(() {
print('Widget mounted');
});
onUnmounted(() {
print('Widget unmounted');
});
return () => Text('Count: ${count.value}');
}
Automatic Resource Cleanup:
All hooks automatically clean up their resources when the widget unmounts, ensuring proper cleanup and preventing memory leaks:
setup: (context) {
final timer = useSignal<Timer?>(null);
onMounted(() {
timer.value = Timer.periodic(Duration(seconds: 1), (_) {
print('Tick');
});
});
onUnmounted(() {
timer.value?.cancel();
});
return () => Text('Timer running');
}
Flutter Hooks #
Declarative hooks for managing common Flutter resources such as controllers, focus nodes, and lifecycle states with automatic cleanup.
Listenable Hooks #
| Hook | Description | Returns |
|---|---|---|
useValueNotifier<T>(initialValue) |
Creates a value notifier | ValueNotifier<T> |
useValueListenable<T>(...) |
Listens to a value notifier and triggers rebuilds | void |
useListenable<T>(...) |
Listens to any listenable and triggers rebuilds | void |
useListenableSync<T, C>(...) |
Bidirectional sync between Signal and Listenable | void |
useChangeNotifier<T>(creator) |
Generic ChangeNotifier hook | T extends ChangeNotifier |
Animation Hooks #
| Hook | Description | Returns |
|---|---|---|
useSingleTickerProvider() |
Creates a single ticker provider | TickerProvider |
useTickerProvider() |
Creates a ticker provider that supports multiple tickers | TickerProvider |
useAnimationController({...}) |
Creates an animation controller | AnimationController |
Focus Hooks #
| Hook | Description | Returns |
|---|---|---|
useFocusNode({...}) |
Creates a focus node | FocusNode |
useFocusScopeNode({...}) |
Creates a focus scope node | FocusScopeNode |
Lifecycle Hooks #
| Hook | Description | Returns |
|---|---|---|
useAppLifecycleState([initialState]) |
Listens to app lifecycle state | ReadonlySignal<AppLifecycleState?> |
Scroll Hooks #
| Hook | Description | Returns |
|---|---|---|
useScrollController({...}) |
Creates a scroll controller | ScrollController |
useTrackingScrollController({...}) |
Creates a tracking scroll controller | TrackingScrollController |
useTabController({...}) |
Creates a tab controller | TabController |
usePageController({...}) |
Creates a page controller | PageController |
useFixedExtentScrollController({...}) |
Creates a fixed extent scroll controller | FixedExtentScrollController |
useDraggableScrollableController() |
Creates a draggable scrollable controller | DraggableScrollableController |
useCarouselController({...}) |
Creates a carousel controller | CarouselController |
Text Hooks #
| Hook | Description | Returns |
|---|---|---|
useTextEditingController([text]) |
Creates a text editing controller | TextEditingController |
useTextEditingController.fromValue([value]) |
Creates a text editing controller from value | TextEditingController |
useRestorableTextEditingController([value]) |
Creates a restorable text editing controller | RestorableTextEditingController |
useSearchController() |
Creates a search controller | SearchController |
useUndoHistoryController({...}) |
Creates an undo history controller | UndoHistoryController |
Controller Hooks #
| Hook | Description | Returns |
|---|---|---|
useTransformationController([value]) |
Creates a transformation controller | TransformationController |
useWidgetStatesController([value]) |
Creates a widget states controller | WidgetStatesController |
useExpansibleController() |
Creates an expansible controller | ExpansibleController |
useTreeSliverController() |
Creates a tree sliver controller | TreeSliverController |
useOverlayPortalController({...}) |
Creates an overlay portal controller | OverlayPortalController |
useSnapshotController({...}) |
Creates a snapshot controller | SnapshotController |
useCupertinoTabController({...}) |
Creates a Cupertino tab controller | CupertinoTabController |
useContextMenuController({...}) |
Creates a context menu controller | ContextMenuController |
useMenuController() |
Creates a menu controller | MenuController |
useMagnifierController({...}) |
Creates a magnifier controller | MagnifierController |
Async Hooks #
| Hook | Description | Returns |
|---|---|---|
useFuture<T>(future, {...}) |
Creates a reactive future signal | AsyncSnapshotFutureSignal<T> |
useStream<T>(stream, {...}) |
Creates a reactive stream signal | AsyncSnapshotStreamSignal<T> |
useStreamController<T>(...) |
Creates a stream controller | StreamController<T> |
useStreamSubscription<T>(...) |
Manages a stream subscription | void |
Keep Alive Hook #
| Hook | Description | Returns |
|---|---|---|
useAutomaticKeepAlive(wantKeepAlive) |
Manages automatic keep alive with reactive signal | void |
Related Packages #
Jolt Setup is part of the Jolt ecosystem:
| Package | Description |
|---|---|
| jolt | Core library providing Signals, Computed, Effects, and reactive collections |
| jolt_flutter | Flutter widgets: JoltBuilder, JoltSelector, and ValueNotifier integration |
| jolt_hooks | Hooks API: useSignal, useComputed, useJoltEffect, useJoltWidget |
| jolt_surge | Signal-powered Cubit pattern: Surge, SurgeProvider, SurgeConsumer |
| jolt_lint | Custom lint and code assists: Wrap widgets, convert to/from Signals, Hook conversions |
License #
This project is licensed under the MIT License - see the LICENSE file for details.