Skip to content

Instantly share code, notes, and snippets.

@HenriqueNas
Last active July 3, 2025 15:58
Show Gist options
  • Save HenriqueNas/dedb6e6523f1ba03acedf780d2c89ef9 to your computer and use it in GitHub Desktop.
Save HenriqueNas/dedb6e6523f1ba03acedf780d2c89ef9 to your computer and use it in GitHub Desktop.
Easiest way to scale your management of your Flutter page/widget state without any package.
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
/// Defines the possible states of a UI page or widget.
///
/// This enum helps to clearly represent different stages of a UI,
/// such as waiting for data (`loading`), displaying content (`success`),
/// or indicating an issue (`error`).
enum PageState {
/// The initial or default state.
/// No operation is currently in progress, and no error has occurred.
idle,
/// Indicates that an asynchronous operation is in progress,
/// such as fetching data from an API.
loading,
/// Indicates that an error has occurred during an operation.
/// An associated error object can provide more details.
error,
/// Indicates that an operation has completed successfully.
success;
/// Returns `true` if the current state is [PageState.loading].
bool get isLoading => this == loading;
/// Returns `true` if the current state is [PageState.error].
bool get isError => this == error;
/// Returns `true` if the current state is [PageState.success].
bool get isSuccess => this == success;
/// Returns `true` if the current state is [PageState.idle].
bool get isIdle => this == idle;
}
/// A `ChangeNotifier` that helps manage and communicate the state
/// of a UI page or widget throughout its lifecycle.
///
/// This class simplifies state management by providing a clear way
/// to set and react to different `PageState` values. It's ideal for
/// scenarios where a UI needs to respond to asynchronous operations
/// (e.g., data loading, form submissions) by displaying appropriate
/// visual feedback (loading indicators, error messages, success views).
///
/// Type parameter `Error`: The type of the error object that can be
/// stored when the state is [PageState.error].
///
/// Example Usage:
/// ```dart
/// class MyPage extends StatefulWidget {
/// @override
/// _MyPageState createState() => _MyPageState();
/// }
///
/// class _MyPageState extends State<MyPage> {
/// final pageStateNotifier = PageStateNotifier<String>(); // Error type is String
///
/// @override
/// void initState() {
/// super.initState();
/// // Listen for state changes to perform side effects
/// pageStateNotifier.onSuccess(() {
/// ScaffoldMessenger.of(context).showSnackBar(
/// SnackBar(content: Text('Operation successful!')),
/// );
/// });
/// pageStateNotifier.onError((error) {
/// ScaffoldMessenger.of(context).showSnackBar(
/// SnackBar(content: Text('Error: $error')),
/// );
/// });
/// }
///
/// Future<void> _fetchData() async {
/// return Future.value();
/// }
///
/// @override
/// Widget build(BuildContext context) {
/// return Scaffold(
/// appBar: AppBar(title: Text('State Notifier Example')),
/// body: Center(
/// child: Column(
/// mainAxisAlignment: MainAxisAlignment.center,
/// children: [
/// // Use stateBuilder to render different UI based on the PageState
/// pageStateNotifier.stateBuilder(
/// idle: () => Text('Press button to load data'),
/// loading: () => CircularProgressIndicator(),
/// success: () => Text('Data loaded!'),
/// error: (error) => Text('Failed: $error'),
/// ),
/// SizedBox(height: 20),
/// _pageDataNotifier.builder((state) {
/// return ElevatedButton(
/// onPressed: state.isLoading ? null : _fetchData,
/// child: state.isLoading ? Text('Loading...') : Text('Load Data'),
/// );
/// });
/// ],
/// ),
/// ),
/// );
/// }
///
/// @override
/// void dispose() {
/// pageStateNotifier.dispose();
/// super.dispose();
/// }
/// }
/// ```
class PageStateNotifier<Error extends Object?> extends ChangeNotifier {
PageState _state = PageState.idle;
/// The current [PageState] of the notifier.
///
/// This getter provides the current state, which should be used
/// to control the UI's appearance and behavior.
PageState get state => _state;
void _setState(final PageState state) {
_state = state;
notifyListeners();
}
/// Sets the current state to [PageState.success] and notifies
/// all registered listeners, triggering UI updates.
void setSuccess() => _setState(PageState.success);
/// Returns `true` if the current [state] is [PageState.success].
bool get isSuccess => _state.isSuccess;
Error? _error;
/// Sets an error object and transitions the state to [PageState.error].
///
/// This method notifies all registered listeners, enabling the UI
/// to display error messages or appropriate feedback.
///
/// `error`: An optional error object. If not provided, the existing
/// error (if any) will remain, or it will be set to `null`.
void setError([final Error? error]) {
_error = error;
_setState(PageState.error);
}
/// The error object associated with the [PageState.error] state.
///
/// If the current `state` is [PageState.error], this getter returns
/// the error object that was set. Otherwise, it returns `null`.
Error? get error => _error;
/// Returns `true` if the current [state] is [PageState.error].
bool get isError => _state.isError;
/// Returns `true` if the current [state] is [PageState.idle].
bool get isIdle => _state.isIdle;
/// Returns `true` if the current [state] is [PageState.loading].
bool get isLoading => _state.isLoading;
/// Sets the current state to [PageState.idle] and notifies
/// all registered listeners.
///
/// Use this to reset the state to its initial or inactive status.
void setIdle() => _setState(PageState.idle);
/// Sets the current state to [PageState.loading] and notifies
/// all registered listeners.
///
/// Use this when an asynchronous operation begins.
void setLoading() => _setState(PageState.loading);
/// Registers a callback to be executed specifically when the
/// `state` changes to [PageState.error].
///
/// The provided `callback` will receive the current `error` object.
/// This is useful for displaying error messages or performing error-specific
/// actions.
///
/// `callback`: A function that takes an `Error?` object.
void onError(final void Function([Error? error]) callback) => addListener(() {
if (isError) callback(error);
});
/// Registers a callback to be executed specifically when the
/// `state` changes to [PageState.success].
///
/// This is useful for navigating, showing success messages, or
/// performing other actions upon successful completion.
///
/// `callback`: A function that takes no arguments.
void onSuccess(final VoidCallback callback) => addListener(() {
if (isSuccess) callback();
});
/// Registers a callback to be executed specifically when the
/// `state` changes to [PageState.loading].
///
/// This is useful for displaying loading indicators or preventing
/// multiple concurrent operations.
///
/// `callback`: A function that takes no arguments.
void onLoading(final VoidCallback callback) => addListener(() {
if (isLoading) callback();
});
/// Registers a callback to be executed specifically when the
/// `state` changes to [PageState.idle].
///
/// This can be used to reset UI elements or perform actions
/// when the page returns to an inactive state.
///
/// `callback`: A function that takes no arguments.
void onIdle(final VoidCallback callback) => addListener(() {
if (isIdle) callback();
});
/// Creates a [ListenableBuilder] that rebuilds its UI whenever
/// the `PageStateNotifier`'s `state` changes.
///
/// This is a generic builder that provides the current `PageState`
/// to its `builder` function, allowing for flexible UI construction.
///
/// `builder`: A function that receives the current [PageState]
/// and returns a [Widget].
Widget builder(Widget Function(PageState state) builder) => ListenableBuilder(
listenable: this,
builder: (final context, final _) => builder(state),
);
/// Creates a [ListenableBuilder] that renders different widgets
/// based on the current [PageState].
///
/// This method provides a convenient way to define separate UI components
/// for each state ([idle], [loading], [success], [error]), making it
/// easy to manage complex UI flows.
///
/// `idle`: A widget builder for the [PageState.idle] state. Defaults
/// to `SizedBox.shrink()`.
/// `loading`: An optional widget builder for the [PageState.loading] state.
/// `success`: An optional widget builder for the [PageState.success] state.
/// `error`: An optional widget builder for the [PageState.error] state.
/// It receives the current `error` object.
/// `fallbackState`: The state to fall back to if a specific state's
/// widget builder is not provided and that state becomes active.
/// Assertions ensure that a widget builder is provided if `fallbackState`
/// is set to `loading`, `error`, or `success`.
///
/// Example:
/// ```dart
/// pageStateNotifier.stateBuilder(
/// idle: () => Text('Ready to start'),
/// loading: () => CircularProgressIndicator(),
/// success: () => Icon(Icons.check_circle),
/// error: (e) => Text('Error: ${e ?? 'Unknown error'}'),
/// );
/// ```
Widget stateBuilder({
final Widget Function() idle = SizedBox.shrink,
final Widget Function()? loading,
final Widget Function()? success,
final Widget Function(Error? error)? error,
final PageState fallbackState = PageState.idle,
}) {
assert(
!fallbackState.isLoading || loading != null,
'Your fallbackState is loading, but you did not provide a loading widget',
);
assert(
!fallbackState.isError || error != null,
'Your fallbackState is error, but you did not provide an error widget',
);
assert(
!fallbackState.isSuccess || success != null,
'Your fallbackState is success, but you did not provide a success widget',
);
return ListenableBuilder(
listenable: this,
builder: (final context, final _) {
final widgetBuilder = switch (_state) {
PageState.idle => idle(),
PageState.loading => loading?.call(),
PageState.success => success?.call(),
PageState.error => error?.call(_error),
};
if (widgetBuilder == null) {
return switch (fallbackState) {
PageState.idle => idle(),
PageState.loading => loading!.call(),
PageState.error => error!.call(_error),
PageState.success => success!.call(),
};
}
return widgetBuilder;
},
);
}
}
@HenriqueNas
Copy link
Author

HenriqueNas commented Jul 4, 2024

how to use it:

Usually you will create a "controller" (or whatever you want to call it) that extends the PageStateNotifier and you can bind to the page that you want to control!

Example:

class HomeController extends PageStateNotifier<String> {
  // you can use a `ValueNotifier<String?>` to make this reactable too !!
  String? userName;

  Future<void> fetch() async {
    try {
      setLoading();

      final result = await Future.delayed(const Duration(seconds: 2), () {
        final isSuccess = Random().nextBool();

        if (isSuccess) {
          return 'Success';
        } else {
          throw 'Error';
        }
      });

      userName = result;

      setSuccess();
    } catch (error) {
      setError(error.toString());
    }
  }
}

your page:

your page can build widgets with the builder and stateBuilder methods of your HomeController:

class HomePage extends StatefulWidget {
  const HomePage({super.key});

  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  final controller = HomeController();

  @override
  void initState() {
    super.initState();

    // when the state is set to Error, call this:
    controller.onError((final error) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text(error ?? 'Unknown error')),
      );
    });
  }

  @override
  Widget build(final BuildContext context) {
    return controller.stateBuilder(
      idle: () => Center(
        child: ElevatedButton(
          onPressed: controller.fetch,
          child: const Text('Fetch'),
        ),
      ),
      loading: () => const Center(child: CircularProgressIndicator()),
      success: () => Center(child: Text(controller.userName ?? 'No user name')),
    );
  }
}

@lucasdealmeida91
Copy link

otima abordagem

@HenriqueNas
Copy link
Author

HenriqueNas commented Dec 30, 2024

a mixin aggregation example:

you can reuse UserMixin in any class that extends PageStateNotifier,
and any controllers can have many mixins it needs

class AuthController extends PageStateNotifier with UserMixin {
  void createNewUser({
    required String email, 
    required String password,
  }) {
    setEmail(email); // or `this.name = name;` by using setter
    setPassword(password);
  }
}

mixin UserMixin on PageStateNotifier {
  String? _email;
  String? get email => _email;
  bool get isEmailValid => email != null && email!.isNotEmpty;

  String? _password;
  String? get password => _password;
  bool get isPasswordValid => password != null && password!.isNotEmpty;

  bool get isUserValid => isEmailValid && isPasswordValid;

  // same as `setEmail` but using setter 
  set email(String? email) {
    _email = email;
    notifyListeners();
  }

  void setEmail(String email) {
    _email = email;
    notifyListeners();
  }

  void setPassword(String password) {
    _password = password;
    notifyListeners();
  }
}

with multiples mixins:

class AuthController extends PageStateNotifier with UserMixin, AccountMixin, ExampleMixin {}

@HenriqueNas
Copy link
Author

HenriqueNas commented Dec 30, 2024

other utils

ComputedNotifier:

ComputedNotifier lets you combine the values of multiple listenables into a new ValueListenable that can be reacted to, or even used by additional ComputedNotifier.

import 'dart:async';

import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';

/// Provides utility methods for any object that implements [Listenable].
///
/// This extension simplifies common operations with listenable objects,
/// especially in the context of Flutter widgets.
extension ListenableUtils on Listenable {
  /// Creates a [ListenableBuilder] that rebuilds its UI whenever this
  /// [Listenable] notifies its listeners.
  ///
  /// This is a convenient way to react to changes from any [Listenable]
  /// (e.g., `ChangeNotifier`, `AnimationController`) and update a part
  /// of the widget tree.
  ///
  /// Example:
  /// ```dart
  /// final myNotifier = ChangeNotifier();
  /// // In your build method:
  /// myNotifier.builder(() {
  ///   return Text('Notifier updated!');
  /// });
  /// ```
  ///
  /// `builder`: A function that returns the [Widget] to be built
  /// when the listenable changes.
  Widget builder(final Widget Function() builder) {
    return ListenableBuilder(
      listenable: this,
      builder: (context, _) {
        return builder();
      },
    );
  }

  /// Creates a [ComputedNotifier] that observes changes in this [Listenable]
  /// and recomputes a derived value.
  ///
  /// This is useful for creating "computed" or "derived" states that
  /// automatically update when their dependencies change. The `value`
  /// of the returned `ComputedNotifier` will be re-evaluated whenever
  /// this `Listenable` notifies.
  ///
  /// Example:
  /// ```dart
  /// final counter = ValueNotifier<int>(0);
  /// final isEven = counter.computed(() => counter.value % 2 == 0);
  ///
  /// // In your build method:
  /// isEven.builder((value) => Text('Is counter even? ${value.toString()}'));
  /// // When counter changes, isEven will automatically recompute and update.
  /// ```
  ///
  /// `value`: A function that computes the derived value `T`. This function
  /// will be re-executed when this `Listenable` changes.
  ///
  /// Returns a [ComputedNotifier] of type `T`.
  ValueListenable computed<T>(T Function() value) {
    return ComputedNotifier<T>([this], value);
  }
}

/// A [ValueListenable] that recomputes its value based on changes
/// in a list of other [Listenable] dependencies.
///
/// `ComputedNotifier` acts like a reactive variable. When any of its
/// registered `listenableList` dependencies notify a change, it re-evaluates
/// its `_compute` function. If the recomputed value is different from
/// the previous one, it then notifies its own listeners.
/// This is a powerful pattern for creating derived state that automatically
/// stays in sync with its sources.
///
/// Type parameter `T`: The type of the value that this `ComputedNotifier` holds.
class ComputedNotifier<T> extends ChangeNotifier implements ValueListenable<T> {
  /// Creates a `ComputedNotifier`.
  ///
  /// `listenableList`: A list of [Listenable] objects that this
  /// `ComputedNotifier` will observe. Any change in these listenables
  /// will trigger a re-computation of this notifier's value.
  /// `_compute`: A function that defines how the value of this notifier
  /// is derived. This function is called initially and then whenever
  /// any of the `listenableList` dependencies change.
  ComputedNotifier(List<Listenable> listenableList, this._compute)
      : _currentValue = _compute() {
    _listenable = Listenable.merge(listenableList);
    _listenable.addListener(_onDependenciesChanged);

    // Enables Flutter DevTools memory allocation tracking if available.
    if (kFlutterMemoryAllocationsEnabled) {
      ChangeNotifier.maybeDispatchObjectCreation(this);
    }
  }

  /// The function used to compute the value of this notifier.
  /// This function will be called whenever a dependency changes.
  final T Function() _compute;

  /// A merged [Listenable] that aggregates all the individual listenables
  /// provided in the constructor.
  late final Listenable _listenable;

  /// The current value held by this notifier.
  /// This value is updated when `_compute` is re-evaluated and its
  /// result differs from the previous value.
  late T _currentValue;

  /// Determines if the notifier should notify its listeners after
  /// a dependency change.
  ///
  /// This check prevents unnecessary rebuilds by only notifying if the
  /// `_compute` function returns a new value.
  bool get _shouldNotify {
    final T newValue = _compute();
    return newValue != _currentValue;
  }

  /// Callback function executed when any of the observed dependencies change.
  ///
  /// It recomputes the value and notifies listeners only if the value
  /// has actually changed.
  void _onDependenciesChanged() {
    if (_shouldNotify) {
      _currentValue = _compute();
      notifyListeners();
    }
  }

  @override
  /// The current value of this computed notifier.
  ///
  /// This value is derived from the `_compute` function and is updated
  /// automatically when dependencies change.
  T get value => _currentValue;

  @override
  /// Disposes the `ComputedNotifier` by removing its listener from
  /// the aggregated [Listenable].
  ///
  /// It's crucial to call this method when the `ComputedNotifier` is no
  /// longer needed to prevent memory leaks.
  void dispose() {
    _listenable.removeListener(_onDependenciesChanged);
    super.dispose();
  }

  @override
  /// A string representation of the `ComputedNotifier`, including its
  /// current value for debugging purposes.
  String toString() => '${describeIdentity(this)}($value)';
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment