> For the complete documentation index, see [llms.txt](https://wong-coupon.gitbook.io/flutter/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://wong-coupon.gitbook.io/flutter/my-flutter/architecture-state/redux-flutter-redux-large-app.md).

# Redux/Flutter Redux at Scale

Organize Redux and Flutter Redux by feature so a global store remains manageable as the application grows

## Result

After splitting the Redux store by feature, each module in my app owns its state, actions, reducer, and selectors. The root store only composes modules and registers shared middleware, while each widget receives a small ViewModel instead of reading the entire `AppState`.

The final structure looks like this:

```
                          AppState
                             │
          ┌──────────────────┼──────────────────┐
          │                  │                  │
       Session            Catalog           Settings
          │                  │                  │
   action/reducer     action/reducer     action/reducer
      selector           selector           selector
          │                  │                  │
          └──────────────────┴──────────────────┘
                             │
                       Root composition
```

There is still one global state tree, but every change has a clear owner. When I add a feature, I no longer need every widget and service to understand the structure of the entire store.

This is a shared Dart/Flutter architecture. It has no Android- or iOS-specific implementation.

## Problem

When my app had only a few screens, one `AppState`, one reducer, and several actions were easy to understand. As the number of features grew, however, the difficult part was no longer writing a reducer.

Each feature started adding fields to `AppState`, registering reducers at the root, and placing side effects in a shared middleware list. The store still worked, but several warning signs appeared:

* The root store imported almost every feature.
* Adding a feature always required changes to multiple central files.
* Widgets read `store.state` directly and became coupled to the shape of `AppState`.
* Reducers performed API calls, logging, or navigation and were no longer pure functions.
* Actions used `dynamic` payloads, so cast errors appeared only at runtime.
* A singleton store made tests depend on state left by earlier tests.
* Logout or session switching had to reset multiple state slices without one clear rule.

A global store does not scale automatically just because all state is kept in one place. Redux remains manageable only when every feature has its own boundary and the root is limited to composition.

Not every state belongs in Redux. If state exists only for one widget, putting it in a global store usually makes the solution more complicated than the problem.

## Solution

### Choose global state by lifetime and scope

Before creating an action or reducer, I check how long the state must live and how many features need it.

| State type                                 | Suitable location          | Example                                            |
| ------------------------------------------ | -------------------------- | -------------------------------------------------- |
| Read by multiple features                  | Redux                      | Current session, permissions, shared configuration |
| A workflow spanning multiple screens       | Redux                      | Multi-step checkout                                |
| Requires an action log for tracing changes | Redux                      | Data synchronization or a request lifecycle        |
| Belongs to one widget                      | Local state/ChangeNotifier | Selected tab, animation, expanded state            |
| A form that has not been submitted         | Close to the screen        | Input and immediate validation                     |
| Can be derived from other state            | Selector                   | Item count or a filtered list                      |

For state used only to control one widget, I keep it close to that widget. The ChangeNotifier article explains how to create a Flutter-native controller for this case, so I do not repeat controller lifecycle management here.

{% content-ref url="/pages/tFi0DZUoGC2jkEso9Mqu" %}
[ChangeNotifier](/flutter/my-flutter/architecture-state/changenotifier.md)
{% endcontent-ref %}

Application size is not the only criterion. Lifetime, usage scope, and the need to trace changes determine whether state belongs in Redux.

### Let each feature own a state slice

I start with a feature-based structure:

```
lib/state/
├── app_state.dart
├── app_reducer.dart
└── catalog/
    ├── catalog_state.dart
    ├── catalog_actions.dart
    ├── catalog_reducer.dart
    ├── catalog_selectors.dart
    └── catalog_middleware.dart
```

For a small module, actions, reducers, and selectors can live in one “duck” file. When the file begins to contain multiple independent action groups and side effects, I split it by responsibility. The number of files matters less than knowing which feature owns the code.

A state slice should be immutable and have an explicit initial value:

```dart
final class CatalogState {
  const CatalogState({
    this.items = const [],
    this.isLoading = false,
    this.errorMessage,
  });

  final List<String> items;
  final bool isLoading;
  final String? errorMessage;

  CatalogState copyWith({
    List<String>? items,
    bool? isLoading,
    String? Function()? errorMessage,
  }) {
    return CatalogState(
      items: items ?? this.items,
      isLoading: isLoading ?? this.isLoading,
      errorMessage:
          errorMessage == null ? this.errorMessage : errorMessage(),
    );
  }
}
```

The callback for `errorMessage` distinguishes between keeping the previous value and intentionally setting it to `null`.

### Use typed actions instead of scattered strings

Redux accepts `dynamic` actions, but that does not mean the entire application should depend on strings and untyped payloads.

```dart
sealed class CatalogAction {
  const CatalogAction();
}

final class CatalogLoadRequested extends CatalogAction {
  const CatalogLoadRequested();
}

final class CatalogLoadSucceeded extends CatalogAction {
  const CatalogLoadSucceeded(this.items);

  final List<String> items;
}

final class CatalogLoadFailed extends CatalogAction {
  const CatalogLoadFailed(this.message);

  final String message;
}
```

With typed actions, the payload lives on the class itself. The IDE can find usages and safely rename the action. Reducers also avoid repeated `dynamic` casts.

If the project must serialize actions for logging or replay, you can still add a stable `name` to the base class. I avoid using a string as the only contract between the UI, middleware, and reducer.

### Keep reducers pure

A reducer only receives the current state and an action, then returns a new state:

```dart
CatalogState catalogReducer(CatalogState state, dynamic action) {
  return switch (action) {
    CatalogLoadRequested() => state.copyWith(
        isLoading: true,
        errorMessage: () => null,
      ),
    CatalogLoadSucceeded(:final items) => state.copyWith(
        items: items,
        isLoading: false,
      ),
    CatalogLoadFailed(:final message) => state.copyWith(
        isLoading: false,
        errorMessage: () => message,
      ),
    _ => state,
  };
}
```

Do not call an API, read storage, navigate, or log inside a reducer. When a reducer remains pure, the same state and action always produce the same result, so its unit test needs neither Flutter bindings nor a network.

The root state only composes slices:

```dart
final class AppState {
  const AppState({
    required this.session,
    required this.catalog,
    required this.settings,
  });

  factory AppState.initial() => const AppState(
        session: SessionState(),
        catalog: CatalogState(),
        settings: SettingsState(),
      );

  final SessionState session;
  final CatalogState catalog;
  final SettingsState settings;

  AppState copyWith({
    SessionState? session,
    CatalogState? catalog,
    SettingsState? settings,
  }) {
    return AppState(
      session: session ?? this.session,
      catalog: catalog ?? this.catalog,
      settings: settings ?? this.settings,
    );
  }
}
```

```dart
AppState appReducer(AppState state, dynamic action) {
  return state.copyWith(
    session: sessionReducer(state.session, action),
    catalog: catalogReducer(state.catalog, action),
    settings: settingsReducer(state.settings, action),
  );
}
```

Hydration, logout, or session switching may need to reset several slices together. I keep these global lifecycle decisions at the composition boundary, but the root reducer does not contain screen-specific logic.

### Move side effects and policies to middleware

API calls, logging, action filtering, and duplicate-request protection do not belong in reducers. A simple middleware can receive its repository as a dependency:

```dart
abstract interface class CatalogRepository {
  Future<List<String>> loadItems();
}

Middleware<AppState> createCatalogMiddleware(
  CatalogRepository repository,
) {
  return (store, action, next) async {
    next(action);

    if (action is! CatalogLoadRequested) return;

    try {
      final items = await repository.loadItems();
      store.dispatch(CatalogLoadSucceeded(items));
    } catch (error) {
      store.dispatch(CatalogLoadFailed(error.toString()));
    }
  };
}
```

I call `next(action)` first so the request action reaches the reducer and enables the loading state. The success or failure action then re-enters the same Redux pipeline.

The repository is the boundary between state management and the data layer. If you want the complete separation of Domain, Data, and Presentation, the Clean Architecture article owns that knowledge.

{% content-ref url="/pages/DOQf7tbR65m4o5Jlz62x" %}
[Flutter Clean Architecture](/flutter/my-flutter/architecture-state/flutter-clean-architecture.md)
{% endcontent-ref %}

The middleware above works for a simple Future. When a side effect needs debouncing, cancellation, double-submit protection, retry, or bounded parallelism, I move the concurrency policy to Redux Epics and RxDart.

{% content-ref url="/pages/YyM552Bk6JgngO4Jb0D9" %}
[Redux Epics and RxDart](/flutter/my-flutter/architecture-state/redux-epics-rxdart.md)
{% endcontent-ref %}

`ARC-02` owns the middleware boundary. The linked article explains how to choose between `asyncMap`, `switchMap`, `exhaustMap`, and `flatMap` without repeating that content here.

### Initialize the store once

I initialize the store before building the widget tree and provide it once near the root:

```dart
final store = Store<AppState>(
  appReducer,
  initialState: AppState.initial(),
  middleware: [
    createCatalogMiddleware(catalogRepository),
  ],
);

runApp(
  StoreProvider<AppState>(
    store: store,
    child: const App(),
  ),
);
```

`StoreProvider` lets descendant widgets retrieve the correct store. Services and the domain layer should not search for a `BuildContext` or read a global store variable; side-effect dependencies are passed to middleware or Epics at the composition root.

If the codebase already uses a singleton store, I still keep a way to create a new store for tests. Every test must have isolated state instead of sharing one instance across the entire suite.

### Use selectors and ViewModels as the read API

Selectors hide the internal structure of `AppState` from the UI:

```dart
List<String> selectCatalogItems(AppState state) => state.catalog.items;

bool selectCatalogLoading(AppState state) => state.catalog.isLoading;
```

If `catalog` moves within the state tree, I update the selector instead of every widget.

The widget receives a small ViewModel:

```dart
final class CatalogViewModel {
  const CatalogViewModel({
    required this.items,
    required this.isLoading,
    required this.reload,
  });

  final List<String> items;
  final bool isLoading;
  final VoidCallback reload;

  @override
  bool operator ==(Object other) {
    return other is CatalogViewModel &&
        identical(other.items, items) &&
        other.isLoading == isLoading;
  }

  @override
  int get hashCode => Object.hash(items, isLoading);
}
```

```dart
StoreConnector<AppState, CatalogViewModel>(
  distinct: true,
  converter: (store) => CatalogViewModel(
    items: selectCatalogItems(store.state),
    isLoading: selectCatalogLoading(store.state),
    reload: () => store.dispatch(const CatalogLoadRequested()),
  ),
  builder: (context, vm) {
    return CatalogView(
      items: vm.items,
      isLoading: vm.isLoading,
      onReload: vm.reload,
    );
  },
);
```

`distinct: true` only works when the ViewModel implements equality correctly. This example relies on immutable state, where every list change creates a new list instance.

`StoreProvider.of<AppState>(context)` only retrieves the store from an `InheritedWidget`; it does not create a selective subscription to `store.onChange`. When the UI must react to state, I use `StoreConnector` or `StoreBuilder`. When I only need to dispatch in `initState`, I can retrieve the store with `listen: false`.

For expensive derived data, you can use a memoized selector. A selector that only reads one field does not need caching yet.

### Test reducers and stores in isolation

A reducer test only needs a state and an action:

```dart
test('load success replaces items and stops loading', () {
  const state = CatalogState(isLoading: true);

  final next = catalogReducer(
    state,
    const CatalogLoadSucceeded(['Keyboard', 'Mouse']),
  );

  expect(next.items, ['Keyboard', 'Mouse']);
  expect(next.isLoading, isFalse);
});
```

For middleware tests, I use a fake repository, dispatch `CatalogLoadRequested`, and verify that the store receives a success or failure action. For widget tests, every test creates a new store and wraps the widget in `StoreProvider`.

I use this short checklist when adding a feature:

1. Explain why the state must be global.
2. Create the state slice and initial state inside the feature.
3. Create actions with explicit payloads.
4. Write reducer tests before registering the reducer at the root.
5. Create selectors as the read API.
6. Add middleware only when a real side effect or policy exists.
7. Decide whether the state resets on logout or session switching.
8. Do not persist the state by default.
9. Give widgets only the ViewModel and callbacks they need.

When state genuinely needs to survive across app sessions, the Redux Persist article continues from decision 8: selecting a persisted schema, migrating it by version, and protecting the load/save flow. I keep that work outside the store architecture article so each article has one clear responsibility.

{% content-ref url="/pages/rmFKA2Mf6X0rir3aOGF7" %}
[Redux Persist and State Migration](/flutter/my-flutter/architecture-state/redux-persist-state-migration.md)
{% endcontent-ref %}

When a codebase still contains Fish Redux screens, I do not rewrite the entire store at once. The migration article explains how I preserve the route contract, wrap legacy pages in the same Fluro registry, and move one vertical slice at a time toward the Redux structure described here.

{% content-ref url="/pages/1Z2MpTyQSwl5Y7CFKgbq" %}
[Incremental Fish Redux Migration](/flutter/my-flutter/architecture-state/incremental-fish-redux-migration.md)
{% endcontent-ref %}

### Common errors and trade-offs

#### Putting all state in Redux

**Symptom:** every small widget change requires an action and reducer.

**Fix:** keep short-lived state close to the widget; use the global store only for shared state or transitions that need tracing.

#### Calling an API inside a reducer

**Symptom:** reducer tests require network mocks, or the same action produces different results.

**Fix:** move the side effect to middleware or an Epic and keep the reducer pure.

#### Reading `store.state` directly in a widget

**Symptom:** the UI updates only when another rebuild happens by accident, or it becomes coupled to too many state branches.

**Fix:** use selectors with `StoreConnector` to create an explicit subscription and ViewModel.

#### Enabling `distinct` without ViewModel equality

**Symptom:** the widget still rebuilds when displayed data has not changed, or a required rebuild is skipped because equality is wrong.

**Fix:** define `==` and `hashCode` from the data used by the UI and keep state immutable.

#### One duck file grows too large

Keeping actions, reducers, and side effects together makes a feature easy to find at first. As the file grows, that locality becomes a file that is difficult to review. I split it by responsibility while keeping the files inside the same feature.

Redux provides a clear and traceable action flow, but the team must maintain actions, reducers, selectors, and middleware. For a local feature, Bloc/Cubit or ChangeNotifier may be shorter. The Bloc article presents another event-driven approach so you can compare requirements instead of choosing one tool for the entire app.

{% content-ref url="/pages/V3SeIz7l08DCG5hi9prc" %}
[Bloc](/flutter/my-flutter/architecture-state/bloc.md)
{% endcontent-ref %}

### Verified versions

* Flutter: 3.41.2.
* Dart: 3.11 to before 4.0.
* `redux`: 5.0.0.
* `flutter_redux`: 0.10.0.
* Platform: Shared.
* Verified on: 2026-08-23.

### References

* [redux — Dart API documentation](https://pub.dev/documentation/redux/latest/)
* [redux — Store](https://pub.dev/documentation/redux/latest/redux/Store-class.html)
* [redux — Middleware](https://pub.dev/documentation/redux/latest/redux/Middleware.html)
* [flutter\_redux — StoreProvider](https://pub.dev/documentation/flutter_redux/latest/flutter_redux/StoreProvider-class.html)
* [flutter\_redux — StoreConnector](https://pub.dev/documentation/flutter_redux/latest/flutter_redux/StoreConnector-class.html)
* [reselect — Dart API documentation](https://pub.dev/documentation/reselect/latest/)

## Conclusion

Redux fits a large Flutter app when multiple features coordinate through one action pipeline and the team wants state transitions to be traceable in one place. However, a global store remains maintainable only when state has clear ownership, reducers stay pure, side effects live outside reducers, and the UI listens only to the data it needs.

If a side effect is only a simple Future, middleware is often enough. When several requests compete, the Redux Epics article helps you decide which request should queue, be dropped, or be allowed to update state.

[Buy Me a Coffee](https://buymeacoffee.com/ducmng12g) | [Support Me on Ko-fi](https://ko-fi.com/I2I81AEJG8)
