AndriousSolutions / state_extended

The State class is extended with State object controllers and lifecycle events.
Other
4 stars 2 forks source link

setState exception occurs using notifyClients when used in streams #1

Closed jlin5 closed 11 months ago

jlin5 commented 11 months ago

Starting from v4.1.1, I've been getting the Unhandled Exception: setState() called in constructor exception when I listen to a stream in the initState of a StateXController. I think the exception occurs because notifyClients is called before the widget is mounted if the stream immediately emits an event when listened to.

I was able to resolve the issue by adding a check in the override notifyClients method to see if the _inheritedState is mounted before the setState call.

        if (_inheritedState?.mounted) {
          _inheritedState?.setState(() {});
        }

https://github.com/AndriousSolutions/state_extended/blob/9ab24a97f236ce47fa9a32da3d276215923017ff/lib/state_extended.dart#L2430

Console logs:

flutter: lastContext!.widget: MyApp
flutter: lastContext!.mounted: true
[VERBOSE-2:dart_vm_initializer.cc(41)] Unhandled Exception: setState() called in constructor: _InheritedState#1414b(lifecycle state: created, no widget, not mounted)
This happens when you call setState() on a State object for a widget that hasn't been inserted into the widget tree yet. It is not necessary to call setState() in the constructor, since the state is already assumed to be dirty when it is initially created.
#0      State.setState.<anonymous closure> (package:flutter/src/widgets/framework.dart:1126:9)
#1      State.setState (package:flutter/src/widgets/framework.dart:1137:6)
#2      AppStateX.notifyClients (package:state_extended/state_extended.dart:2430:26)
#3      StateXController.notifyClients (package:state_extended/state_extended.dart:1496:36)
flutter: lastContext!.widget: MyHomePage
flutter: lastContext!.mounted: true

Sample code:

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:state_extended/state_extended.dart';

void main() {
  runApp(const MyApp());
}

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

  @override
  State createState() => _MyAppState();
}

class _MyAppState extends AppStateX<MyApp> {
  _MyAppState() : super(controller: StreamCounterController());

  @override
  Widget buildIn(BuildContext context) {
    return const MaterialApp(
      home: MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key, required this.title});

  final String title;

  @override
  State createState() => _MyHomePageState();
}

class _MyHomePageState extends StateX<MyHomePage> {
  late StreamCounterController streamDataController;

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

    streamDataController = controllerByType<StreamCounterController>()!;
  }

  @override
  Widget build(BuildContext context) {
    streamDataController.dependOnInheritedWidget(context);

    return Scaffold(
      appBar: AppBar(title: const Text('Flutter Demo Home Page')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            const Text(
              'Stream counter:',
            ),
            Text(
              '${streamDataController.counter}',
              style: Theme.of(context).textTheme.headlineMedium,
            ),
          ],
        ),
      ),
    );
  }
}

class StreamCounterController extends StateXController {
  StreamSubscription<int>? _streamCounterSubscription;

  int _counter = 0;

  int get counter => _counter;

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

    _streamCounterSubscription = timedCounter(const Duration(seconds: 1), 3).listen((event) {
      _counter = event;

      debugPrint('lastContext!.widget: ${lastContext!.widget}');
      debugPrint('lastContext!.mounted: ${lastContext!.mounted}');

      notifyClients();
    });
  }

  @override
  void dispose() {
    _streamCounterSubscription?.cancel();

    super.dispose();
  }

  Stream<int> timedCounter(Duration interval, [int? maxCount]) {
    late StreamController<int> controller;
    Timer? timer;
    int counter = 0;

    void tick(_) {
      counter++;
      controller.add(counter);
      if (counter == maxCount) {
        timer?.cancel();
        controller.close();
      }
    }

    void startTimer() {
      timer = Timer.periodic(interval, tick);
    }

    void stopTimer() {
      timer?.cancel();
      timer = null;
    }

    controller = StreamController<int>(
      onListen: startTimer,
      onPause: stopTimer,
      onResume: startTimer,
      onCancel: stopTimer,
    );

    // Preload a value in the stream.
    controller.add(1);

    return controller.stream;
  }
}
Andrious commented 11 months ago

...just saw your issue. Allow me to read it over, and see if I can't help you. Love the fact you've provided sample code!

I suspect this may be resolved in a later version of StateX (I'm up to 4.6.1), but it doesn't hurt to first confirm the issue! Testing the sample code (using v4.1.1) now.

Andrious commented 11 months ago

You're right! It's not mounted when that routine is first assigned as a listener. Be aware that such routines are fired when first assigned as a listener. Regardless, this results in that exception you pointed out. Happily, in later versions of StateX, that exception is caught and doesn't crash the app, but that doesn't help you here and now!

Let's see if I can't help you here and now. You were very close and did nothing wrong in the code. I had to make a few changes to make it work, and maybe should have explained how things work more clearly in the StateX documentation.

First and foremost, notifyClients() would have never worked as you intended, and I replaced it with setState((){}); The StateX object didn't have its built-in InheritedWidget 'turned on' as it were with the parameter, useInherited

: super(controller: StreamCounterController(), useInherited: true) {

Further, assigning the controller to the 'App State Object' means you would have to explicitly call the home page's State object to actually see the counter incrementing:

// You've assigned the controller to the App's first State object, AppState:
_MyAppState() : super(controller: StreamCounterController());

// That means the following will have to be entered in the listener routine:
 final state = this.state?.stateByType<_MyHomePageState>();
  state?.setState(() {});

Below is the sample code now with those few changes. However, further below is the next variation where the InheritedWidget is used--- making for what I feel is a more efficient sample app. InheritedWidgets are very powerful! Far more than just used to 'bring down data from the widget tree'! They allow you to assign widgets as 'dependent clients' and only rebuild them and not the whole screen.

I would suggest you try the first version first. You'll then get a better idea of what I did with the second version below.

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:state_extended/state_extended.dart';

void main() {
  runApp(const MyApp());
}

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

  @override
  State createState() => _MyAppState();
}

class _MyAppState extends AppStateX<MyApp> {
  _MyAppState() : super(controller: StreamCounterController());

  @override
  Widget buildIn(BuildContext context) {
    return const MaterialApp(
      home: MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key, required this.title});

  final String title;

  @override
  State createState() => _MyHomePageState();
}

class _MyHomePageState extends StateX<MyHomePage> {
//  _MyHomePageState() : super(controller: StreamCounterController());
  late StreamCounterController streamDataController;

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

    streamDataController = controllerByType<StreamCounterController>()!;
  }

  @override
  Widget build(BuildContext context) {
//    streamDataController.dependOnInheritedWidget(context);

    return Scaffold(
      appBar: AppBar(title: const Text('Flutter Demo Home Page')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            const Text(
              'Stream counter:',
            ),
            Text(
              '${streamDataController.counter}',
              style: Theme.of(context).textTheme.headlineMedium,
            ),
          ],
        ),
      ),
    );
  }
}

class StreamCounterController extends StateXController {
  StreamSubscription<int>? _streamCounterSubscription;

  int _counter = 0;

  int get counter => _counter;

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

    _streamCounterSubscription =
        timedCounter(const Duration(seconds: 1), 3).listen((event) {
      _counter = event;

      debugPrint('lastContext!.widget: ${lastContext!.widget}');
      debugPrint('lastContext!.mounted: ${lastContext!.mounted}');

      /// Notifies clients (inherited dependencies) of the StateX's built-in InheritedWidget
//      notifyClients();

      final state = this.state?.stateByType<_MyHomePageState>();
      state?.setState(() {});

      /// Add the controller instead to _MyHomePageState() and just call,
      /// i.e:  _MyHomePageState() : super(controller: StreamCounterController());
      // setState((){});
    });
  }

  @override
  void dispose() {
    _streamCounterSubscription?.cancel();

    super.dispose();
  }

  Stream<int> timedCounter(Duration interval, [int? maxCount]) {
    late StreamController<int> controller;
    Timer? timer;
    int counter = 0;

    void tick(_) {
      counter++;
      controller.add(counter);
      if (counter == maxCount) {
        timer?.cancel();
        controller.close();
      }
    }

    void startTimer() {
      timer = Timer.periodic(interval, tick);
    }

    void stopTimer() {
      timer?.cancel();
      timer = null;
    }

    controller = StreamController<int>(
      onListen: startTimer,
      onPause: stopTimer,
      onResume: startTimer,
      onCancel: stopTimer,
    );

    // Preload a value in the stream.
    controller.add(1);

    return controller.stream;
  }
}

In this sample, the built-in InheritedWidget is used.

Notice the controller, StreamCounterController, is instantiated and added to the State object it's to work with:, _MyHomePageState, keeping the code very tight and modular.

You'll also notice I changed the timedCounter() function into a class of its own! Timers are 'memory hogs', and changing it to a class allowed me to easily turn off and on the Timer if and when the app is set in the background and then resumed again by the user.

Follow up here if you have any questions.

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:state_extended/state_extended.dart';

void main() {
  runApp(const MyApp());
}

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

  @override
  State createState() => _MyAppState();
}

class _MyAppState extends AppStateX<MyApp> {
  /// Linking the StreamCounterController here at the 'app level' is fine if necessary.
  /// However, I would have assigned it directly to the State object it's to work with.
  /// Personal preference. That's all.
  _MyAppState() : super(controller: _MyAppController());

  @override
  Widget buildIn(BuildContext context) {
    return const MaterialApp(
      home: MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key, required this.title});

  final String title;

  @override
  State createState() => _MyHomePageState();
}

class _MyHomePageState extends StateX<MyHomePage> {
// class _MyHomePageState extends StateIn<MyHomePage> { /// StateIn defaults to useInherited = true
  /// Assign to the State object it's to work with.
  _MyHomePageState()
      : super(controller: StreamCounterController(), useInherited: true) {
    /// Assigning the variable in iniState() is fine.
    /// Just know it could be done also in constructor using the 'controller' property.
    streamDataController = controller as StreamCounterController;
  }
  late StreamCounterController streamDataController;

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

    /// Assigning the variable in iniState() is fine.
    /// Just know it could be done also in constructor.
    // streamDataController = controllerByType<StreamCounterController>()!;
  }

  @override
  Widget build(BuildContext context) {
    streamDataController.dependOnInheritedWidget(context);

    return Scaffold(
      appBar: AppBar(title: const Text('Flutter Demo Home Page')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            const Text(
              'Stream counter:',
            ),
             Text(
               '${streamDataController.counter}',
               style: Theme.of(context).textTheme.headlineMedium,
             ),
          ],
        ),
      ),
    );
  }
}

/// Provide a separate controller to 'work with' the AppStateX object.
/// One that's concerned with the 'overall' workings of the app.
class _MyAppController extends StateXController {}

class StreamCounterController extends StateXController {
  StreamCounterController() {
    /// Made into its own separate class so to stop and start the timer when we want.
    timedCounter = TimedCounter(const Duration(seconds: 1), 3);
  }
  late TimedCounter timedCounter;
  StreamSubscription<int>? _streamCounterSubscription;

  int _counter = 0;

  int get counter => _counter;

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

    /// This routine is called when it's first assigned as a 'listener'
    /// and so you must know the notifyClients() functions first call does nothing of consequence.
    /// Not a bad thing! Such so you know.
    _streamCounterSubscription = timedCounter.stream.listen((event) {
      _counter = event;

      debugPrint('lastContext!.widget: ${lastContext!.widget}');
      debugPrint('lastContext!.mounted: ${lastContext!.mounted}');

      ///This will now call the _MyHomePageState's build() function
      notifyClients();
    });
  }

  /// Timers are 'memory hogs'.
  /// Nice to stop them if the app's placed in the background.
  @override
  void pausedLifecycleState() {
    timedCounter.stopTimer();
  }

  /// Returned to the app; return to the Timer
  @override
  void resumedLifecycleState() {
    timedCounter.startTimer();
  }

  /// dispose() is called at the discretion of the operating system (i.e like garbage collection ).
  /// I would recommend using the deactivate() function instead---you know it's always to be called when appropriate.
  @override
  void deactivate() {
    _streamCounterSubscription?.cancel();
    timedCounter.stopTimer();
  }
}

// Separate class dealing with the Stream and the Timer
// so we can stop and start the timer when we want
class TimedCounter {
  TimedCounter(this.interval, [this.maxCount]) {
    // Define the stream controller
    controller = StreamController<int>(
      onListen: startTimer,
      onPause: stopTimer,
      onResume: startTimer,
      onCancel: stopTimer,
    );

    // Preload a value in the stream.
    controller.add(1);
  }
  Duration interval;
  int? maxCount;
  late StreamController<int> controller;

  // Supply the stream
  Stream<int> get stream => controller.stream;

  Timer? timer;
  int counter = 0;

  void tick(_) {
    counter++;
    controller.add(counter);
    if (maxCount != null && counter == maxCount!) {
      timer?.cancel();
      controller.close();
    }
  }

  //
  void startTimer() {
    timer = Timer.periodic(interval, tick);
  }

  void stopTimer() {
    timer?.cancel();
    timer = null;
  }
}
Andrious commented 11 months ago

v4.7.0 has just been uploaded to Pub.dev.

I would request you use that version now as it will allow even a little more magic. With this version, you can confidently use the State object's state widget to update only the Text widget containing the counter.

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:state_extended/state_extended.dart';

void main() {
  runApp(const MyApp());
}

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

  @override
  State createState() => _MyAppState();
}

class _MyAppState extends AppStateX<MyApp> {
  /// Linking the StreamCounterController here at the 'app level' is fine if necessary.
  /// However, I would have assigned it directly to the State object it's to work with.
  /// Personal preference. That's all.
  _MyAppState() : super(controller: _MyAppController());

  @override
  Widget buildIn(BuildContext context) {
    return const MaterialApp(
      home: MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key, required this.title});

  final String title;

  @override
  State createState() => _MyHomePageState();
}

class _MyHomePageState extends StateX<MyHomePage> {
// class _MyHomePageState extends StateIn<MyHomePage> { /// StateIn defaults to useInherited = true
  /// Assign to the State object it's to work with.
  _MyHomePageState()
      : super(controller: StreamCounterController(), useInherited: true) {
    /// Assigning the variable in iniState() is fine.
    /// Just know it could be done also in constructor using the 'controller' property.
    streamDataController = controller as StreamCounterController;
  }
  late StreamCounterController streamDataController;

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

    /// Assigning the variable in iniState() is fine.
    /// Just know it could be done also in the constructor.
    // streamDataController = controllerByType<StreamCounterController>()!;
  }

  @override
  Widget build(BuildContext context) {
//    streamDataController.dependOnInheritedWidget(context);

    return Scaffold(
      appBar: AppBar(title: const Text('Flutter Demo Home Page')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            const Text(
              'Stream counter:',
            ),
            // Text(
            //   '${streamDataController.counter}',
            //   style: Theme.of(context).textTheme.headlineMedium,
            // ),
            state(
              (context) => Text(
                '${streamDataController.counter}',
                style: Theme.of(context).textTheme.headlineMedium,
              ),
            ),
          ],
        ),
      ),
    );
  }
}

/// Provide a separate controller to 'work with' the AppStateX object.
/// One that's concerned with the 'overall' workings of the app.
class _MyAppController extends StateXController {}

class StreamCounterController extends StateXController {
  StreamCounterController() {
    /// Made into its own separate class so to stop and start the timer when we want.
    timedCounter = TimedCounter(const Duration(seconds: 1), 3);
  }
  late TimedCounter timedCounter;
  StreamSubscription<int>? _streamCounterSubscription;

  int _counter = 0;

  int get counter => _counter;

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

    /// This routine is called when it's first assigned as a 'listener'
    /// and so you must know the notifyClients() functions first call does nothing of consequence.
    /// Not a bad thing! Such so you know.
    _streamCounterSubscription = timedCounter.stream.listen((event) {
      _counter = event;

      debugPrint('lastContext!.widget: ${lastContext!.widget}');
      debugPrint('lastContext!.mounted: ${lastContext!.mounted}');

      /// This will call the lone Text widget wrapped in the state widget.
      notifyClients();
    });
  }

  /// Timers are 'memory hogs'.
  /// Nice to stop them if the app's placed in the background.
  @override
  void pausedLifecycleState() {
    timedCounter.stopTimer();
  }

  /// Returned to the app; return to the Timer
  @override
  void resumedLifecycleState() {
    timedCounter.startTimer();
  }

  /// dispose() is called at the discretion of the operating system (i.e like garbage collection ).
  /// I would recommend using the deactivate() function instead---you know it's always to be called when appropriate.
  @override
  void deactivate() {
    _streamCounterSubscription?.cancel();
    timedCounter.stopTimer();
  }
}

// Separate class dealing with the Stream and the Timer
// so we can stop and start the timer when we want
class TimedCounter {
  TimedCounter(this.interval, [this.maxCount]) {
    // Define the stream controller
    controller = StreamController<int>(
      onListen: startTimer,
      onPause: stopTimer,
      onResume: startTimer,
      onCancel: stopTimer,
    );

    // Preload a value in the stream.
    controller.add(1);
  }
  Duration interval;
  int? maxCount;
  late StreamController<int> controller;

  // Supply the stream
  Stream<int> get stream => controller.stream;

  Timer? timer;
  int counter = 0;

  void tick(_) {
    counter++;
    controller.add(counter);
    if (maxCount != null && counter == maxCount!) {
      timer?.cancel();
      controller.close();
    }
  }

  //
  void startTimer() {
    timer = Timer.periodic(interval, tick);
  }

  void stopTimer() {
    timer?.cancel();
    timer = null;
  }
}
jlin5 commented 11 months ago

Thank you for the detailed explanation! I want to give more context of the use case I had that I hope you can give me guidance on. I have a few streams that would get data from a document database like Firestore where one stream listens to changes on a user document and another one would listen to changes on a cart document so I created a UserController and CartController respectively. The user and cart data is used in multiple screens throughout the app so my understanding was to add those controllers to the AppStateX so I can reference those controllers with controllerByType and use them in the page widgets. Is there a better way to achieve what I'm looking for or a better pattern to follow. I appreciate the feedback!

Andrious commented 11 months ago

Glad I can be of help. Hope you take a close look at the code and come to appreciate what StateX can do for you.

As to your second question. The impression I'm getting is you believe supplying those controllers to the 'first State object' (i.e. AppStateX) will allow you access to those controllers throughout your app, is that right? I would suggest you only add a controller to a StateX object when that State object needs it; not when your whole app needs it.

Know that you're intent is to use the controllerByType to retrieve those controllers anytime anywhere in your app. However, since you're going to know the type throughout your app, use the factory constructor in your controllers and then simply instantiate a controller here and there throughout your app when you need it: For example: final con = StreamCounterController();

Doing so, then only one instance of that controller will ever reside in memory. Below is a typical factory constructor:

class StreamCounterController extends StateXController {
  factory StreamCounterController() => _this ??= StreamCounterController._();
  StreamCounterController._();
  static StreamCounterController? _this;

In fact, all my controllers use the factory constructor. By their very role, there's always only the need for one instance of a particular controller anyway!

Simply instantiate UserController and CartController all over the place once you've implemented their factory constructors.

What do you think? Did I answer your question? Maybe I don't quite understand. You did say, you want access to those controllers for multiple screens throughout your app. That'll do it and do it simply.

Cheers.

Andrious commented 11 months ago

When using StateX, you really only want to add a controller to a particular StateX object if you want to be able to:

1) Call the controller's setState() function to, in turn, call that State object's setState() function.

2) To run the controller's initState(), initAsync(), pauseLifecycleState(), dispose(), etc. functions when the State object runs its corresponding functions.

There may be other reasons, but I'm tired and don't remember. :)

jlin5 commented 11 months ago

Yes! In hindsight, realize now that I should've kept use the singleton patterns for the controllers. I removed the singleton pattern because it was affecting my widget tests (the controllers were keeping state between tests), which led to me use controllerByType on the screens that use data from the controllers. I should've been using deactivate instead of dispose to clean up state or add a method to null the _this instance for test usage only. Thank you for the help!