This article will show you how to gracefully build high quality projects from setState to futureBuilder and streamBuilder, without the side effects of setState. If you are interested in this article, please check out the source code.

Basic setState updates data

First, we use the basic StatefulWidget to create the page as follows:

class BaseStatefulDemo extends StatefulWidget {
  @override
  _BaseStatefulDemoState createState() => _BaseStatefulDemoState();
}

class _BaseStatefulDemoState extends State<BaseStatefulDemo> {
  @override
  Widget build(BuildContext context) {
    returnContainer(); }}Copy the code

We then use the Future to create some data to simulate the network request as follows:

  Future<List<String>> _getListData() async {
    await Future.delayed(Duration(seconds: 1)); // Returns data after 1 second
    return List<String>.generate(10, (index) => '$index content');
  }
Copy the code

Call _getListData() in the initState() method to initialize the data as follows:

  List<String> _pageData = List<String> ();@override
  void initState() {
    _getListData().then((data) => setState(() {
              _pageData = data;
            }));
    super.initState();
  }
Copy the code

Use ListView.Builder to process this data to build the UI as follows:

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Base Stateful Demo'),
      ),
      body: ListView.builder(
        itemCount: _pageData.length,
        itemBuilder: (buildContext, index) {
          returnColumn( children: <Widget>[ ListTile( title: Text(_pageData[index]), ), Divider(), ], ); },),); }Copy the code

Finally, we can see the interface 😎, as shown:

list data

Of course, you can also separate the UI display into a method for later maintenance and clearer code hierarchy, as follows:

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Base Stateful Demo'),
      ),
      body: ListView.builder(
        itemCount: _pageData.length,
        itemBuilder: (buildContext, index) {
          return getListDataUi(intindex); },),); } Widget getListDataUi(int index) {
    return Column(
                children: <Widget>[
                  ListTile(
                    title: Text(_pageData[index]),
                  ),
                  Divider(),
                ],
              );
  }
Copy the code

Go ahead, let’s refine it, get the data from the back end normally, the back end should give us different information, depending on which information needs to handle different states, such as:

  • BusyState(loading) : We display a loading indicator on the screen
  • DataFetchedState: We delay 2 seconds to simulate the completion of the data load
  • ErrorState: Displays an error message
  • NoData(NoData) : the request succeeded, but NoData is displayed

Let’s deal with the BusyState load indicator first, as follows:

bool get _fetchingData => _pageData == null; // Check whether the data is null

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Base Stateful Demo'),
      ),
      body: _fetchingData
          ? Center(
              child: CircularProgressIndicator( // Load indicator
                valueColor: AlwaysStoppedAnimation<Color>(Colors.yellow), // Set the indicator color
                backgroundColor: Colors.yellow[100].// Set the background color
              ),
            )
          : ListView.builder(
              itemCount: _pageData.length,
              itemBuilder: (buildContext, index) {
                returngetListDataUi(index); },),); }Copy the code

The effect is as follows:

indicator

Next, to deal with ErrorState, I add the hasError parameter to _getListData() to simulate the error returned from the back end, as follows

  Future<List<String>> _getListData({bool hasError = false}) async {
    await Future.delayed(Duration(seconds: 1)); // Returns data after 1 second

    if (hasError) {
      return Future.error('Problem getting data, please try again');
    }

    return List<String>.generate(10, (index) => '$index content');
  }
Copy the code

Then, catch the exception update data in the initState() method as follows:

  @override
  void initState() {
    _getListData(hasError: true)
        .then((data) => setState(() {
              _pageData = data;
            }))
        .catchError((error) => setState(() {
              _pageData = [error];
            }));
    super.initState();
  }
Copy the code

It looks like this (of course, you can use an error page here) :

error

Next, we deal with NoData. I add the hasData parameter to _getListData() to simulate the return of empty data from the back end, as follows:

  Future<List<String>> _getListData(
      {bool hasError = false.bool hasData = true}) async {
    await Future.delayed(Duration(seconds: 1));

    if (hasError) {
      return Future.error('Problem getting data, please try again');
    }

    if(! hasData) {return List<String> (); }return List<String>.generate(10, (index) => '$index content');
  }
Copy the code

Then, update the data in the initState() method as follows:

  @override
  void initState() {
    _getListData(hasError: false, hasData: false)
        .then((data) => setState(() {
              if (data.length == 0) {
                data.add('No data fount');
              }
              _pageData = data;
            }))
        .catchError((error) => setState(() {
              _pageData = [error];
            }));
    super.initState();
  }
Copy the code

The effect is as follows:

error

This is simply updating data with setState(), isn’t it? Normally we can do this with no problem. However, if our page is complex enough and has enough states to handle, we need to use more setState(), which means we need more code to update data. The build() method is re-executed every time we setState() (this is the side effect mentioned above).

Flutter already provides a more elegant way to update our data and processing state. It is the futureBuilder that we will introduce next.

FutureBuilder

FutureBuilder receives a Future from the Future: parameter and builds the UI from the Builder: parameter. The Builder: parameter is a function that provides a snapshot parameter with the required state and data.

Next, we change the StatefulWidget above to StatelessWidget and replace it with FutureBuilder as follows:

class FutureBuilderDemo extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Future Builder Demo'),
      ),
      body: FutureBuilder(
        future: _getListData(),
        builder: (buildContext, snapshot) {
          if (snapshot.hasError) {  // FutureBuilder already provides us with the error state
            return _getInfoMessage(snapshot.error);
          }

          if(! snapshot.hasData) {// FutureBuilder already provides us with empty data states
            return Center(
              child: CircularProgressIndicator(
                valueColor: AlwaysStoppedAnimation<Color>(Colors.yellow),
                backgroundColor: Colors.yellow[100],),); }var listData = snapshot.data;
          if (listData.length == 0) {
            return _getInfoMessage('No data found');
          }

          return ListView.builder(
            itemCount: listData.length,
            itemBuilder: (buildContext, index) {
              returnColumn( children: <Widget>[ ListTile( title: Text(listData[index]), ), Divider(), ], ); }); },),); }...Copy the code

By looking at the source code, we can see that FutureBuilder has dealt with some basic states for me, as shown in the figure

snapshot

We use the _getInfoMessage() method to handle status hints as follows:

  Widget _getInfoMessage(String msg) {
    return Center(
      child: Text(msg),
    );
  }
Copy the code

In this way, we do not use any setState() to achieve the same effect as above, and no side effects, is not very cool 💪.

However, it’s not perfect, for example, if we want to refresh the data, we need to re-call the _getListData() method, and it doesn’t refresh.

StreamBuilder

StreamBuilder accepts a stream via the stream: argument, and also via Builder: Parameters to build the UI, similar to futureBuilder, with the only benefit that we can control the stream inputs and outputs at will, adding any state to update the UI in the specified state.

First, we use enum to represent our state, adding it to the header of the file as follows:

enum StreamViewState { Busy, DataRetrieved, NoData }
Copy the code

Next, create a stream controller using StreamController, replace FutureBuilder with StreamBuilder, and change the Future: argument to stream: as follows:


final StreamController<StreamDemoState> _stateController = StreamController<StreamDemoState>();

@override
  Widget build(BuildContext context) {
    return Scaffold(

      ...

      body: StreamBuilder(
        stream: model.homeState,
        builder: (buildContext, snapshot) {
          if (snapshot.hasError) {
            return _getInfoMessage(snapshot.error);
          }
          // Use the enumerated Busy to update the data
          if(! snapshot.hasData || StreamViewState.Busy) {return Center(
              child: CircularProgressIndicator(
                valueColor: AlwaysStoppedAnimation<Color>(Colors.yellow),
                backgroundColor: Colors.yellow[100],),); }// Use enumerated NoData to update data
          if (listItems.length == StreamViewState.NoData) {
            return _getInfoMessage('No data found');
          }

          return ListView.builder(
            itemCount: listItems.length,
            itemBuilder: (buildContext, index) {
              returnColumn( children: <Widget>[ ListTile( title: Text(listItems[index]), ), Divider(), ], ); }); },),); }Copy the code

Enumeration values are added to determine whether data needs to be updated, but the rest remains largely unchanged.

Next, I need to modify the _getListData() method to add state and data using the flow controller as follows:

  Future _getListData({bool hasError = false.bool hasData = true}) async {
    _stateController.add(StreamViewState.Busy);
    await Future.delayed(Duration(seconds: 2));

    if (hasError) {
      return _stateController.addError('error'); // Add error data to stream
    }

    if(! hasData) {return _stateController.add(StreamViewState.NoData); // Add no data state to stream
    }

    _listItems = List<String>.generate(10, (index) => '$index content');
    _stateController.add(StreamViewState.DataRetrieved); // Add data completion status to stream
  }
Copy the code

We are not returning data at this point, so we need to create listItems to store the data and change the StatelessWidget to the StatefulWidget so that we can update the data based on the stream output. This conversion is very convenient. VS Code editor can use Option + Shift + R (Mac) or Ctrl + Shift + R (Win) shortcuts, Android Studio uses Option + Enter shortcuts, The data is then initialized in the initState() method as follows:

List<String> listItems;

@override
void initState() {
  _getListData();
  super.initState();
}
Copy the code

Now that we have resolved FutureBuilder’s limitations, we can add a new FloatingActionButton to refresh the data as follows:

@override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Stream Builder Demo'),
      ),
      floatingActionButton: FloatingActionButton(
        backgroundColor: Colors.yellow,
        child: Icon(
          Icons.cached,
          color: Colors.black87,
        ),
        onPressed: () {
          model.dispatch(FetchData());
        },
      ),
      body: StreamBuilder(

        ...
        
      ),
    );
  }
Copy the code

Now click on FloatingActionButton and the load indicator is displayed. However, our listItems data is not really updated. Clicking on FloatingActionButton just updates the load status. And our business logic code and UI code are still in the same file, so obviously they are decoupled, so we can refine it to separate the business logic code from the UI code.

Separate business logic code andUIcode

We can separate the stream code into a class like this:

import 'dart:async';
import 'dart:math';

import 'package:pro_flutter/demo/stream_demo/stream_demo_event.dart';
import 'package:pro_flutter/demo/stream_demo/stream_demo_state.dart';


enum StreamViewState { Busy, DataRetrieved, NoData }

class StreamDemoModel {
  final StreamController<StreamDemoState> _stateController = StreamController<StreamDemoState>();

  List<String> _listItems;

  Stream<StreamDemoState> get streamState => _stateController.stream;

  void dispatch(StreamDemoEvent event){
    print('Event dispatched: $event');
    if(event is FetchData) {
      _getListData(hasData: event.hasData, hasError: event.hasError);
    }
  }

  Future _getListData({bool hasError = false.bool hasData = true}) async {
    _stateController.add(BusyState());
    await Future.delayed(Duration(seconds: 2));

    if (hasError) {
      return _stateController.addError('error');
    }

    if(! hasData) {return _stateController.add(DataFetchedState(data: List<String> ())); } _listItems =List<String>.generate(10, (index) => '$index content'); _stateController.add(DataFetchedState(data: _listItems)); }}Copy the code

Then, wrap the state into a file and associate the data with the state as follows:

class StreamDemoState{}

class InitializedState extends StreamDemoState {}

class DataFetchedState extends StreamDemoState {
  final List<String> data;

  DataFetchedState({this.data});

  bool get hasData => data.length > 0;
}

class ErrorState extends StreamDemoState{}

class BusyState extends StreamDemoState{}
Copy the code

Wrap another event file like this:

class StreamDemoEvent{}

class FetchData extends StreamDemoEvent{
  final bool hasError;
  final bool hasData;

  FetchData({this.hasError = false.this.hasData = true});

  @override
  String toString() {
    return 'FetchData { hasError: $hasError, hasData: $hasData }'; }}Copy the code

Finally, the code for our UI section is as follows:

class _StreamBuilderDemoState extends State<StreamBuilderDemo> {
  final model = StreamDemoModel(); / / create the model

  @override
  void initState() {
    model.dispatch(FetchData(hasData: true)); // Get the data in the model
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(

      ...

      body: StreamBuilder(
        stream: model.streamState,
        builder: (buildContext, snapshot) {
          if (snapshot.hasError) {
            return _getInformationMessage(snapshot.error);
          }

          var streamState = snapshot.data;

          if(! snapshot.hasData || streamStateis BusyState) {  // Use the encapsulated status class to determine whether to update the UI
            return Center(
              child: CircularProgressIndicator(
                valueColor: AlwaysStoppedAnimation<Color>(Colors.yellow),
                backgroundColor: Colors.yellow[100],),); }if (streamState is DataFetchedState) { // Use the encapsulated status class to determine whether to update the UI
            if(! homeState.hasData) {return _getInformationMessage('not found data'); }}return ListView.builder(
            itemCount: streamState.data.length,  // At this point, the data is no longer local data, but output data from streamitemBuilder: (buildContext, index) => _getListItem(index, streamState.data), ); },),); }... }Copy the code

At this point, the business logic code and the UI code are completely separated, extensible and maintainable, and our data and state are associated. Clicking FloatingActionButton will look the same as above and the data has been updated.

Finally, I attach my blog and GitHub address as follows:

Blog: h.lishaoy.net/futruebuild… GitHub address: github.com/persilee/fl…