get_hooked 0.2.2 copy "get_hooked: ^0.2.2" to clipboard
get_hooked: ^0.2.2 copied to clipboard

Shared state with flutter_hooks! Inspired by riverpod and get_it.

Get Hooked! (logo)


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 Animations
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:


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 RenderHookWidgets (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 HookWidgets and RenderHookWidgets.

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:

  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:


The RenderHookWidget is unique to get_hooked—RenderHook methods can update RenderObjects 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!






get_hooked (logo, bottom)

0
likes
130
points
168
downloads

Publisher

verified publisherno-tolls.dev

Weekly Downloads

Shared state with flutter_hooks! Inspired by riverpod and get_it.

Homepage
Repository (GitHub)
View/report issues

Topics

#flutter #animation #state-management

License

MIT (license)

Dependencies

collection, collection_notifiers, flutter, meta

More

Packages that depend on get_hooked