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

State management with Listenables and Hooks! Inspired by hooks_riverpod and get_it.

Get Hooked! (logo)

please don't. #


get_hooked handles state management with globally-scoped ValueListenable objects. This practice goes against Flutter's style guide and in some situations can lead to memory leaks.

Futhermore, prior to the 1.0.0 release, this package will not have deprecation periods for breaking changes.


Here are a few alternatives to consider:

  • signals: a feature-rich package that reduces boilerplate without any code generation.
  • riverpod: if you don't mind using build_runner, this is a fantastic option.
  • watch_it: this package works great in combination with get_it.

To learn more about get_hooked, continue reading here.






Summary #

Listenable providers built with Hooks!

No boilerplate, no build_runner, huge performance.


Comparison #

InheritedWidget provider bloc riverpod signals get_it get_hooked
shared state between widgets
supports scoping
optimized for performance
optimized for testability
boilerplate reduction
avoids type overlap
build_runner not needed
conditional subscriptions
context not needed
supports lazy-loading
supports animations
supports non-Flutter applications
Has a stable release

Note

There are a few caveats to the above list:

  • build_runner is recommended, but not required, for riverpod
  • signals requires a context (and suffers from type overlap issues) only when using SignalProvider (as shown below). Both get_hooked and riverpod follow a convention of "globally-scoped final objects", and when applying this paradigm to signals, the boilerplate reduction gets even better.
    (Then the only drawback becomes a lack of support for scoping.)

Drawbacks #

"Early Alpha" stage #

Until version 1.0.0, you can expect breaking changes without prior warning.


Flutter only #

Many packages on pub.flutter-io.cn 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.

Unconditional Subscriptions #

Depending on who you ask, a lack of conditional subscriptions could be characterized as a "missing feature" or as a "performance tradeoff".
(See flutter.dev/go/inheritedwidget-subscription and its linked issue for more discussion.)

Setting up a provider to auto-dispose when it no longer has listeners can reduce costs: both in terms of performance and money.

Widget build(BuildContext context, WidgetRef ref) {
  Object? data;
  if (_showingData) {
    data = ref.watch(databaseProvider);
  }

  // The provider can disconnect itself from the database
  // After the widget builds without a ref.watch() call.
}

But a similar result is achievable via composition:

Widget build(BuildContext context) {
  return _showingData ? const WidgetA() : const WidgetB();
}

Implementing conditional subscriptions for this package would be difficult due to conflicts with the "Hook" paradigm.
That being said: feel free to post an issue and share your opinion if you'd like!


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

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

An immutable, 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;

signals #

typedef MyData = FlutterSignal<Data>;

class MyWidget extends StatelessWidget {
  const MyWidget({super.key, required this.child});

  final Widget child;

  @override
  Widget build(BuildContext context) {
    return SignalProvider(
      create: () => MyData(Data.initial),
      child: child,
    );
  }
}
To be fair, signals can be set up as static or globally-scoped objects (or as class members) to greatly reduce the boilerplate—the only downside being a lack of support for scoping.
    final data = SignalProvider.of<MyData>(context);

get_hooked #

final myData = Get.it(Data.initial);
    final data = ref.watch(myData);

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 in other packages:


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 tickers up-to-date, and RefPaint can subscribe and re-render without ever rebuilding the widget tree.


class MyWidget extends StatelessWidget {
  static final animation = Get.vsync();

  @override
  Widget build(BuildContext context) {
    return RefPaint((ref) {
      // This widget will re-paint each time the animation sends an update.
      final double t = ref.watch(animation);

      ref.canvas.drawPath(/* ... */);
    });
  }
}

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: [Substitute(myData, OtherData.new)],
  child: RefBuilder(builder: (context) {
    final data = ref.watch(myData);
    // ...
  }),
),

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 StatefulWidget {
  const CounterButton({super.key});

  @override
  State<CounterButton> createState() => _CounterButtonState();
}

class _CounterButtonState extends State<CounterButton> {
  int counter = 0;

  @override
  Widget build(BuildContext context) {
    return FilledButton(
      onPressed: () {
        setState(() => counter += 1);
      },
      child: Text('counter value: $counter'),
    );
  }
}

But the following change would allow any widget to access this value:

final counter = Get.it(0);

class CounterButton extends RefWidget {
  const CounterButton({super.key});

  @override
  Widget build(BuildContext context) {
    return FilledButton(
      onPressed: () {
        counter.value += 1;
      },
      child: Text('counter value: ${ref.watch(counter)}'),
    );
  }
}

An object like counter 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 the counter without the outer button widget ever being rebuilt:

final counter = Get.it(0);

class CounterButton extends FilledButton {
  const CounterButton({super.key})
    : super(onPressed: _increment, child: const RefBuilder(builder: _build));

  static void _increment() {
    counter.value += 1;
  }

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

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.

Ref get ref => _elementDoingBuild!;
RefElement? _elementDoingBuild;

base mixin RefElement on ComponentElement implements ComputeContext {
  // ...
}

ref.watch() and ref.select() link Get objects with RefWidgets and render object widgets that use ComputeContext.

A GetScope enables substitutions: descendant widgets that use ref.watch() will reference the new object in its place.


Tips for success #

Use ref inside build methods #

ref.watch() and ref.select() should only be called inside a RefWidget's or StatefulRefWidget's build method, or a RefBuilder callback.

// BAD
Builder(builder: (context) {
  final data = ref.watch(myData);
})

// GOOD
RefBuilder(builder: (context) {
  final data = ref.watch(myData);
})

Unlike hook-based approaches, ref.watch() calls can be made conditionally and in any order. Subscriptions are tracked by identity, not by position.


No simultaneous read & write #

If a function is calling ref.watch(), that function should not mutate any non-local values.

// BAD
double computeFunction(Ref ref) {
  final a = ref.watch(getA);
  if (a > 0) {
    getB.value += a;
  }
  final b = ref.watch(getB);
  return a + b;
}
// GOOD
double computeFunction(Ref ref) {
  final a = ref.watch(getA);
  final b = ref.watch(getB);
  return a + b;
}

void performUpdate() {
  final a = getA.value;
  if (a > 0) {
    getB.value += a;
  }
}

As a rule of thumb:

  • Watching happens during a frame, while widgets are being built and rendered.
  • Updates happen between frames, e.g. in response to user input or after awaiting a Future.

Tip

Try to avoid post-frame callbacks whenever possible. It's an easy band-aid solution for error: setState() called during build(), but this practice often results in the framework drawing multiple frames in response to a single update.


The more const, the better #

The Flutter framework understands that, when a widget instance is identical to the previous version, the underlying Element doesn't need to rebuild unless markNeedsBuild() was called for another reason. Using const constructors is a super easy way to take advantage of this functionality.

But there's another reason why const is great: it helps your changes to show up after a hot reload.

Dart considers something as "constant" if it has the const keyword, or if it's a globally-scoped function or static method.

// The app needs a "hot restart" after a part of this callback is changed
final myValue = Get.compute((ref) {
  return ref.watch(a) + ref.watch(b);
});


// Changes show up after a hot reload!
final myValue = Get.compute(_myValue);
double _myValue(Ref ref) {
  return ref.watch(a) + ref.watch(b);
}

Only scope when necessary #

One of the best things about get_hooked is the ability to interact with providers directly.

While building a RefWidget, the ref methods handle the BuildContext boilerplate, but as far as handling things between frames, scoping makes things arguably a bit too verbose.

// With scope:
context.read(animation).forward();

// No scope:
animation.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 ref.sub() and GetScope.add() leading to conflicting substitutions, and it mitigates the additional performance costs.


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.

A static or globally-scoped object should avoid calling the internal ChangeNotifier.dispose() method, since the object would be unusable from that point onward.



Troubleshooting / FAQs #

Ticker AssertionError

You might see an error with the message Cannot absorb Ticker after it has been disposed.

This is a bug: if an AnimationController starts its ticker and then calls resync() before the next frame, the Flutter framework incorrectly assumes that the ticker was disposed of.

Eventually, a bugfix will be merged into the framework and subsequently will show up in a stable release. Until then, feel free to make a change to ticker.dart:

    assert(
-     (originalTicker._future == null) == (originalTicker._startTime == null),
+     (originalTicker._future != null) || (originalTicker._startTime == null),
      'Cannot absorb Ticker after it has been disposed.',
    );
0
likes
150
points
140
downloads

Documentation

API reference

Publisher

verified publisherno-tolls.dev

Weekly Downloads

State management with Listenables and Hooks! Inspired by hooks_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