get_hooked 0.0.1 get_hooked: ^0.0.1 copied to clipboard
shared state with flutter_hooks!
A Flutter package for sharing state between widgets, inspired by riverpod and get_it.
Given a generic Data
class, let's see how different state management options compare.
@immutable
class Data {
const Data(this.firstItem, [this.secondItem]);
static const initial = Data('initial data');
final Object firstItem;
final Object? secondItem;
}
(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.value(Data.initial);
final data = Use.watch(getMyData);
Comparison #
InheritedWidget |
provider | bloc | riverpod | get_it | get_hooked | |
---|---|---|---|---|---|---|
shared state between widgets | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
optimized for performance | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
optimized for testability | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
integrated with Hooks | ❌ | ❌ | ❌ | ✅ | ❌ | ✅ |
avoids type overlap | ❌ | ❌ | ❌ | ✅ | ❌ | ✅ |
no context needed |
❌ | ❌ | ❌ | ❌ | ✅ | ✅ |
no boilerplate/code generation needed | ❌ | ✅ | ❌ | ❌ | ✅ | ✅ |
no extra packages needed | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
supports lazy-loading | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ |
supports scoping | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
supports auto-dispose | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
supports Animation s |
✅ | ✅ | ❌ | ❌ | ❌ | ✅ |
Flutter & non-Flutter variants | ❌ | ❌ | ✅ | ✅ | ✅ | ❌ |
Drawbacks #
Let's start with the bad news.
"Early Alpha" stage #
Until the 1.0.0 release, you can expect breaking changes without prior warning.
No scoping #
Here, "scoping" is defined as a form of dependency injection, where a subset of widgets
(typically descendants of an InheritedWidget
) receive data by different means.
testWidgets('my test', (WidgetTester tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [myDataProvider.overrideWith(TestData.new)],
child: MyWidget(),
),
);
});
Even though get_hooked does not support this, widgets can be effectively tested via global dependency injection.
setUp(() {
reconfigureMyData();
});
testWidgets('my test', (WidgetTester tester) async {
await tester.pumpWidget(
child: MyWidget(),
);
});
(This code snippet was written for the purpose of instruction;
please disregard the glaring lack of a tear-off.)
Testability is super important—please don't hesitate to reach out with any issues you run into.
Tip
Global dependency injection is great for test coverage, but in some cases, a scoping mechanism might be desired as part of the app structure.
The solution is: don't use get_hooked for it! Reusable "state" can be achieved with a
StatefulWidget
or HookWidget
, and reusable "shared state" is possible through InheritedWidget
s.
(Using both get_hooked and provider is totally fine!)
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 |
If you want a non-Flutter version of get_hooked, please open an issue and describe your use case.
Overview #
abstract class Get {
ValueListenable get it;
void update(Function function);
}
"Get" encapsulates a listenable object with an interface for easy updates and automatic lifecycle management.
Example usage
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.value(0);
class CounterButton extends HookWidget {
const CounterButton({super.key});
@override
Widget build(BuildContext context) {
return FilledButton(
onPressed: () {
getCount.update((int value) => value + 1);
},
child: Text('counter value: ${Use.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.value(0);
class CounterButton extends FilledButton {
const CounterButton({super.key})
: super(onPressed: _update, child: const HookBuilder(builder: _build));
static void _update() {
getCount.update((int value) => value + 1);
}
static Widget _build(BuildContext context) {
return Text('counter value: ${Use.watch(getCount)}');
}
}
Detailed Overview #
Here's a (less oversimplified) rundown of the Get API:
abstract interface class Get<T, V extends ValueListenable<T>> {
/// [Get.value] is a pseudo-constructor: it creates a [Get] object,
/// but it's structured as a `static` method instead of a `factory`
/// so that it can expose the [GetValue<T>] return type.
static GetValue<T> value<T>(T initial) {
return GetValue<T>(initial);
}
V get it;
void update(covariant Function updateFunction);
}
class GetValue<T> implements Get<T, ValueNotifier<T>> {
GetValue(T initial) : it = ValueNotifier(initial);
@override
final ValueNotifier<T> it;
@override
void update(T Function(T previous) updateFunction) {
it.value = updateFunction(it.value);
}
}
/// The [Use] class is just a namespace for hook functions.
abstract final class Use {
static T watch(Get<T, ValueListenable<T>> getObject) {
return use(_GetHook(getObject));
}
}
Caution
Do not access it
directly: get it through a Hook instead.
If a listener is added without automatically being removed, it can result in memory leaks,
and calling it.dispose()
would create problems for other widgets that are still using it.
Only use it
in the following situations:
- If another API accepts a
Listenable
object (and takes care of the listener automatically). - If you feel like it.
Highlights #
Animations #
This package makes it easier than ever before for a multitude of widgets to subscribe to a single
Animation
.
A tailor-made TickerProvider
allows animations to repeatedly attach & detach from BuildContext
s
based on how they're being used. A developer could prevent widget rebuilding entirely by
hooking them straight up to RenderObject
s.
final getAnimation = Get.vsync();
class _RenderMyAnimation extends RenderSliverAnimatedOpacity {
_RenderMyAnimation() : super(opacity: getAnimation.it);
}
No boilerplate, no magic curtain #
get_hooked is powered by Flutter's ChangeNotifier API (riverpod does the same thing under the hood) along with the concept of "Hooks" introduced by React.
Want to find out what's going on?
No breakpoints, no print statements. Just type the name.
Tips for success #
Avoid using it
directly #
Unlike most StatefulWidget
member variables, 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 Use.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 never call the
internal ChangeNotifier.dispose()
method, since the object would be unusable from that point onward.
Follow the rules of Hooks #
By convention, Hook function names start with use
, and they should only be called inside a HookWidget
's build method.
The HookWidget
's context
keeps track of:
- How many hook functions the build method calls, and
- the order they were called in.
Neither of these should change throughout the widget's lifetime.
For a more detailed explanation, see also:
Get
naming conventions #
Just like how each Hook function starts with use
, Get objects should start with get
.
If the object is only intended to be used by widgets in the same .dart
file, consider marking it with an annotation:
@visibleForTesting
final getAnimation = Get.vsync();
Consistent Get object names make keeping inventory a lot easier:
This mimics the setup of the class API.