Widget Methods vs. Stateful/Stateless Widget Classes in Flutter: Which is Better?

Video thumbnail

I want to show you how I modularized the Tabs system in my Academy application in Flutter and share the keys to this refactoring. Originally, the system was implemented with methods, and I migrated it to classes to improve reusability and modularity.

These posts cover aspects of good practices that I consider important and that few people talk about; previously we saw how to create abstract classes in StatefulWidget to inherit in Flutter.

️ Reusability and Modularization

Although I currently only use these five tabs in one module, it's important to leave the code as clean as possible for future scalability. I'll show you the original implementation (method-based) and how I improved it by migrating it to classes, which facilitates maintenance.

❌ Problem 1: Properties Mixed at the Same Level

In the method-based approach, all the necessary properties to build the five tabs were declared in the same _TabsDetailCourseState class, resulting in unclear code.

Method-Based Implementation (Problem)

The parent Widget class contained all the necessary properties for all Tabs, making it very difficult to know which property (loadComments, _commentEditId, _commentTitleController, etc.) was used by which method (_contentTab1, _contentTab2, etc.).

class TabsDetailCourse extends StatefulWidget {
  final TutorialModel tutorialModel;
  String searchClass = '';

  TabsDetailCourse(this.tutorialModel, {super.key});
  @override
  State<TabsDetailCourse> createState() => _TabsDetailCourseState();
}

class _TabsDetailCourseState extends State<TabsDetailCourse> {
  int active = 1;
  bool loadComments = false;

  bool blockWriting = false;
  late AppModel appModel;
  late ThemeSwitch appTheme;

  final List<ExpansionTileController> _listSectionsExpansionTileController = [];
  final ScrollController _controller = ScrollController();

  int _commentEditId = 0;
  bool _sendMessage = false;
  final _commentTitleController = TextEditingController();

These properties are used in the methods for each tab:

@override
  Widget build(BuildContext context) {
    Widget content = _contentTab1();

    // tabs de la app de detalle
    switch (active) {
      case 2:
        content = _contentTab2(
          widget
              .tutorialModel
              .tutorialSectionsModel[appModel.selectedSectionIndex]
              .tutorialSectionClassesModel[appModel.selectedClassIndex]
              .description,
        );
        break;
      case 3:
        content = _contentTab3();
        break;
      case 4:
        content = _contentTab4();
        break;
      case 5:
        content = _contentTab5(
          '${LocaleKeys.creationDate.tr()} ${widget.tutorialModel.date}',
          widget.tutorialModel.content,
          "$baseUrlAcademy/free/${widget.tutorialModel.urlClean}",
          widget.tutorialModel.title,
        );
        break;
    }

Therefore, we don't know which one of those properties we are going to use, but in the class approach, we don't have those problems as the classes have better modularization:

class _TabsDetailCourseState extends State<TabsDetailCourse> {
  // tab activa
  int active = 1;
  late AppModel appModel;

  @override
  void initState() {
    appModel = Provider.of<AppModel>(context, listen: false);

    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    // secciones y clases del curso
    Widget content = _Tab1(tutorialModel: widget.tutorialModel);

    // tabs de la app de detalle
    switch (active) {
      case 2:
        // Notas de la clase
        content = _Tab2(tutorialModel: widget.tutorialModel);
        break;
      case 3:
        // Notas del usuario por clases
        content = _Tab3(tutorialModel: widget.tutorialModel);
        break;
      case 4:
        // Comentarios de los estudiantes al curso
        content = _Tab4(tutorialModel: widget.tutorialModel);
        break;
      case 5:
        // Acerca del curso
        content = Tab5(
          duration:
              '${LocaleKeys.creationDate.tr()} ${widget.tutorialModel.date}',
          html: widget.tutorialModel.content,
          urlShareTutorial:
              "$baseUrlAcademy/free/${widget.tutorialModel.urlClean}",
          titleTutorial: widget.tutorialModel.title,
        );
        break;
    }

In the code above, notice that we don't have any of those properties, simply active which indicates which tab is active, and the model which is the provider for me that I always use to see the little things there, that clean. Therefore, and here you can see a gain.

Now, the specific properties of each Tab (like those for comments, etc.) are moved to their own StatefulWidget or StatelessWidget classes. The main class only retains the essentials: the active tab and the shared data models (appModel).

By migrating to classes, we achieved better modularization. The code is much cleaner and easier to read, as each Tab (class) contains only the properties it needs.

❌ Problem 2: Methods at the Same Level

Another problem with the method-based approach is that each Tab method (_contentTab4()) in turn needed to invoke several auxiliary methods (e.g., _getCommentUsers()).

Since all these auxiliary methods are at the same level as the main methods, it's not known for sure:

  • Which methods are used directly.
  • Which methods are auxiliary to others.
  • Where they are consumed.

Class-Based Implementation (Solution)

By moving the logic of each Tab to its own class, all its auxiliary methods are grouped within that class context, resolving the ambiguity.

// En el enfoque de Clases, todos los métodos (incluidos los auxiliares)
// forman parte de la clase del Tab, haciendo obvio dónde se usan.
class __Tab4State extends State<_Tab4> {
    ***
    await _getCommentUsers();
      _listComments();
       setStateIfMounted(() {});
      _commentEditId = 0;
      _commentTitleController.text = '';

      showToastMessage(context, LocaleKeys.messageSend.tr());
  }
 } 

Problem

By having all methods at the same level and not grouped into classes, we don't know which methods are used to be consumed directly or indirectly and where they are consumed, whereas in a class, we already know they are used within it as they are part of the class.

Widget _contentTab4() { * _getCommentUsers() * }

❌ Problem 3: Asynchrony When Returning a Widget

The most complex problem with the method approach is when a Tab needs to fetch data from the Internet asynchronously to be built.

In the method approach, the method that returns the Widget needs to handle the data's promise (Future), which complicates the implementation:

Widget _contentTab3() {
      final comment = AcademyHelper.getNoteUserByClassGet(
      widget
          .tutorialModel
          .tutorialSectionsModel[appModel.selectedSectionIndex]
          .tutorialSectionClassesModel[appModel.selectedClassIndex]
          .id,
      appModel.userToken,
    );

    // establece el comentario
    comment.then(
      (c) =>
          _controllerHtmlEditor.document = Document.fromDelta(htmlToDelta(c)),
    );
 }

The use of .then() or the need to wrap everything in a FutureBuilder complicates maintenance.

Class-Based Implementation (Solution)

By using a StatefulWidget for each Tab, we can follow the classic and clean Flutter design pattern:

  1. Define the Tab class as a StatefulWidget.
  2. In the initState() method, we start fetching the asynchronous data.
  3. When the data is ready (inside the Future), we call setState() to redraw only that Tab.

The Tab class is synchronous, and the management of asynchrony is contained and controlled within its own State, without affecting the main component.

✅ Conclusions

Sometimes I criticize myself and sometimes I'm happy to see these old implementations, as they demonstrate growth as a developer.

In many tutorials (even in large projects), these structural details are overlooked. However, these small decisions, such as prioritizing classes over methods for modular components, make the difference between poor code and maintainable and scalable code.

The improved structure, although always subject to further refinement, allows us to reuse this Tabs system in the future in a much cleaner way than the initial implementation.

Next step, learn how to create your first 2D games with the Flame and Flutter game engine.

I agree to receive announcements of interest about this Blog.

I talk about what we should use when developing more complex custom widgets in Flutter.

| 👤 Andrés Cruz

🇪🇸 En español