DEV Community

Cover image for Flutter Bloc Async State: Stop Modeling Data With Nulls and Loading Flags
Konstantin Voronov
Konstantin Voronov

Posted on

Flutter Bloc Async State: Stop Modeling Data With Nulls and Loading Flags

Flutter apps rarely start with complicated state.

At first, a Bloc or Cubit state often looks harmless:

class KidState {
  final Kid? kid;
  final bool isLoading;
  final StateError? error;

  const KidState({
    required this.kid,
    required this.isLoading,
    required this.error,
  });
}
Enter fullscreen mode Exit fullscreen mode

This works until the screen becomes real.

You add initial loading. Then pull-to-refresh. Then a cached value. Then an empty state. Then an error that should not erase the old content. Then a retry. Then one more field with the same lifecycle.

Now the state is not simple anymore. It is implicit.

// What does this mean?
kid != null && isLoading && error != null
Enter fullscreen mode Exit fullscreen mode

Is the kid loaded? Is the screen refreshing? Did the refresh fail? Should the UI show the old kid, the error, or a full-screen loader?

The problem is not Bloc. The problem is that the data lifecycle is hidden behind nulls and flags.

That distinction matters for adoption.

Bloc or Cubit should coordinate the screen: user actions, refreshes, navigation, analytics, and when to emit a new state. It should not have to reinvent a full data-lifecycle model for every async field.

The repository or API layer should fetch data and return a success or failure. It should not know whether the UI will show a shimmer, a tab, a toast, or an empty placeholder.

StatefulData sits between those responsibilities. It describes the state of the data itself.

Declarative Programming Patterns

Declarative Programming Patterns means writing code where every meaningful outcome is explicit: no nullable state, no hidden default branches, no ignored failures, and no ambiguous "maybe" values.

For async data, that means the lifecycle should be part of the type.

With stateful_data, one field can describe the whole lifecycle:

StatefulData<Kid, StateError> kid;
Enter fullscreen mode Exit fullscreen mode

This value is always in exactly one meaningful state:

  • Uninitialized
  • Loading
  • Empty
  • Ready
  • Dirty
  • Updating
  • Failure

The code no longer asks "is this null because it failed, because it is empty, or because we never loaded it?"

The state tells you.

The Old Way

A typical Cubit state starts like this:

class KidState {
  final Kid? kid;
  final bool isLoading;
  final StateError? error;

  const KidState({
    required this.kid,
    required this.isLoading,
    required this.error,
  });

  KidState copyWith({
    Kid? kid,
    bool? isLoading,
    StateError? error,
  }) {
    return KidState(
      kid: kid ?? this.kid,
      isLoading: isLoading ?? this.isLoading,
      error: error ?? this.error,
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

This has several hidden problems.

copyWith cannot set kid or error back to null without extra sentinel logic. isLoading does not explain whether this is first load or refresh. error can exist together with old data, but the type does not say whether that is allowed.

The state permits combinations that the product may not actually support.

The StatefulData Way

Use one lifecycle field:

class KidState {
  final StatefulData<Kid, StateError> kid;

  const KidState({
    required this.kid,
  });

  const KidState.initial() : kid = const Uninitialized();

  KidState copyWith({
    StatefulData<Kid, StateError>? kid,
  }) {
    return KidState(
      kid: kid ?? this.kid,
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Now the Cubit can move through named transitions:

emit(state.copyWith(kid: state.kid.toLoading()));

final result = await repository.fetchKid();

emit(state.copyWith(kid: result));
Enter fullscreen mode Exit fullscreen mode

If the request fails:

emit(
  state.copyWith(
    kid: state.kid.toFailure(
      StateError('Failed to load kid info'),
    ),
  ),
);
Enter fullscreen mode Exit fullscreen mode

The difference is small in code, but large in meaning.

toLoading() can preserve the best previous value. toFailure() can preserve the best previous value. Ready(kid) means there is a usable value. Empty() means the request completed and there is nothing to show. Uninitialized() means loading has not happened yet.

These are not flags. They are states.

Bloc Still Does the Bloc Job

StatefulData does not replace Bloc or Cubit.

Bloc and Cubit still coordinate actions:

  • user taps;
  • screen refreshes;
  • repository calls;
  • navigation;
  • analytics;
  • state emission.

StatefulData only models the lifecycle of one async value.

This is the separation of concerns:

Repository/API:
  fetches or saves data
  returns success, empty, or failure

StatefulData:
  describes the lifecycle of that data
  keeps previous value context during loading or failure

Bloc/Cubit:
  coordinates screen behavior
  decides when to load, refresh, retry, or emit

Widget:
  renders the lifecycle
Enter fullscreen mode Exit fullscreen mode

When UI state becomes responsible for keeping data state, responsibilities start to leak. The Cubit ends up remembering API outcomes through flags. The widget starts guessing what null means. The repository may be pressured to return UI-shaped data.

StatefulData gives data state its own small home, so the layers can stay simpler.

That makes it useful inside normal Bloc/Cubit state:

class ProfileState {
  final StatefulData<Kid, StateError> kid;
  final StatefulData<List<Product>, StateError> products;
  final int selectedTabIndex;

  const ProfileState({
    required this.kid,
    required this.products,
    required this.selectedTabIndex,
  });
}
Enter fullscreen mode Exit fullscreen mode

The tab index is ordinary UI state. The async values get explicit lifecycle state.

That boundary keeps the pattern clean.

A Practical Rule

If a field can be:

  • not loaded yet;
  • loading;
  • loaded;
  • empty;
  • refreshing;
  • failed;
  • locally edited;

then it probably should not be modeled as:

T? value;
bool isLoading;
Object? error;
Enter fullscreen mode Exit fullscreen mode

It should be modeled as:

StatefulData<T, E> value;
Enter fullscreen mode Exit fullscreen mode

That is the first StatefulData pattern to adopt.

You can try it gradually. Pick one field that currently uses T?, isLoading, and error, replace only that field with StatefulData<T, E>, and keep the rest of your Bloc/Cubit architecture unchanged.

What the UI Starts to Look Like

Once the data lifecycle is explicit, the widget can render that lifecycle directly:

state.kid.statefulBuilder(
  shimmer: () => const KidHeaderShimmer(),
  emptyBuilder: () => const SizedBox.shrink(),
  failureBuilder: (failure) => KidHeaderError(
    message: failure.message,
  ),
  builder: (kid, inProgress, {error}) {
    return KidHeader(
      kid: kid,
      isRefreshing: inProgress,
      refreshError: error,
    );
  },
);
Enter fullscreen mode Exit fullscreen mode

This is the adoption-friendly part: the UI no longer needs to reverse-engineer state from kid == null, isLoading, and error. It receives one lifecycle value and maps each outcome to a deliberate widget.

Install

dependencies:
  stateful_data: ^1.0.7
Enter fullscreen mode Exit fullscreen mode

Then import the core package:

import 'package:stateful_data/stateful_data.dart';
Enter fullscreen mode Exit fullscreen mode

For Flutter UI helpers:

import 'package:stateful_data/stateful_data_flutter.dart';
Enter fullscreen mode Exit fullscreen mode

StatefulData is small, but the idea behind it is bigger: make every meaningful async outcome explicit, then let the compiler and the UI work with that truth.

Links:

Top comments (0)