get_hooked 0.2.2 get_hooked: ^0.2.2 copied to clipboard
Shared state with flutter_hooks! Inspired by riverpod and get_it.
A Flutter package for sharing state between widgets, inspired by riverpod and get_it.
Summary #
Listenable providers built with Hooks!
No boilerplate, no build_runner
, huge performance.
Comparison #
InheritedWidget |
provider | bloc | riverpod | get_it | get_hooked | |
---|---|---|---|---|---|---|
shared state between widgets | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
supports scoping | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
optimized for performance | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
optimized for testability | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
conditional subscriptions | ❌ | ❌ | ❌ | ✅ | ❌ | ✅ |
integrated with Hooks | ❌ | ❌ | ❌ | ✅ | ❌ | ✅ |
avoids type overlap | ❌ | ❌ | ❌ | ✅ | ❌ | ✅ |
no context needed |
❌ | ❌ | ❌ | ❌ | ✅ | ✅ |
no boilerplate/code generation needed | ❌ | ✅ | ❌ | ❌ | ✅ | ✅ |
supports lazy-loading | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ |
supports auto-dispose | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
supports Animation s |
✅ | ✅ | ❌ | ❌ | ❌ | ✅ |
Flutter & non-Flutter variants | ❌ | ❌ | ✅ | ✅ | ✅ | ❌ |
Has a stable release | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
Drawbacks #
"Early Alpha" stage #
Until version 1.0.0, you can expect breaking changes without prior warning.
Flutter only #
Many packages on pub.dev 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.
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');
}
(The ==
/hashCode
overrides could be added manually, or with a fancy macro!)
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);
}
}
A final
, 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;
get_hooked #
final getMyData = Get.it(Data.initial);
final data = Ref.watch(getMyData);
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 the effects have stretched into other packages:
- flutter_hooks includes built-in support for Flutter's
Listenable
objects. - riverpod has adopted the same strategy as ChangeNotifier in its internal logic.
Then 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 animation's ticker up-to-date, and
RenderHookWidget
s (such as HookPaint
) can re-render animations
without ever rebuilding the widget tree.
final getAnimation = Get.vsync();
class MyWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
return HookPaint.compose(
painter: (context, size) {
// This widget will re-paint each time getAnimation sends an update.
Ref.vsync(getAnimation, watch: true);
// ...
},
);
}
}
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: [Ref(getMyData).subFactory(OtherData.new)],
child: HookBuilder(builder: (context) {
final data = Ref.watch(getMyData);
// ...
}),
),
If the current context
has an ancestor GetScope
, building another scope
isn't necessary:
class MyWidget extends HookWidget {
const MyWidget({super.key, required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
final newData = useSubstitute(getMyData, OtherData.new);
return Row(
children: [Text('$newData'), child],
);
}
}
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 HookWidget {
const CounterButton({super.key});
@override
Widget build(BuildContext context) {
final counter = useState(0);
return FilledButton(
onPressed: () {
counter.value += 1;
},
child: Text('counter value: ${counter.value}'),
);
}
}
But the following change would allow any widget to access this value:
final getCount = Get.it(0);
class CounterButton extends HookWidget {
const CounterButton({super.key});
@override
Widget build(BuildContext context) {
return FilledButton(
onPressed: () {
getCount.value += 1;
},
child: Text('counter value: ${Ref.watch(getCount)}'),
);
}
}
15 lines of code, same as before!
An object like getCount
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
getCount
without the outer button widget ever being rebuilt:
final getCount = Get.it(0);
class CounterButton extends FilledButton {
const CounterButton({super.key})
: super(onPressed: _increment, child: const HookBuilder(builder: _build));
static void _increment() {
getCount.value += 1;
}
static Widget _build(BuildContext context) {
return Text('counter value: ${Ref.watch(getCount)}');
}
}
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
Listenable
object (and takes care of the listener automatically). - If you feel like it.
extension type Ref<T, V>(Get _get) {
static T watch(Get<T, V> getObject) {
return use(_RefWatchHook(getObject));
}
Substitution sub(Get other) {
return _SubEager(_get.hooked, other.hooked);
}
}
Ref.watch()
and other static methods link Get objects with HookWidget
s
and RenderHookWidget
s.
The Ref()
constructor is used in a GetScope
to make substitutions.
Descendant widgets that use Ref.watch()
will reference the new object
in its place.
Tips for success #
Follow the rules of Hooks #
Ref
functions, along with any function name starting with use
,
should only be called inside a HookWidget
's build method.
// BAD
Builder(builder: (context) {
final focusNode = useFocusNode();
final data = Ref.watch(getMyData);
})
// GOOD
HookBuilder(builder: (context) {
final focusNode = useFocusNode();
final data = Ref.watch(getMyData);
})
A HookWidget
's context
keeps track of:
- how many hook functions are called, and
- the order they're called in.
Neither of these should change throughout the widget's lifetime.
For a more detailed explanation, see also:
The RenderHookWidget
is unique to get_hooked—RenderHook methods
can update RenderObject
s directly, but they're only compatible with the
static functions defined in Ref
:
// BAD
HookPaint.compose(painter: (context, size) {
final controller = useAnimationController();
})
// GOOD
HookPaint.compose(painter: (context, size) {
Ref.vsync(getAnimation);
})
Only scope when necessary #
One of the best things about get_hooked is the ability to interact with providers directly.
The additional BuildContext
boilerplate is handled by Ref
functions
within a hook widget's build method, but scoping makes handling things
between frames more verbose than it could be.
// With scope:
context.get(getAnimation).forward();
// No scope:
getAnimation.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 useSubstitute()
and GetScope.add()
leading to
conflicting substitutions. Additionally, RenderHookElement
can safely take a
performance shortcut (e.g. after GlobalKey reparenting)
when the there's no ancestor GetScope
.
When creating tests, consider performing global dependency injection when possible.
// OKAY, but it assumes that MyWidget doesn't reference the original object.
testWidgets('my test', (tester) async {
await tester.pumpWidget(
GetScope(
substitutes: {Ref(getMyData).subFactory(TestData.new)},
child: MyWidget(),
)
);
});
// BETTER
setUp(() {
reconfigureMyData();
});
testWidgets('my test', (tester) async {
await tester.pumpWidget(MyWidget());
});
(This code snippet was written for the purpose of instruction;
please disregard the glaring lack of a tear-off.)
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.
When a GetAsync
object's listeners are removed, it will automatically end its
stream subscription and restore the listenable to its default state.
A listenable encapulated in a Get object should avoid calling the internal
ChangeNotifier.dispose()
method, since the object would be unusable
from that point onward.
Troubleshooting / FAQs #
So far, not a single person has reached out because of a problem with this package. Which means it's probably flawless!