Flutter State Management in 2026: Why I Built atomic_flutter

I’ve been building Flutter apps for years. I love the framework — the widget model, the hot reload, the single codebase dream. But every time I started a new project and reached the point where I needed shared state between screens, I hit the same wall.

Not a technical wall. A friction wall.

The existing solutions all worked. They were well-maintained, battle-tested, and backed by smart people. But none of them let me just… manage state. Every one of them wanted something from me first — a ceremony of setup, a particular way of structuring my thoughts, a toll paid in boilerplate before I could store a string and react to its changes.

So I built atomic_flutter. And in this post, I want to explain the frustrations that led me there and the design decisions I made along the way.


The Frustration: Death by a Thousand Abstractions

Let me be concrete. Say you want the simplest possible piece of shared state: a counter. An integer that lives outside a single widget so multiple parts of your app can read and update it.

Here’s what that looks like in BLoC:

// counter_event.dart
abstract class CounterEvent {}
class IncrementCounter extends CounterEvent {}

// counter_state.dart
class CounterState {
  final int count;
  const CounterState({this.count = 0});
}

// counter_bloc.dart
class CounterBloc extends Bloc<CounterEvent, CounterState> {
  CounterBloc() : super(const CounterState()) {
    on<IncrementCounter>((event, emit) {
      emit(CounterState(count: state.count + 1));
    });
  }
}

// In the widget tree — provide it
BlocProvider(
  create: (_) => CounterBloc(),
  child: MyApp(),
)

// In the widget — use it
BlocBuilder<CounterBloc, CounterState>(
  builder: (context, state) => Text('${state.count}'),
)

// To update
context.read<CounterBloc>().add(IncrementCounter());

That’s three files and an event class hierarchy for an integer.

Now, BLoC’s defenders will say this scales beautifully for complex apps. And they’re right — the event-driven architecture gives you audit trails, middleware hooks, and testable separation of concerns. But for the vast majority of state in a typical app, you don’t need any of that. You need an integer that goes up.

Riverpod improved things significantly. It removed the dependency on BuildContext for accessing state, which was a huge step forward. Riverpod 3.0 (released September 2025) cleaned up the API further — StateNotifier and StateProvider moved to legacy imports, the Notifier/AsyncNotifier classes became the standard, and separate AutoDispose variants were unified. But even with the modernized syntax, it still introduces its own indirection:

// Define the notifier
class CounterNotifier extends Notifier<int> {
  @override
  int build() => 0;
  void increment() => state++;
}

// Define the provider
final counterProvider = NotifierProvider<CounterNotifier, int>(
  CounterNotifier.new,
);

// Wrap your app in ProviderScope
void main() {
  runApp(ProviderScope(child: MyApp()));
}

// In the widget — you need ref
class CounterWidget extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final count = ref.watch(counterProvider);
    return Text('$count');
  }
}

// To update
ref.read(counterProvider.notifier).increment();

Better than before. But you still need ref everywhere. Every widget that touches state becomes a ConsumerWidget or wraps itself in a Consumer. The ref object is the gatekeeper — you can’t read or write state without it. Your entire app must be wrapped in a ProviderScope. And the provider/notifier split means you’re always managing two concepts (the provider that exposes state, and the notifier that mutates it) when often you just want one thing: the state.

Provider, the original recommendation, kept things simpler but leaned entirely on BuildContext:

// Define
class Counter with ChangeNotifier {
  int _count = 0;
  int get count => _count;
  void increment() {
    _count++;
    notifyListeners();
  }
}

// Provide it
ChangeNotifierProvider(create: (_) => Counter(), child: MyApp())

// Use it
final counter = context.watch<Counter>();
Text('${counter.count}');

// Update
context.read<Counter>().increment();

Reasonable. But context.watch and context.read are easy to confuse, the ChangeNotifier mixin requires manual notifyListeners() calls (forget one and your UI silently stops updating), and everything flows through the widget tree via BuildContext.

After years of working with these solutions, a pattern emerged in my frustration. The pain points weren’t about capability — they were about friction:

  1. Verbosity: Too much code for simple state. Events, states, blocs, providers, notifiers, consumers — the abstraction count kept climbing.
  2. Dependency on ref or context: You always needed a magic object to interact with state. That object coupled your code to the framework’s opinion about where and how state should be accessed.
  3. Indirection: The gap between “I want to store a value” and “I can store a value” was filled with ceremony.

The Design Decisions Behind atomic_flutter

When I sat down to build atomic_flutter, I had a clear set of principles. Not “let’s build a better BLoC” — more like “what if state management got out of your way?”

Decision 1: State Is Just a Value

The core abstraction in atomic_flutter is the atom — an observable container for a value. That’s it.

final counterAtom = Atom<int>(0);

One line. The atom holds an integer. You can read it with counterAtom.value, update it with counterAtom.set(5) or counterAtom.update((n) => n + 1). No event classes, no notifier subclasses, no provider wrappers.

This was the most important design decision: the unit of state should be small, self-contained, and require zero infrastructure.

Atoms are top-level declarations. They don’t live inside a widget tree, they don’t need a provider ancestor, and they don’t require context or ref to access. Any Dart code, anywhere in your app — a widget, a service class, a utility function — can read or write an atom directly.

Decision 2: No Gatekeeper Objects

In Riverpod, you need ref. In Provider, you need context. In BLoC, you need context.read<MyBloc>().

In atomic_flutter, you need nothing. The atom is the API:

// In a service class — no context, no ref, no injection
class AuthService {
  static Future<void> login(String email, String password) async {
    final user = await api.login(email, password);
    userAtom.set(user); // Direct. Done.
  }
}

// In a widget — use AtomBuilder
AtomBuilder<int>(
  atom: counterAtom,
  builder: (context, count, child) => Text('$count'),
);

// Or skip the builder entirely with the WatchAtom mixin
class ProfilePage extends StatefulWidget {
  const ProfilePage({super.key});
  @override
  State<ProfilePage> createState() => _ProfilePageState();
}

class _ProfilePageState extends State<ProfilePage> with WatchAtom {
  @override
  Widget build(BuildContext context) {
    final user = watch(userAtom);
    final theme = watch(themeAtom);
    return Text(user.name, style: theme.titleStyle);
  }
}

The WatchAtom mixin was something I added in 0.5.0 after hearing the same feedback repeatedly: “I love atoms, but I don’t love wrapping everything in AtomBuilder.” Fair enough. Now you can call watch() directly inside build(), subscribe to as many atoms as you want, and the mixin handles reconciliation automatically after each frame. Atoms you stop referencing get unsubscribed without any manual cleanup.

This was a deliberate trade-off. Solutions like Riverpod use ref as a dependency graph mechanism — it knows what depends on what and can do smart things with that knowledge. I chose to give that up in favor of directness. In practice, I found that for most apps, the dependency graph is simple enough that explicit wiring (this service writes to this atom, this widget reads from it) is clearer than implicit tracking.

Decision 3: Async as a First-Class Citizen, Not an Afterthought

One of my biggest frustrations with state management libraries was how they handled async operations. Every app makes API calls. Loading states, error states, and success states are universal. But most solutions treated them as something you implement yourself on top of the state primitive.

atomic_flutter has AsyncAtom built in:

final postsAtom = AsyncAtom<List<Post>>();

// Execute an async operation — loading/error/success tracked automatically
await postsAtom.execute(() => api.fetchPosts());

// Pattern match on the result
final widget = postsAtom.value.when(
  idle:    ()       => const Text('Tap to load'),
  loading: ()       => const CircularProgressIndicator(),
  success: (posts)  => PostsList(posts: posts),
  error:   (e, st)  => Text('Error: $e'),
);

// Or use AsyncAtomBuilder — just pass your operation and get
// retry + pull-to-refresh for free
AsyncAtomBuilder<List<Post>>(
  atom: postsAtom,
  operation: () => api.fetchPosts(),
  builder: (context, posts) => PostsList(posts: posts),
);

No writing a sealed class PostsState with PostsLoading, PostsLoaded, PostsError variants. No mapping over states in your builder. Pass operation to AsyncAtomBuilder and it gives you a retry button on errors and pull-to-refresh on success automatically — no flags, no wiring. The async lifecycle is handled by the atom and the widget, and you focus on what matters: what to show when you have data.

Decision 4: Derived State Should Be Trivial

Computed values — state derived from other state — are everywhere. “Is the user authenticated?” is derived from “does a user object exist?” “Cart total” is derived from cart items. In many solutions, derived state requires its own provider or selector setup.

In atomic_flutter:

final isAuthenticatedAtom = computed<bool>(
  () => userAtom.value != null,
  tracked: [userAtom],
);

One line, the dependency is explicit, and the computed atom updates automatically when its source changes. It’s also read-only — you can’t accidentally set() a computed atom (it throws at runtime), which prevents a whole class of bugs.

Decision 5: Keep Escape Hatches Open

I didn’t want atomic_flutter to be an island. Real apps need debouncing, throttling, stream interop, and side effects. These are built in as extensions:

// Debounce a search field
final debouncedQuery = searchAtom.debounce(Duration(milliseconds: 300));

// Run a side effect when state changes
final cleanup = cartAtom.effect((cart) => analytics.trackCart(cart));

// Bridge to streams
final stream = counterAtom.asStream();

Each extension returns a cleanup function. No special disposal logic, no lifecycle coupling — call the function when you’re done.


What 0.5.0 Brought to the Table

The design decisions above shaped atomic_flutter from the start. But the 0.5.0 release added features that I think move it from “simple and useful” to “I can build serious apps with this.” Let me walk through the highlights.

Middleware

One of the first requests I got was “how do I add logging to every atom?” or “how do I clamp values before they’re stored?” The answer used to be: wrap your set() calls. That’s fine for one atom, but it doesn’t scale.

Now there’s a proper middleware system. Global middleware runs on every atom, per-atom transformers run only where you attach them:

// Global: log every state change
Atom.addMiddleware(const LoggingMiddleware());
// [AtomicFlutter] counter: 0 → 1

// Per-atom: clamp volume to 0–100
final volume = Atom<int>(50, middleware: [(old, next) => next.clamp(0, 100)]);

Custom middleware is a single class with one method to override. Per-atom transformers are just functions. Both run before the value is stored, so listeners only ever see valid state.

Undo / Redo

I kept implementing undo manually in apps — a stack here, a previous-value variable there. It was always the same pattern, so I built it in. AtomHistory wraps any atom with a bounded ring buffer:

final counter = Atom<int>(0);
final history = AtomHistory(counter, maxHistory: 50);

counter.set(1);
counter.set(2);
counter.set(3);

history.undo(); // → 2
history.undo(); // → 1
history.redo(); // → 2

canUndo and canRedo are exposed as Atom<bool>, so you can wire them directly to your UI:

AtomBuilder(
  atom: history.canUndo,
  builder: (ctx, canUndo, _) => ElevatedButton(
    onPressed: canUndo ? history.undo : null,
    child: const Text('Undo'),
  ),
);

The button enables and disables itself as the history stack changes — no manual state tracking.

Persistence

Every app I’ve built eventually needs to persist some state — a theme preference, an auth token, onboarding completion. The persistAtom helper and AtomStorage interface make this a one-liner:

final storage = SharedPreferencesStorage(prefs);

final themeAtom = persistAtom<String>(
  'light',
  key: 'theme',
  storage: storage,
  fromJson: (v) => v as String,
  toJson: (v) => v,
);
// Loads from storage on init, writes back on every set()

You implement AtomStorage once for your backend (SharedPreferences, Hive, SQLite, whatever), and persistAtom handles the rest. There’s an InMemoryAtomStorage for tests.

Cross-Atom Batching

When you update multiple related atoms, you don’t want listeners firing between each change. atomicUpdate defers all notifications until every atom in the block has been updated:

atomicUpdate(() {
  userAtom.set(newUser);
  cartAtom.set(newCart);
  themeAtom.set(newTheme);
});
// All three listeners fire here — once each, not three times

Nested calls work correctly too. If the block throws, no listeners fire. This was one of those features where the implementation was tricky but the API is exactly what you’d hope for.


What I’m Not Claiming

I want to be honest about trade-offs.

atomic_flutter is not the right choice for every project. If you’re building a banking app that needs strict event sourcing and audit trails, BLoC’s event-driven architecture gives you that for free. If you need a sophisticated dependency injection system with scoped overrides for testing, Riverpod’s ref-based model is purpose-built for it.

atomic_flutter prioritizes simplicity and directness over structural enforcement. It trusts the developer to organize their code sensibly rather than forcing a particular architecture through the type system. For some teams, that freedom is exactly right. For others, the guardrails of BLoC or Riverpod are what keep large codebases manageable. (That said, the middleware system in 0.5.0 gives you a hook to enforce invariants globally if you need it.)

The atoms-as-globals pattern also means you’re responsible for your own cleanup. atomic_flutter has autoDispose, configurable disposal timeouts, and the WatchAtom mixin handles subscription cleanup automatically — but it won’t prevent you from creating atoms that live forever if you’re not thoughtful about it.


Where It’s Going

atomic_flutter is at version 0.5.0. With middleware, persistence, undo/redo, WatchAtom, and cross-atom batching all shipped, the core feature set feels solid. But there’s always more to build.

The package ships with a Flutter DevTools extension — when you depend on atomic_flutter and open DevTools, an “AtomicFlutter” tab appears alongside Widget Inspector, Performance, and the rest. It includes an atom inspector with live state, an interactive dependency graph, an async timeline, and a performance dashboard that surfaces hot atoms and rebuild rankings. I’m actively improving it with new capabilities.

Beyond DevTools, here’s what’s on the roadmap:

  • Atom families for parameterized, per-key state (think userAtom(userId) instead of manually managing a map)
  • Scoped atoms for widget-tree-scoped overrides without losing the simplicity of global atoms
  • Code generation to reduce even the small amount of boilerplate that remains
  • Better testing utilities with built-in helpers for overriding atoms in tests
  • Expanded DevTools with time-travel debugging and state snapshots

Try It

If any of this resonated — if you’ve ever felt like your state management solution was doing more to you than for you — give atomic_flutter a look.

dependencies:
  atomic_flutter: ^0.5.0

The GitHub repo has a full example app, API documentation, and a growing collection of patterns for common scenarios.

I built this because I wanted to enjoy managing state in Flutter. I hope it helps you feel the same way.