Flutter: setState() does not trigger a build

1,205

Solution 1

I have the feeling, the answers don't address my question. My question was why the widget only builds once and why setState() does not trigger a second build.

Answers like "use a FutureBuilder" are not helpful since they completely bypass the question about setState(). So no matter how late the async function finishes, triggering a rebuild should update the UI with the new list when setState() is executed.

Also, the async function does not finish too early (before build has finished). I made sure it does not by trying WidgetsBinding.instance.addPostFrameCallback which changed: nothing.

I figured out that the problem was somewhere else. In my main() function the first two lines were:

  SystemChrome.setEnabledSystemUIOverlays([SystemUiOverlay.bottom]);
  SystemChrome.setPreferredOrientations(
    [DeviceOrientation.portraitUp,DeviceOrientation.portraitDown]
  );

which somehow affected the build order. But only on my Huawei P20 Lite, on no other of my test devices, not in the emulator and not on Dartpad.

So conclusion: Code is fine. My understanding of setState() is also fine. I haven't provided enough context for you to reproduce the error. And my solution was to make the first two lines in the main() function async:

void main() async {
  await SystemChrome.setEnabledSystemUIOverlays([SystemUiOverlay.bottom]);
  await SystemChrome.setPreferredOrientations(
    [DeviceOrientation.portraitUp,DeviceOrientation.portraitDown]
  );
  ...
}

Solution 2

Your code works in DartPad.dev

DartPad run

Just copy/paste this to see it running:

import 'package:flutter/material.dart';

final Color darkBlue = Color.fromARGB(255, 18, 32, 47);

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

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      home: Scaffold(
        body: Center(
          child: Scan(),
        ),
      ),
    );
  }
}

class Scan extends StatefulWidget {
  @override
  _ScanState createState() => _ScanState();
}

class _ScanState extends State<Scan> {
  List<int> numbers;

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

  @override
  Widget build(BuildContext context) {
    print('Build was scheduled');
    return Center(
      child: Text(
        numbers == null ? '0' : numbers.length.toString()
      )
    );
  }

  Future<List<int>> _getAsyncNumberList() {
    return Future(() => [1,2,3,4]);
  }

  _initializeController() async {
    List<int> newNumbersList = await _getAsyncNumberList();

    print("Number list was updated to list of length ${newNumbersList.length}");

    setState(() {
      numbers = newNumbersList;
    });
  }
}

Solution 3

Proof it works

I don't know why you say your code is not working, but here you can see that even the prints perform as they should. Your example might be oversimplified. If you add a delay to that Future (which is a real case scenario, cause fetching data and waiting for it does take a few seconds sometimes), then the code does indeed display 0.

The reason why your code works right now is that the Future returns the list instantly before the build method starts rendering Widgets. That's why the first thing that shows up on the screen is 4.

If you add that .delayed() to the Future, then it does indeed stop working, because the list of numbers is retrieved after some time and the build renders before the numbers are updated.

Problem explanation

SetState in your code is not called properly. You either do it like this (which in this case makes no sense because you use "await", but generally it works too)

    _initializeController() async {
    setState(() {
    List<int> newNumbersList = await _getAsyncNumberList();

    print("Number list was updated to list of length ${newNumbersList.length}");

    
      numbers = newNumbersList;
    });
  }

or like this

    _initializeController() async {
    List<int> newNumbersList = await _getAsyncNumberList();

    print("Number list was updated to list of length ${newNumbersList.length}");

    
    numbers = newNumbersList;
    setState(() {
    /// this thing right here is an entire function. You MUST HAVE THE AWAIT in 
    /// the same function as the update, otherwise, the await is callledn, and on 
    /// another thread, the other functions are executed. In your case, this one 
    /// too. This one finishes early and updates nothing, and the await finishes later.
    });
  }

Suggested solution

This will display 0 while waiting 5 seconds for the Future to return the new list with the data and then it will display 4. If you want to display something else while waiting for the data, please use a FutureBuilder Widget.

FULL CODE WITHOUT FutureBuilder:

class Scan extends StatefulWidget {
  @override
  _ScanState createState() => _ScanState();
}

class _ScanState extends State<Scan> {
  List<int> numbers;

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

  @override
  Widget build(BuildContext context) {
    print('Build was scheduled');

    return Center(
        child: Text(numbers == null ? '0' : numbers.length.toString()));
  }

  Future<List<int>> _getAsyncNumberList() {
    return Future.delayed(Duration(seconds: 5), () => [1, 2, 3, 4]);
  }

  _initializeController() async {
      List<int> newNumbersList = await _getAsyncNumberList();

      print(
          "Number list was updated to list of length ${newNumbersList.length}");
      numbers = newNumbersList;
      setState(() {});
  }
}

I strongly recommend using this version, since it displays something to the user the whole time while waiting for the data and also has a failsafe if an error comes up. Try them out and pick what is best for you, but again, I recommend this one.

FULL CODE WITH FutureBuilder:

class _ScanState extends State<Scan> {
  List<int> numbers;

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

  }

  @override
  Widget build(BuildContext context) {
    print('Build was scheduled');

    return FutureBuilder(
      future: _getAsyncNumberList(),
      builder: (BuildContext context, AsyncSnapshot<List<int>> snapshot) {
        switch (snapshot.connectionState) {
          case ConnectionState.waiting: return Center(child: Text('Fetching numbers...'));
          default:
            if (snapshot.hasError)
              return Center(child: Text('Error: ${snapshot.error}'));
            else
              /// snapshot.data is the result that the async function returns
              return Center(child: Text('Result: ${snapshot.data.length}'));
        }
      },
    );
  }

  Future<List<int>> _getAsyncNumberList() {
    return Future.delayed(Duration(seconds: 5), () => [1, 2, 3, 4]);
  }
}

Here is a more detailed example with a full explanation of how FutureBuilder works. Take some time and carefully read through it. It's a very powerful thing Flutter offers.

Share:
1,205
Schnodderbalken
Author by

Schnodderbalken

I am the liquid that comes out of your nose. My shape is a timber. In Germany we call that thing SCHNODDERBALKEN!

Updated on December 24, 2022

Comments

  • Schnodderbalken
    Schnodderbalken over 1 year

    I have a very simple (stateful) widget that contains a Text widget that displays the length of a list which is a member variable of the widget's state.

    Inside the initState() method, I override the list variable (formerly being null) with a list that has four elements using setState(). However, the Text widget still shows "0".

    The prints I added imply that a rebuild of the widget has not been triggered although my perception was that this is the sole purpose of the setState() method.

    Here ist the code:

    import 'package:flutter/material.dart';
    
    class Scan extends StatefulWidget {
      @override
      _ScanState createState() => _ScanState();
    }
    
    class _ScanState extends State<Scan> {
      List<int> numbers;
    
      @override
      void initState() {
        super.initState();
        _initializeController();
      }
    
      @override
      Widget build(BuildContext context) {
        print('Build was scheduled');
        return Center(
          child: Text(
            numbers == null ? '0' : numbers.length.toString()
          )
        );
      }
    
      Future<List<int>> _getAsyncNumberList() {
        return Future.delayed(Duration(seconds: 5), () => [1, 2, 3, 4]);
      }
    
      _initializeController() async {
        List<int> newNumbersList = await _getAsyncNumberList();
    
        print("Number list was updated to list of length ${newNumbersList.length}");
    
        setState(() {
          numbers = newNumbersList;
        });
      }
    }
    

    My question: why does the widget only build once? I would have expected to have at least two builds, the second one being triggered by the execution of setState().

  • Admin
    Admin over 3 years
    this is not an answer, imo
  • Schnodderbalken
    Schnodderbalken over 3 years
    Thank your for your answer. I understand what you're saying, but I still don't get why it's a problem to have the _getAsyncNumberList() completed after build(). Calling setState()'s purpose is to trigger a build isn't it? So even if the execution of the function takes longer than the build, then calling setState() should update the widget and the text anyways, shouldn't it?
  • Schnodderbalken
    Schnodderbalken over 3 years
    I edited the code using Future.delayed now. This makes it also happen in Dartpad not only on my phone.
  • Mad World
    Mad World over 3 years
    I haven't said that is the problem. The problem is, your build renders before your method finished retrieving the numbers. Please don't just downvote people because you did not read the answer properly. Try out my code and you will see it works as it should. It displays something while waiting a few seconds for the result, and then the result is shown
  • Mad World
    Mad World over 3 years
    In conclusion, in your initial code, where there was no delay, the numbers were retrieved before build, therefore the build displayed 4 on the screen straight up. SetState is not used properly in your code. Check my edit
  • Schnodderbalken
    Schnodderbalken over 3 years
    I wasn't me who downvoted your answer, sorry! Well, I thought the whole purpose of await is to wait for the execution of the function, turning an async call into a sync call. How can setState() be executed before the execution then? Apart from that: I can not put everything including the awaited function call inside setState(), even if I make setState async itself. See for yourself, the compiler will complain. If I do _getAsyncNumberList().then(() {setState(...)}), it still does not work, which is from my understanding the same like my code now.
  • Mad World
    Mad World over 3 years
    Yeah, I forgot to mention the fact that the await makes no sense inside the setState. What was wrong with your code was just not placing all the connected parts outside the setState function. Just keep in mind it's best to leave setState empty and do your thing before and outside of it
  • Schnodderbalken
    Schnodderbalken over 3 years
    Technically it doesn't matter whether to put the variable assignment inside the callback of setState or not. However, it's discouraged to have empty setState() callbacks. api.flutter.dev/flutter/widgets/ModalRoute/setState.html medium.com/@mehmetf_71205/setting-the-state-2809936fb79d So what's your source of information for "it's best to leave setState empty"? And why do you think it makes a difference?