> ## Content Index
> Fetch the complete content index at: https://arkar.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# Flutter State Management in 2026: Why I Built atomic_flutter
- URL: https://arkar.dev/flutter-state-management-in-2026-why-i-built-atomic_flutter/
- Published: 2026-03-19T01:16:16.000Z
- Updated: 2026-03-20T14:12:02.000Z
- Author: Arkar Min Tun
- Tags: 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](https://pub.dev/packages/atomic%5Fflutter?ref=arkar.dev). 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**:

```dart
// 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:

```dart
// 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`:

```dart
// 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.

```dart
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:

```dart
// 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:

```dart
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:

```dart
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:

```dart
// 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:

```dart
// 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:

```dart
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:

```dart
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:

```dart
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:

```dart
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.

```yaml
dependencies:
  atomic_flutter: ^0.5.0

```

The [GitHub repo](https://github.com/arkarmintun1/atomic%5Fflutter?ref=arkar.dev) 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.