stateful_data 1.0.0 copy "stateful_data: ^1.0.0" to clipboard
stateful_data: ^1.0.0 copied to clipboard

A fully declarative data-lifecycle approach to BLoC-style state management in Flutter.

stateful_data #

A small Flutter/Dart package that introduces a declarative lifecycle type for values in BLoC-style state management. Created by Konstantin Voronov.

The library hides a lot of corner-case logic under the hood and gives you a bullet-proof, readable scaffold per layer. It promotes:

  • clear error handling,
  • explicit value state transitions,
  • separation of concerns across Clean Architecture layers.

stateful_data gives you a single sealed type:

StatefulData<T, E>

that replaces Traditional BLoC state patterns like:

  • nullable fields
  • ad-hoc flags (isLoading, hasError, isValid, etc.)
  • inconsistent async handling
  • implicit states (“is this loaded or not?”)
  • forgotten edge cases
  • sealed classes where some fields are null in some variants and non-null in others
T? value;
bool isLoading;
bool hasError;
String? errorMessage;

with an explicit lifecycle:

  • Uninitialized – nothing loaded yet
  • Empty – known to be empty (e.g. empty list)
  • Loading – loading from backend/storage (optionally with previous value)
  • Ready – successfully loaded value
  • Dirty – locally edited / cached / not yet validated
  • Updating – sending updates to backend
  • Failure – last operation failed (optionally with previous value)

Every value wrapped in StatefulData is always in exactly one of these states.

It’s just a data type, so it works with:

  • BLoC / Cubit
  • Riverpod
  • ValueNotifier
  • any other state management

🧱 Core types #

sealed class StatefulData<T, E extends Object> {
  const StatefulData([this.value]);
  final T? value;

  // Collapse “full lifecycle” → “value or no value”
  R either<R>(
    R Function(T value) onValue,
    R Function(E? failure) onNoValue,
  );

  // Get best usable value or null (built on top of either)
  T? valueOrNull();

  Loading<T, E> toLoading({
    Future<bool>? future,
    Completer<T>? completer,
  });

  Updating<T, E> toUpdating(
    T newValue, {
      Future<bool>? future,
      Completer<T>? completer,
    });

  Dirty<T, E> toDirty(
    T newValue, {
      DirtyKind kind = const EditedDirty(),
      DateTime? dirtyAt,
    });

  Failure<T, E> toFailure(E failure);
}

final class Uninitialized<T, E extends Object> extends StatefulData<T, E> { /* ... */ }
final class Loading<T, E extends Object>       extends StatefulData<T, E> { /* ... */ }
final class Empty<T, E extends Object>         extends StatefulData<T, E> { /* ... */ }
final class Ready<T, E extends Object>         extends StatefulData<T, E> { /* ... */ }
final class Updating<T, E extends Object>      extends StatefulData<T, E> { /* ... */ }
final class Failure<T, E extends Object>       extends StatefulData<T, E> { /* ... */ }
final class Dirty<T, E extends Object>         extends StatefulData<T, E> { /* ... */ }


🧩 Dirty and extensible DirtyKind #

Dirty represents “local edits or cached data” that differ from the last confirmed backend state.
The reason or kind of “dirty” is extensible:

abstract class DirtyKind {
  const DirtyKind();
}

class EditedDirty extends DirtyKind {
  const EditedDirty(); // locally edited, not yet saved
}

class ValidatedDirty extends DirtyKind {
  const ValidatedDirty(); // passed local validation
}

class CachedDirty extends DirtyKind {
  const CachedDirty(); // comes from cache, not yet confirmed by backend
}

// You can extend in your app:
class ConflictDirty extends DirtyKind {
  const ConflictDirty(); // e.g. server conflict that user must resolve
}

Example:

// define you own helper type with your Error processing. for example AppError (down in the examples
typedef AppStatefulData<T> = StatefulData<T, AppError>;

AppStatefulData<String> name = const Ready('Initial');

// User edits:
name = name.toDirty('New value', kind: const EditedDirty());

// After validation:
name = name.toDirty(
  'Valid value',
  kind: const ValidatedDirty(),
);

🧬 Lifecycle in action #

A typical flow for a single field:

// Initial state:
StatefulData<String, AppError> name = const Uninitialized();

// Start loading from backend:
name = name.toLoading();

// Or: if you already have cached value:
name = Dirty<String, AppError>(
  'John (cached)',
  kind: const CachedDirty(),
).toLoading();

// Got result from backend:
name = Ready<String, AppError>('John');

// User edits the value locally:
name = name.toDirty('Jon'); // EditedDirty by default

// Validation fails:
name = name.toFailure(
  const ValidationError('Must be at least 5 characters'),
);

// User fixes and we send update:
name = name.toDirty('Jonathan');

// Mark as “validated but not saved yet”:
name = name.toDirty(
  'Jonathan',
  kind: const ValidatedDirty(),
);

// Start updating backend:
name = name.toUpdating('Jonathan');

// Server accepts → mark as ready:
name = Ready<String, AppError>('Jonathan');

// If a network error happens:
name = name.toFailure(
  const NetworkError('Network error, please try again'),
);

🔧 Simple error handling examples - error type and typedefs #

You choose the error type E (e.g. AppError, Exception, String).

sealed class AppError {
  final String message;
  const AppError(this.message);
}

class ValidationError extends AppError {
  const ValidationError(String message) : super(message);
}

class NetworkError extends AppError {
  const NetworkError(String message) : super(message);
}

🧭 How to use it across layers #

The pattern is designed to be used consistently across Repo → Controller/BLoC → UI.

In Repository / Data layer #

Return StatefulData instead of nullable values or flags:

Future<AppStatefulData<User>> fetchUser() async {
  try {
    final raw = await api.getUser();

    if (raw == null) {
      return const Empty<User, AppError>();
    }

    return Ready<User, AppError>(raw);
  } on Exception catch (e) {
    return Failure<User, AppError>(
      NetworkError(e.toString()),
    );
  }
}

In Controller / BLoC #

Use transitions to move through the lifecycle:

class UserState {
  final AppStatefulData<User> user;
  const UserState({required this.user});

  factory UserState.initial() =>
      const UserState(user: Uninitialized<User, AppError>());

  UserState copyWith({AppStatefulData<User>? user}) =>
      UserState(user: user ?? this.user);
}

class UserController extends Cubit<UserState> {
  final UserRepository repo;

  UserController(this.repo) : super(UserState.initial());

  Future<void> loadUser() async {
    // 1) Show loading (keep prev value if any):
    emit(state.copyWith(user: state.user.toLoading()));

    // 2) Ask repo:
    final result = await repo.fetchUser();

    // 3) Store exactly what repo returned:
    emit(state.copyWith(user: result));
  }
}

If you want a simple “value vs no value” view for UI flags:

final showSkeleton = state.user.either(
  (value)    => false,           // we have something
  (failure)  => true,            // no value → show skeleton
);

In UI #

Use simple StatefulDataBuilder on StatefulData:


StatefulDataBuilder<User, AppError>(
  data: context.read<UserController>().state.user,

  shimmer: () => const UserShimmer(),

  builder: (user, {inProgress, error}) => UserView(
    user: user,
    isRefreshing: inProgress,
    errorBanner: error,
  ),
  emptyBuilder: () => const EmptyUserPlaceholder(),
  failureBuilder: (error) => ErrorScreen(message: error.message),
);

Or (if your need more freedom) switch on StatefulData:

final userData = context.read<YourController>().state.user;

return switch (userData) {
  Uninitialized<User, AppError>() ||
  Loading<User, AppError>(prev: null) =>
    const UserShimmer(),

  Loading<User, AppError>(prev: final u?) =>
    UserView(user: u, isRefreshing: true),

  Ready<User, AppError>(value: final u) =>
    UserView(user: u),

  Dirty<User, AppError>(value: final u, kind: _) =>
    UserView(user: u, isEdited: true),

  Updating<User, AppError>(value: final u) =>
    UserView(user: u, isSaving: true),

  Failure<User, AppError>(prev: final u?, failure: final e) when u != null =>
    UserView(user: u, errorBanner: e.message),

  Failure<User, AppError>(prev: null, failure: final e) =>
    ErrorScreen(message: e.message),

  Empty<User, AppError>() =>
    const EmptyUserPlaceholder(),
};

No more juggling:

String? name;
bool isLoadingName;
bool isNameValid;
String? nameError;

or giant state classes where fields are null only for some subclasses.

Each value is self-contained and explicit about its lifecycle.


🎯 Core philosophy #

  • Non-nullable by design
    You model states, not “maybe null” values.

  • Declarative lifecycle
    You describe what the data is (uninitialized / loading / ready / dirty / failure), not a bunch of flags.

  • Predictable and robust
    All states must be consciously handled; the compiler helps you remember them.

  • Slightly more boilerplate → much clearer logic
    You pay once in structure and win every day in readability and correctness.


📦 Installation #

dependencies:
  stateful_data: ^1.0.0

Then:

import 'package:stateful_data/stateful_data.dart';

📘 Documentation & Sources #

More docs and layer-specific examples (repositories, controllers, UI) will be added over time.


🧑‍💻 Author #

Konstantin Voronov
Creator of the StatefulData declarative data-lifecycle pattern for Flutter BLoC architectures.
Email: me@konstantinvoronov.com


⭐ Support #

If you find this package useful, please consider giving it a ⭐ on GitHub!

3
likes
0
points
183
downloads

Publisher

verified publisherkonstantinvoronov.com

Weekly Downloads

A fully declarative data-lifecycle approach to BLoC-style state management in Flutter.

Repository (GitHub)
View/report issues

License

unknown (license)

Dependencies

flutter

More

Packages that depend on stateful_data