nate-thegrate / get_hooked

shared state with flutter_hooks!
https://no-tolls.dev/
0 stars 0 forks source link
animation flutter state-management

Get Hooked! (logo)


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]);

  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);


Comparison

InheritedWidget provider bloc riverpod get_it get_hooked
shared state between widgets
supports scoping
optimized for performance
optimized for testability
Tailored for Dart 3
Has a stable release
supports conditional subscriptions
integrated with Hooks
avoids type overlap
no context needed
no boilerplate/code generation needed
supports lazy-loading
supports auto-dispose
supports Animations
Flutter & non-Flutter variants


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

If you want a non-Flutter version of get_hooked, please open an issue and describe your use case.


Highlights

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:


Then in February 2024, Dart introduced extension types, allowing for complete control of an API surface without incurring runtime performance costs.


November 2024: get_hooked is born.

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 TickerProvider allows animations to repeatedly attach & detach from BuildContexts based on how they're being used. A developer could prevent widget rebuilding entirely by hooking them straight up to RenderObjects.

final getAnimation = Get.vsync();

class _RenderMyAnimation extends RenderSliverAnimatedOpacity {
  _RenderMyAnimation() : super(opacity: getAnimation.hooked);
}


Optional 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.

ProviderScope(
  overrides: [myDataProvider.overrideWith(OtherDataClass.new)],
  child: Consumer(builder: (context, ref, child) {
    final data = ref.watch(myDataProvider);
    // ...
  }),
),

Ref.watch() will watch a different Get object if an Override is found in an ancestor GetScope widget.

GetScope(
  overrides: [Override(getMyData, overrideWith: OtherDataClass.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 = useState(Data.initial);
    Ref.override(getMyData, () => newData); // Adds newData to the scope.

    return Row(
      children: [Text('$newData'), child],
    );
  }
}

If the child widget uses Ref.watch(getMyData), it will watch the newData by default.


No magic curtain

Want to find out what's going on?\ No breakpoints, no print statements. Just type the name.

getFade


Overview

"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.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.modify((int value) => value + 1);
  }

  static Widget _build(BuildContext context) {
    return Text('counter value: ${Ref.watch(getCount)}');
  }
}


Detailed Overview

Here's a (less oversimplified) rundown of the Get API:

extension type Get<T, V extends ValueListenable<T>>.custom(V hooked) {
  @factory
  static GetValue<T> it<T>(T initial) => GetValue<T>._(initial);

  T get value => hooked.value
}

extension type GetValue<T>._(ValueNotifier<T> hooked) implements Get<T, ValueNotifier<T>> {
  void emit(T newValue) => hooked.value = newValue;

  void modify(T Function(T value) modifier) => emit(modifier(hooked.value));
}

class Ref {
  static T watch(Get<T, ValueListenable<T>> getObject) {
    return use(_RefHook(getObject));
  }
}

[!CAUTION] Do not access hooked directly: get it through a Hook instead.\ If a listener is added without automatically being removed, it can result in memory leaks, and calling dispose() would create problems for other widgets that are still using it.


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.


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(useMyData);
})

// GOOD
HookBuilder(builder: (context) {
  final focusNode = useFocusNode();
  final data = Ref.watch(useMyData);
})

A HookWidget's context keeps track of:

  1. how many hook functions are called, and
  2. the order they're 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 Hook functions generally start 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, to avoid cluttering autofill suggestions:

@visibleForTesting
final getAnimation = Get.vsync();


Avoid using hooked 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 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!






get_hooked (logo, bottom)