The author is available for consulting & contract workBook a call →
Skip to content

Lifecycle Functions ​

callOnce() and onDispose() ​

Execute a function only on the first build (even in a StatelessWidget), with optional dispose handler.

Method signatures:

dart
void callOnce(
  void Function(BuildContext context) init,
  {void Function()? dispose}
);

void onDispose(void Function() dispose);

Typical use case: Trigger data loading on first build, then display results with watchValue:

dart
class UserWidget extends WatchingWidget {
  const UserWidget({super.key});

  @override
  Widget build(BuildContext context) {
    // Trigger data loading once on first build
    callOnce((context) => di<UserDataService>().loadUser());

    // Watch and display the loaded data
    final user = watchValue((UserDataService s) => s.currentUser);

    return Text(user?.name ?? 'Loading...');
  }
}

callOnceAfterThisBuild() ​

Execute a callback once after the current build completes. Unlike callOnce() which runs immediately during build, this runs in a post-frame callback.

Method signature:

dart
void callOnceAfterThisBuild(
  void Function(BuildContext context) callback
);

Perfect for:

  • Navigation after async dependencies are ready
  • Showing dialogs or snackbars after initial render
  • Accessing RenderBox dimensions
  • Operations that should not run during build

Key behavior:

  • Executes once after the first build where this function is called
  • Runs in a post-frame callback (after layout and paint)
  • Safe to use inside conditionals - will execute once when the condition first becomes true
  • Won't execute again on subsequent builds, even if called again

Example - Navigate when dependencies are ready:

dart
class InitializationScreen extends WatchingWidget {
  @override
  Widget build(BuildContext context) {
    final dbReady = isReady<Database>();
    final configReady = isReady<ConfigService>();

    if (dbReady && configReady) {
      // Navigate once when all dependencies are ready
      // callOnceAfterThisBuild executes after the current build completes
      // Safe for navigation, dialogs, and accessing RenderBox
      callOnceAfterThisBuild((context) {
        Navigator.of(context).pushReplacement(
          MaterialPageRoute(builder: (_) => MainApp()),
        );
      });
    }

    return Scaffold(
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            CircularProgressIndicator(),
            SizedBox(height: 16),
            if (dbReady) Text('✓ Database ready'),
            if (configReady) Text('✓ Configuration loaded'),
            if (!dbReady || !configReady) Text('Initializing...'),
          ],
        ),
      ),
    );
  }
}

Contrast with callOnce:

  • callOnce(): Runs immediately during build (synchronous)
  • callOnceAfterThisBuild(): Runs after build completes (post-frame callback)

callAfterEveryBuild() ​

Execute a callback after every build. The callback receives a cancel() function to stop future invocations.

Method signature:

dart
void callAfterEveryBuild(
  void Function(BuildContext context, void Function() cancel) callback
);

Use cases:

  • Update scroll position after rebuilds
  • Reposition overlays or tooltips
  • Perform measurements after layout changes
  • Sync animations with rebuild state

Example - Scroll to top with cancel:

dart
class ScrollToTopWidget extends StatelessWidget with WatchItMixin {
  final ScrollController scrollController = ScrollController();

  @override
  Widget build(BuildContext context) {
    final counter = watch(counterNotifier);

    // Scroll to top after every build where counter changes
    // The cancel function allows stopping the callback when needed
    callAfterEveryBuild((context, cancel) {
      if (counter.value > 5) {
        // Stop calling this callback after counter reaches 5
        cancel();
        return;
      }

      // Scroll to top after each rebuild
      if (scrollController.hasClients) {
        scrollController.animateTo(
          0,
          duration: Duration(milliseconds: 300),
          curve: Curves.easeOut,
        );
      }
    });

    return Scaffold(
      appBar: AppBar(title: Text('Scroll Example')),
      body: ListView.builder(
        controller: scrollController,
        itemCount: 50,
        itemBuilder: (context, index) => ListTile(
          title: Text('Item $index - Counter: ${counter.value}'),
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: counterNotifier.increment,
        child: Icon(Icons.add),
      ),
    );
  }
}

Important:

  • Callback executes after EVERY rebuild
  • Use cancel() to stop when no longer needed
  • Runs in post-frame callback (after layout completes)

createOnce and createOnceAsync ​

Create an object on the first build that is automatically disposed when the widget is destroyed. Ideal for all types of controllers (TextEditingController, AnimationController, ScrollController, etc.) or reactive local state (ValueNotifier, ChangeNotifier).

Method signatures:

dart
T createOnce<T extends Object>(
  T Function() factoryFunc,
  {void Function(T)? dispose}
);

AsyncSnapshot<T> createOnceAsync<T>(
  Future<T> Function() factoryFunc,
  {required T initialValue, void Function(T)? dispose}
);
dart
class TextFieldWithClear extends WatchingWidget {
  @override
  Widget build(BuildContext context) {
    final controller =
        createOnce<TextEditingController>(() => TextEditingController());
    return Row(
      children: [
        TextField(
          controller: controller,
        ),
        ElevatedButton(
          onPressed: () => controller.clear(),
          child: const Text('Clear'),
        ),
      ],
    );
  }
}

How it works:

  • On first build, the object is created with factoryFunc
  • On subsequent builds, the same instance is returned
  • When the widget is disposed:
    • If the object has a dispose() method, it's called automatically
    • If you need a different dispose function (like cancel() on StreamSubscription), pass it as the dispose parameter

Creating local state with ValueNotifier:

dart
class CounterWidget extends WatchingWidget {
  const CounterWidget({super.key});

  @override
  Widget build(BuildContext context) {
    // Create a local notifier that persists across rebuilds
    final counter = createOnce(() => ValueNotifier<int>(0));

    // Watch it directly (not from get_it)
    final count = watch(counter).value;

    return Column(
      children: [
        Text('Count: $count'),
        ElevatedButton(
          onPressed: () => counter.value++,
          child: Text('Increment'),
        ),
      ],
    );
  }
}

Alternative: Cached Factories with Parameters ​

createOnce ties the object to one widget instance. When you need one manager per entity (per station, per chat, per product id) that can also be shared by other widgets or accessed outside the widget tree, register it in get_it with registerCachedFactoryParam and pass the parameter to the watch function:

dart
/// One manager per station - created on demand, kept while in use.
class StationManager {
  StationManager(this.stationId);

  final String stationId;
  final name = ValueNotifier<String>('');

  Future<void> load() async {
    // fetch station details...
    name.value = 'Station $stationId';
  }
}

void registerStationManager() {
  di.registerCachedFactoryParam<StationManager, String, void>(
    (stationId, _) => StationManager(stationId)..load(),
  );
}

Compare the two ways of using it in a widget:

With createOnce - the manager belongs to this widget:

dart
class StationTileCreateOnce extends WatchingWidget {
  const StationTileCreateOnce({super.key, required this.stationId});

  final String stationId;

  @override
  Widget build(BuildContext context) {
    // Manager lives and dies with this widget instance
    final manager = createOnce(() => StationManager(stationId)..load());
    final name = watch(manager.name).value;
    return Text(name);
  }
}

With registerCachedFactoryParam + param1 - the manager is resolved from get_it:

dart
class StationTileCachedFactory extends WatchingWidget {
  const StationTileCachedFactory({super.key, required this.stationId});

  final String stationId;

  @override
  Widget build(BuildContext context) {
    // Manager is resolved from get_it with the parameter; if stationId
    // changes, watch_it switches to the new instance automatically
    final name = watchValue(
      (StationManager m) => m.name,
      param1: stationId,
    );
    return Text(name);
  }
}
createOnceregisterCachedFactoryParam + param1
OwnershipThe widget - disposed with itget_it - kept while any reference exists, garbage-collected afterwards
Shared between widgetsNo, every widget gets its ownYes, same parameter → same instance while it is alive
Parameter changesNeeds a new widget instance (key)watch_it unsubscribes from the old instance and subscribes to the new one
Accessible outside widgetsNoYes, di<StationManager>(param1: id)

param1/param2 are available on all watch and handler functions that resolve their object from get_it (watchIt, watchValue, watchPropertyValue, watchStream, watchFuture, registerHandler, ...). They are only valid for types registered with registerCachedFactoryParam; passing them for another registration type throws in debug mode. Plain registerFactory registrations can't be watched at all - every build would get a new instance - and are rejected in debug mode as well.

Learn more about cached factories in get_it →

createOnceAsync ​

Ideal for one-time async function calls to display data, for instance from some backend endpoint.

Full signature:

dart
AsyncSnapshot<T> createOnceAsync<T>(
  Future<T> Function() factoryFunc,
  {required T initialValue, void Function(T)? dispose}
);

How it works:

  • Returns AsyncSnapshot<T> immediately with initialValue
  • Executes factoryFunc asynchronously on first build
  • Widget rebuilds automatically when the future completes
  • AsyncSnapshot contains the state (loading, data, error)
  • Object is disposed when widget is destroyed
dart
class UserDataWidget extends WatchingWidget {
  const UserDataWidget({super.key});

  @override
  Widget build(BuildContext context) {
    // Fetch data once on first build
    final snapshot = createOnceAsync(
      () => di<BackendService>().fetchUserData(),
      initialValue: '',
    );

    // Display based on AsyncSnapshot state
    if (snapshot.connectionState == ConnectionState.waiting) {
      return const CircularProgressIndicator();
    }

    if (snapshot.hasError) {
      return Text('Error: ${snapshot.error}');
    }

    return Text(snapshot.data ?? 'No data');
  }
}

Released under the MIT License. Built by Thomas Burkhart.