get_hooked 0.5.4
get_hooked: ^0.5.4 copied to clipboard
State management with Listenables and Hooks! Inspired by hooks_riverpod and get_it.
please don't. #
get_hooked handles state management with globally-scoped ValueListenable objects.
This practice goes against Flutter's style guide
and in some situations can lead to memory leaks.
Futhermore, prior to the 1.0.0 release, this package will not have deprecation periods for breaking changes.
Here are a few alternatives to consider:
- signals: a feature-rich package that reduces boilerplate without any code generation.
- riverpod: if you don't mind using build_runner, this is a fantastic option.
- watch_it: this package works great in combination with get_it.
To learn more about get_hooked, continue reading here.
Summary #
Listenable providers built with Hooks!
No boilerplate, no build_runner, huge performance.
Comparison #
InheritedWidget |
provider | bloc | riverpod | signals | get_it | get_hooked | |
|---|---|---|---|---|---|---|---|
| shared state between widgets | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| supports scoping | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| optimized for performance | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| optimized for testability | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| boilerplate reduction | ❌ | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
| avoids type overlap | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ✅ |
| build_runner not needed | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ |
| conditional subscriptions | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
context not needed |
❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ |
| supports lazy-loading | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| supports animations | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ |
| supports non-Flutter applications | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ |
| Has a stable release | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
Note
There are a few caveats to the above list:
build_runneris recommended, but not required, for riverpod- signals requires a
context(and suffers from type overlap issues) only when usingSignalProvider(as shown below). Both get_hooked and riverpod follow a convention of "globally-scopedfinalobjects", and when applying this paradigm to signals, the boilerplate reduction gets even better.
(Then the only drawback becomes a lack of support for scoping.)
Drawbacks #
"Early Alpha" stage #
Until version 1.0.0, you can expect breaking changes without prior warning.
Flutter only #
Many packages on pub.flutter-io.cn have both a Flutter and a non-Flutter variant.
| Flutter | generic |
|---|---|
| flutter_riverpod | riverpod |
| flutter_bloc | bloc |
| watch_it | get_it |
This is not a planned feature for get_hooked.
Unconditional Subscriptions #
Depending on who you ask, a lack of conditional subscriptions
could be characterized as a "missing feature" or as a "performance tradeoff".
(See flutter.dev/go/inheritedwidget-subscription
and its linked issue for more discussion.)
Setting up a provider to auto-dispose when it no longer has listeners can reduce costs: both in terms of performance and money.
Widget build(BuildContext context, WidgetRef ref) {
Object? data;
if (_showingData) {
data = ref.watch(databaseProvider);
}
// The provider can disconnect itself from the database
// After the widget builds without a ref.watch() call.
}
But a similar result is achievable via composition:
Widget build(BuildContext context) {
return _showingData ? const WidgetA() : const WidgetB();
}
Implementing conditional subscriptions for this package would be difficult
due to conflicts with the "Hook" paradigm.
That being said: feel free to post an issue
and share your opinion if you'd like!
Highlights #
No boilerplate. #
Given a generic Data class, let's see how different state management options compare.
@immutable
class Data {
const Data(this.firstItem, [this.secondItem]);
final Object firstItem;
final Object? secondItem;
static const initial = Data('initial data');
}
Inherited Widget #
class _InheritedData extends InheritedWidget {
const _InheritedData({super.key, required this.data, required super.child});
final Data data;
@override
bool updateShouldNotify(MyData oldWidget) => data != oldWidget.data;
}
class MyData extends StatefulWidget {
const MyData({super.key, required this.child});
final Widget child;
static Data of(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<_InheritedData>()!.data;
}
State<MyData> createState() => _MyDataState();
}
class _MyDataState extends State<MyData> {
Data _data = Data.initial;
@override
Widget build(BuildContext context) {
return _InheritedData(data: _data, child: widget.child);
}
}
Then the data can be accessed with
final data = MyData.of(context);
provider #
typedef MyData = ValueNotifier<Data>;
class MyWidget extends StatelessWidget {
const MyWidget({super.key, required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (context) => MyData(Data.initial),
child: child,
);
}
}
(flutter_bloc is very similar but requires extending Cubit<Data> rather than making a typedef.)
final data = context.watch<MyData>().value;
riverpod #
@riverpod
class MyData extends _$MyData {
@override
Data build() => Data.initial;
void update(Object firstItem, [Object? secondItem]) {
state = Data(firstItem, secondItem);
}
}
An immutable, globally-scoped myDataProvider object is created via code generation:
$ dart run build_runner watch
and accessed as follows:
final data = ref.watch(myDataProvider);
get_it #
typedef MyData = ValueNotifier<Data>;
GetIt.I.registerSingleton(MyData(Data.initial));
final data = watchIt<MyData>().value;
signals #
typedef MyData = FlutterSignal<Data>;
class MyWidget extends StatelessWidget {
const MyWidget({super.key, required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
return SignalProvider(
create: () => MyData(Data.initial),
child: child,
);
}
}
To be fair, signals can be set up as static or globally-scoped objects
(or as class members) to greatly reduce the boilerplate—the only downside being
a lack of support for scoping.
final data = SignalProvider.of<MyData>(context);
get_hooked #
final myData = Get.it(Data.initial);
final data = ref.watch(myData);
Zero-cost interface #
In April 2021, flutter/flutter#71947
added a huge performance optimization to the ChangeNotifier
API.
This boosted Listenable objects throughout the Flutter framework,
and in other packages:
- flutter_hooks includes built-in support for Flutter's
Listenableobjects. - riverpod has adopted the same strategy as ChangeNotifier in its internal logic.
- get_it recommends using ChangeNotifier to interact with its service locator API.
- signals has adapted its Flutter signal APIs so that they implement ValueListenable by default.
In February 2024, Dart introduced extension types, allowing for complete control of an API surface without incurring runtime performance costs.
November 2024:
extension type Get(Listenable hooked) {
// ...
}
Animations #
This package makes it easier than ever before for a multitude of widgets to subscribe to a single animation.
A tailor-made Vsync keeps the tickers up-to-date, and RefPaint can subscribe
and re-render without ever rebuilding the widget tree.
class MyWidget extends StatelessWidget {
static final animation = Get.vsync();
@override
Widget build(BuildContext context) {
return RefPaint((ref) {
// This widget will re-paint each time the animation sends an update.
final double t = ref.watch(animation);
ref.canvas.drawPath(/* ... */);
});
}
}
Optional scoping #
"Scoping" allows descendants of an InheritedWidget to receive data
by different means.
For example, flutter_riverpod includes a ProviderScope widget:
ProviderScope(
overrides: [myDataProvider.overrideWith(OtherData.new)],
child: Consumer(builder: (context, ref, child) {
final data = ref.watch(myDataProvider);
// ...
}),
),
Likewise, get_hooked enables ref.watch() to subscribe to a different object
if a substitution is found in an ancestor GetScope.
GetScope(
substitutes: [Substitute(myData, OtherData.new)],
child: RefBuilder(builder: (context) {
final data = ref.watch(myData);
// ...
}),
),
If the child widget uses ref.watch(getMyData), it will watch
the newData by default.
Overview #
Get objects aren't necessary if the state isn't shared between widgets.
This example shows how to make a button with a number that increases each time it's tapped:
class CounterButton extends StatefulWidget {
const CounterButton({super.key});
@override
State<CounterButton> createState() => _CounterButtonState();
}
class _CounterButtonState extends State<CounterButton> {
int counter = 0;
@override
Widget build(BuildContext context) {
return FilledButton(
onPressed: () {
setState(() => counter += 1);
},
child: Text('counter value: $counter'),
);
}
}
But the following change would allow any widget to access this value:
final counter = Get.it(0);
class CounterButton extends RefWidget {
const CounterButton({super.key});
@override
Widget build(BuildContext context) {
return FilledButton(
onPressed: () {
counter.value += 1;
},
child: Text('counter value: ${ref.watch(counter)}'),
);
}
}
An object like counter can't be passed into a const constructor.
However: since access isn't limited in scope, it can be referenced by functions
and static methods, creating huge potential for rebuild-optimization.
The following example supports the same functionality as before, but
the Text widget updates based on the counter without the outer button widget
ever being rebuilt:
final counter = Get.it(0);
class CounterButton extends FilledButton {
const CounterButton({super.key})
: super(onPressed: _increment, child: const RefBuilder(builder: _build));
static void _increment() {
counter.value += 1;
}
static Widget _build(BuildContext context) {
return Text('counter value: ${ref.watch(counter)}');
}
}
Detailed Overview #
/// Wraps a [Listenable] with a new interface.
extension type Get<T, V extends ValueListenable<T>>.custom(V _hooked) {
@factory
static GetValue<T> it<T>(T initial) => GetValue<T>._(ValueNotifier(initial));
T get value => hooked.value
}
/// A subtype of [Get] that encapsulates a [ValueNotifier].
extension type GetValue<T>._(ValueNotifier<T> _hooked) implements Get<T, ValueNotifier<T>> {}
/// Gives direct access to the underlying [Listenable].
extension GetHooked<V> on Get<Object?, V> {
V get hooked => _hooked;
}
Caution
Do not get hooked directly: use ref.watch() instead.
If a listener is added without automatically being removed, it can result in memory leaks,
not to mention the problems that calling dispose() would create for other widgets
that are still using the object.
Consider hiding this getter as follows:
import 'package:get_hooked/get_hooked.dart' hide GetHooked;
Only use hooked in the following situations:
- If another API accepts a
Listenableobject (and takes care of the listener automatically). - If you feel like it.
Ref get ref => _elementDoingBuild!;
RefElement? _elementDoingBuild;
base mixin RefElement on ComponentElement implements ComputeContext {
// ...
}
ref.watch() and ref.select() link Get objects with RefWidgets
and render object widgets that use ComputeContext.
A GetScope enables substitutions: descendant widgets that use ref.watch()
will reference the new object in its place.
Tips for success #
Use ref inside build methods #
ref.watch() and ref.select() should only be called inside a
RefWidget's or StatefulRefWidget's build method, or a RefBuilder callback.
// BAD
Builder(builder: (context) {
final data = ref.watch(myData);
})
// GOOD
RefBuilder(builder: (context) {
final data = ref.watch(myData);
})
Unlike hook-based approaches, ref.watch() calls can be made conditionally
and in any order. Subscriptions are tracked by identity, not by position.
No simultaneous read & write #
If a function is calling ref.watch(), that function should not mutate
any non-local values.
// BAD
double computeFunction(Ref ref) {
final a = ref.watch(getA);
if (a > 0) {
getB.value += a;
}
final b = ref.watch(getB);
return a + b;
}
// GOOD
double computeFunction(Ref ref) {
final a = ref.watch(getA);
final b = ref.watch(getB);
return a + b;
}
void performUpdate() {
final a = getA.value;
if (a > 0) {
getB.value += a;
}
}
As a rule of thumb:
- Watching happens during a frame, while widgets are being built and rendered.
- Updates happen between frames, e.g. in response to user input or after
awaiting a Future.
Tip
Try to avoid post-frame callbacks
whenever possible. It's an easy band-aid solution for
error: setState() called during build(), but this practice often results in
the framework drawing multiple frames in response to a single update.
The more const, the better #
The Flutter framework understands that, when a widget instance is identical
to the previous version, the underlying Element doesn't need to rebuild
unless markNeedsBuild() was called for another reason. Using const constructors
is a super easy way to take advantage of this functionality.
But there's another reason why const is great: it helps your changes to show up
after a hot reload.
Dart considers something as "constant" if it has the const keyword, or if it's
a globally-scoped function or static method.
// The app needs a "hot restart" after a part of this callback is changed
final myValue = Get.compute((ref) {
return ref.watch(a) + ref.watch(b);
});
// Changes show up after a hot reload!
final myValue = Get.compute(_myValue);
double _myValue(Ref ref) {
return ref.watch(a) + ref.watch(b);
}
Only scope when necessary #
One of the best things about get_hooked is the ability to interact with providers directly.
While building a RefWidget, the ref methods handle the BuildContext
boilerplate, but as far as handling things between frames, scoping makes things
arguably a bit too verbose.
// With scope:
context.read(animation).forward();
// No scope:
animation.forward();
Scoping is sometimes necessitated by the app's target behavior: in these cases,
prefer adding the GetScope directly above the target widget(s), rather than
at the root of the tree.
// BAD
runApp(const GetScope(child: App()));
// GOOD
const GetScope(
// This scope is as low in the tree as possible
// while staying above the widgets that need scoping.
child: Row(
children: [
ScopedWidget1(),
ScopedWidget2(),
Expanded(child: ScopedWidget3()),
],
),
)
This reduces the likelihood of ref.sub() and GetScope.add() leading to
conflicting substitutions, and it mitigates the additional performance costs.
If scoping is always the desired behavior for a certain Get object,
prefer instantiating via a ScopedGet constructor.
final getString = ScopedGet.it<String>();
Avoid accessing hooked directly #
Unlike a typical State member variable, Get objects persist throughout
changes to the app's state, so a couple of missing removeListener() calls
might create a noticeable performance impact.
Prefer calling ref.watch() to subscribe to updates.
A static or globally-scoped object should avoid calling the internal
ChangeNotifier.dispose() method, since the object would be unusable
from that point onward.
Troubleshooting / FAQs #
Ticker AssertionError
You might see an error with the message
Cannot absorb Ticker after it has been disposed.
This is a bug: if an AnimationController starts its ticker and then calls
resync()
before the next frame, the Flutter framework incorrectly assumes that
the ticker was disposed of.
Eventually, a bugfix will be merged into the framework and subsequently will show up in a stable release. Until then, feel free to make a change to ticker.dart:
assert(
- (originalTicker._future == null) == (originalTicker._startTime == null),
+ (originalTicker._future != null) || (originalTicker._startTime == null),
'Cannot absorb Ticker after it has been disposed.',
);