get_hooked 0.0.1 copy "get_hooked: ^0.0.1" to clipboard
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 Animations
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 InheritedWidgets.
(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 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.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.

getFade


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:

  1. How many hook functions the build method calls, and
  2. 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:

get objects

This mimics the setup of the class API.

get autofill