states_rebuilder

pub package CircleCI codecov

A Flutter state management combined with a dependency injection solution to get the best experience with state management.

  • Performance

    • Strictly rebuild control
    • Auto clean state when not used
    • Immutable / Mutable states support
  • Code Clean

    • Zero Boilerplate
    • No annotation & code-generation
    • Separation of UI & business logic
    • Achieve business logic in pure Dart.
  • User Friendly

    • Built-in dependency injection system
    • SetState in StatelessWidget.
    • Hot-pluggable Stream / Futures
    • Easily Undo / Redo
    • Navigate, show dialogs without BuildContext
    • Easily persist the state and retrieve it back
    • Override the state for a particular widget tree branch (widget-wise state)
  • Maintainable

    • Easy to test, mock the dependencies
    • Built-in debugging print function
    • Capable for complex apps

A Quick Tour of global functional injection (Newer Approach)

Start by your business logic. Use plain old vanilla dart class only.

class MyModel(){
  //Can return any type, futures and streams included
  someMethod(){ ... }
}
  • To inject it following functional injection approach:

    //Can be defined globally, as a class field or even inside the build method.
    final model = RM.inject<MyModel>(
        ()=>MyModel(),
        //After initialized, it preserves the state it refers to until it is disposed
        onInitialized : (MyModel state) => print('Initialized'),
        //Default callbacks for side effects.
        onWaiting : () => print('Is waiting'),
        hasData : (MyModel data) => print('Has data'),
        hasError : (error, stack) => print('Has error'),
        //It is disposed when no longer needed
        onDisposed: (MyModel state) => print('Disposed'),
    );
    
  • To mock it in test:

    model.injectMock(()=> MyMockModel());
    //You can even mock the mocked implementation
    

    Similar to RM.inject there are:

    RM.injectFuture//For Future, 
    RM.injectStream,//For Stream,
    RM.injectComputed//depends on other injected Models and watches them.
    RM.injectFlavor// For flavor and development environment
    
  • To listen to an injected model from the User Interface:

    • Rebuild when model has data only:
      model.rebuilder(()=> Text('${model.state}')); 
      
      -Handle all possible async status:
      model.whenRebuilder(
          isIdle: ()=> Text('Idle'),
          isWaiting: ()=> Text('Waiting'),
          hasError: ()=> Text('Error'),
          hasData: ()=> Text('Data'),
      )
      
  • To listen to many injected models and exposes and merged state:

      [model1, model1 ..., modelN].whenRebuilder(
          isWaiting: ()=> Text('Waiting'),//If any is waiting
          hasError: ()=> Text('Error'),//If any has error
          isIdle: ()=> Text('Idle'),//If any is Idle
          hasData: ()=> Text('Data'),//All have Data
      )
    
  • To mutate the state and notify listener:

    //Direct mutation
    model.state= newState;
    //or for more options
    model.setState(
      (s)=> s.someMethod()//can be sync or async
      debounceDelay=500,
    );
    
  • To undo and redo immutable state:

    model.undoState();
    model.redoState();
    
  • To navigate, show dialogs and snackBars without BuildContext:

    RM.navigate.to(HomePage());
    
    RM.navigate.toDialog(AlertDialog( ... ));
    
    RM.scaffoldShow.snackbar(SnackBar( ... ));
    
  • To Persist the state and retrieve it when the app restarts,

    final model = RM.inject<MyModel>(
        ()=>MyModel(),
      persist:() => PersistState(
        key: 'modelKey',
        toJson: (MyModel s) => s.toJson(),
        fromJson: (String json) => MyModel.fromJson(json),
        //Optionally, throttle the state persistance
        throttleDelay: 1000,
      ),
    );
    

    You can manually persist or delete the state

    model.persistState();
    model.deletePersistState();
    
  • Widget-wise state (overriding the state):

final items = [1,2,3];

final item = RM.inject(()=>null);

class App extends StatelessWidget{
  build (context){
    return ListView.builder(
      itemCount: items.length,
      itemBuilder: (BuildContext context, int index) {
        return item.inherited(
          stateOverride: () => items[index],
          builder: () {

            return const ItemWidget();
            //Inside ItemWidget you can use the buildContext to get 
            //the right state for each widget branch using:
            item.of(context); //the Element owner of context is registered to item model.
            //or
            item(context) //the Element owner of context is notregistered to item model.
          }
        );
      },
    );
  }
}

And many more features.

🚀 To see global functional injection in action and feel how easy and efficient it is, please refer to this tutorial Global function injection from A to Z 🚀 To see how to navigate and show dialogs, menus, bottom sheets, and snackBars without BuildContext, please refer to this document Navigate and show dialogs, menus, bottom sheets, and snackBars without BuildContext 🚀 To see how to how to persist the state and retrieve it on app restart, please refer to this document Navigate and show dialogs, menus, bottom sheets and snackBars without BuildContext

Here are the two most important examples that detail the concepts of states_rebuilder with global functional injection and highlight where states_rebuilder shines compared to existing state management solutions.

  1. Example 1. TODO MVC example based on the Flutter architecture examples extended to account for dynamic theming and app localization. The state will be persisted locally using Hive, SharedPreferences, and Sqflite.
  2. Example 2 The same examples as above adding the possibility for a user to sin up and log in. A user will only see their own todos. The log in will be made with a token which, once expired, the user will be automatically disconnected.

An Example of widget-wise injection using Injector (Older approach).

Here is a typical class, that encapsulated, all the type of method mutation one expects to find in real-life situations.

class Model {
  int counter;

  Model(this.counter);

  //1- Synchronous mutation
  void incrementMutable() {
    counter++;
  }

  Model incrementImmutable() {
    //fields should be final,
    //immutable returns a new instance based on the current state
    return Model(counter + 1);
  }

  //2- Async mutation Future
  Future<void> futureIncrementMutable() async {
    //Pessimistic 😢: wait until future completes without error to increment
    await Future.delayed(Duration(seconds: 1));
    if (Random().nextBool()) {
      throw Exception('ERROR 😠');
    }
    counter++;
  }

  Future<Model> futureIncrementImmutable() async {
    await Future.delayed(Duration(seconds: 1));
    if (Random().nextBool()) {
      throw Exception('ERROR 😠');
    }
    return Model(counter + 1);
  }

  //3- Async Stream
  Stream<void> streamIncrementMutable() async* {
    //Optimistic 😄: start incrementing and if the future completes with error
    //go back the the initial state
    final oldState = counter;
    yield counter++;

    await Future.delayed(Duration(seconds: 1));
    if (Random().nextBool()) {
      yield counter = oldState;
      throw Exception('ERROR 😠');
    }
  }

  Stream<Model> streamIncrementImmutable() async* {
    yield Model(counter + 1);

    await Future.delayed(Duration(seconds: 1));
    if (Random().nextBool()) {
      yield this;
      throw Exception('ERROR 😠');
    }
  }
}

The Model class is a pure dart class without any reference to any external library (even Flutter). In the Model class we have :

  • Sync mutation of state (mutable and immutable);
  • Async future mutation of the state (mutable and immutable);
  • Async stream mutation of the state (mutable and immutable);

Async mutation is whether :

  • Pessimistic so that we must await it to complete and display an awaiting screen.
  • Optimistic so that we just display what we expect from it, and execute it in the background. It is until it fails that we go back to the old state and display an error message.

states_rebuilder manage all that with the same easiness.

The next step is to inject the Model class into the widget tree.

class App extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    
    return Injector(
      //Inject Model instance into the widget tree.
      inject: [Inject(() => Model(0))],
      builder: (context) {
        return MaterialApp(
          home: MyHome(),
        );
      },
    );
  }
}

Get more information on Injector.

To get the injected model at any level of the widget tree, we use:

//get the injected instance
final Model model = IN.get<Model>(); 
//get the injected instance decorated with a ReactiveModel
final ReactiveModel<Model> modelRM =  RM.get<Model>();

The next step is to subscribe to the injected ReactiveModel and mutate the state and notify observers.

  • subscription is done using one of the four observer widgets : StateBuilder, WhenRebuilder, WhenRebuilderOr, OnSetStateListener.
  • notification is done mainly with setState method.

setState can do all:

  @override
  Widget build(BuildContext context) {
    //StateBuilder is one of four observer widgets
    return StateBuilder<Model>(
      //get the ReactiveModel of the injected Model instance,
      //and subscribe this StateBuilder to it.
      observe: () => RM.get<Model>(),
      builder: (context, modelRM) {
        //The builder exposes the BuildContext and the Model ReactiveModel
        return Row(
          children: [
            //get the state of the model
            Text('${modelRM.state.counter}'),
            RaisedButton(
              child: Text('Increment (SetStateCanDoAll)'),
              onPressed: () async {
                //setState treats mutable and immutable objects equally
                modelRM.setState(
                  //mutable state mutation
                  (currentState) => currentState.incrementMutable(),
                );
                modelRM.setState(
                  //immutable state mutation
                  (currentState) => currentState.incrementImmutable(),
                );

                //await until the future completes
                await modelRM.setState(
                  //await for the future to complete and notify observer with
                  //the corresponding connectionState and data
                  //future will be canceled if all observer widgets are removed from
                  //the widget tree.
                  (currentState) => currentState.futureIncrementMutable(),
                );
                //
                await modelRM.setState(
                  (currentState) => currentState.futureIncrementImmutable(),
                );

                //await until the stream is done
                await modelRM.setState(
                  //subscribe to the stream and notify observers.
                  //stream subscription are canceled if all observer widget are removed
                  //from the widget tree.
                  (currentState) => currentState.streamIncrementMutable(),
                );
                //
                await modelRM.setState(
                  (currentState) => currentState.streamIncrementImmutable(),
                );
                //setState can do all; mutable, immutable, sync, async, futures or streams.
              },
            )
          ],
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
        );
      },
    );
  }

No matter you deal with mutable or immutable objects, your method is sync or async, you use future or stream, setState can handle each case to mutate the state and notify listeners.

Get more information on setState method
Get more information on StateBuilder method

Pessimistic future

One common use case is to fetch some data from a server. In this case we may want to display a CircularProgressIndicator while awaiting for the future to complete.

  @override
  Widget build(BuildContext context) {
    return Row(
      children: [
        //WhenRebuilder is the second of the four observer widgets
        WhenRebuilder<Model>(
          //subscribe to the global ReactiveModel
          observe: () => RM.get<Model>(),
          onSetState: (context, modelRM) {
            //side effects here
            modelRM.whenConnectionState(
              onIdle: () => print('Idle'),
              onWaiting: () => print('onWaiting'),
              onData: (data) => print('onData'),
              onError: (error) => print('onError'),
            );
          },
          onIdle: () => Text('The state is not mutated at all'),
          onWaiting: () => Text('Future is executing, we are waiting ....'),
          onError: (error) => Text('Future completes with error $error'),
          onData: (Model data) => Text('${data.counter}'),
        ),
        RaisedButton(
          child: Text('Increment'),
          onPressed: () {
            //All other widget subscribe to the global ReactiveModel will be notified to rebuild
            RM.get<Model>().setState(
                  (currentState) => currentState.futureIncrementImmutable(),
                  //will await the current future if its pending
                  //before calling futureIncrementImmutable
                  shouldAwait: true,
                );
          },
        )
      ],
      mainAxisAlignment: MainAxisAlignment.spaceBetween,
    );
  }

The futureIncrementImmutable (or futureIncrementMutable) is trigger in response to a button click. We can trigger the async method automatically once the widget is inserted into the widget tree:

  @override
  Widget build(BuildContext context) {
    return StateBuilder(
      //Create a local ReactiveModel model that decorate the false value
      observe: () => RM.create<bool>(false),
      builder: (context, switchRM) {
        //builder expose the BuildContext and the locally created ReactiveModel.
        return Row(
          children: [
            if (switchRM.state)
              WhenRebuilder<Model>(
                //get the global ReactiveModel and call setState
                //All other widget subscribed to this global ReactiveModel will be notified
                observe: () => RM.get<Model>()
                  ..setState(
                    (currentState) {
                      return currentState.futureIncrementImmutable();
                    },
                  ),
                onSetState: (context, modelRM) {
                  //side effects
                  if (modelRM.hasError) {
                    //show a SnackBar on error
                    Scaffold.of(context).hideCurrentSnackBar();
                    Scaffold.of(context).showSnackBar(
                      SnackBar(
                        content: Text('${modelRM.error}'),
                      ),
                    );
                  }
                },
                onIdle: () => Text('The state is not mutated at all'),
                onWaiting: () =>
                    Text('Future is executing, we are waiting ....'),
                onError: (error) => Text('Future completes with error $error'),
                onData: (Model data) => Text('${data.counter}'),
              )
            else
              Container(),
            RaisedButton(
              child: Text(
                  '${switchRM.state ? "Dispose" : "Insert"}'),
              onPressed: () {
                //mutate the state of the local ReactiveModel directly
                //without using setState although we can.
                //setState gives us more features that we do not need here
                switchRM.state = !switchRM.state;
              },
            )
          ],
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
        );
      },
    );
  }

In the example above, we first created a local ReactiveModel of type bool and initialize it with false.

In states_rebuilder there are Global ReactiveModel (injected using Injector) and local ReactiveModel (created locally).

Get more information on global and local ReactiveModel method Get more information on WhenRebuilder

Once a global model emits a notification, all widget subscribed to it will be notified.

To limit the notification to one widget we can use the future method.

  @override
  Widget build(BuildContext context) {
    return StateBuilder(
        observe: () => RM.create(false),
        builder: (context, switchRM) {
          return Row(
            children: [
              if (switchRM.state)
                WhenRebuilder<Model>(
                  //Here use the future method to create new reactive model
                  observe: () => RM.get<Model>().future(
                    (currentState, stateAsync) {
                      //future method exposed the current state and teh Async representation of the state
                      return currentState.futureIncrementImmutable();
                    },
                  ),
                  ////This is NOT equivalent to this :
                  //// observe: () => RM.future(
                  ////   IN.get<Model>().futureIncrementImmutable(),
                  //// ),

                  onIdle: () => Text('The state is not mutated at all'),
                  onWaiting: () =>
                      Text('Future is executing, we are waiting ....'),
                  onError: (error) =>
                      Text('Future completes with error $error'),
                  onData: (Model data) => Text('${data.counter}'),
                )
              else
                Text('This widget will not affect other widgets'),
              RaisedButton(
                child: Text(
                    '${switchRM.state ? "Dispose" : "Insert"}'),
                onPressed: () {
                  switchRM.state = !switchRM.state;
                },
              )
            ],
            mainAxisAlignment: MainAxisAlignment.spaceBetween,
          );
        },
    );
  }

Get more information on future method

Optimistic update

One might be able to predict the outcome of an async operation. If so, we can implement optimistic updates by displaying the expected data when starting the async action and execute the async task in the background and forget about it. It is only if the async task fails that we want to go back to the last state and notify the user of the error.

  @override
  Widget build(BuildContext context) {
    return Row(
      children: [
        //WhenRebuilderOr is the third observer widget
        WhenRebuilderOr<Model>(
          observe: () => RM.get<Model>(),
          onWaiting: () => Text('Future is executing, we are waiting ....'),
          builder: (context, modelRM) => Text('${modelRM.state.counter}'),
        ),
        RaisedButton(
          child: Text('Increment'),
          onPressed: () {
            RM.get<Model>().setState(
              //stream is the choice for optimistic update
              (currentState) => currentState.streamIncrementMutable(),
              //debounce setState for 1 second
              debounceDelay: 1000,
              onError: (context, error) {
                Scaffold.of(context).hideCurrentSnackBar();
                Scaffold.of(context).showSnackBar(
                  SnackBar(
                    content: Text('$error'),
                  ),
                );
              },
            );
          },
        )
      ],
      mainAxisAlignment: MainAxisAlignment.spaceBetween,
    );
  }

Get more information on WhenRebuilderOr method

If we want to automatically call the streamIncrementMutable once the widget is inserted into the widget tree:

  Widget build(BuildContext context) {
    return StateBuilder(
      observe: () => RM.create(false),
      builder: (context, switchRM) {
        return Row(
          children: [
            if (switchRM.state)
              WhenRebuilderOr<Model>(
                //Create a new ReactiveModel with the stream method.

                observe: () => RM.get<Model>().stream((state, subscription) {
                  //It exposes the current state and the current StreamSubscription.
                  return state.streamIncrementImmutable();
                }),

                ////This is NOT equivalent to this : 
                //// observe: () => RM.stream(
                ////   IN.get<Model>().streamIncrementImmutable(),
                //// ),
                ///
                onSetState: (context, modelRM) {
                  if (modelRM.hasError) {
                    Scaffold.of(context).hideCurrentSnackBar();
                    Scaffold.of(context).showSnackBar(
                      SnackBar(
                        content: Text('${modelRM.error}'),
                      ),
                    );
                  }
                },
                builder: (context, modelRM) {
                  return Text('${modelRM.state.counter}');
                },
              )
            else
              Text('This widget will not affect other widgets'),
            RaisedButton(
              child: Text(
                  '${switchRM.state ? "Dispose" : "Insert"} (OptimisticAsyncOnInitState)'),
              onPressed: () {
                switchRM.state = !switchRM.state;
              },
            )
          ],
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
        );
      },
    );
  }

Get the full working example

For more information about how to use states_rebuilder see in the wiki :

Libraries

states_rebuilder