> 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/incremental-fish-redux-migration.md).

# Incremental Fish Redux Migration

How I migrate Fish Redux screens to Flutter Redux while preserving routes, arguments, deep links, and the back stack

## Result

In my app, legacy Fish Redux screens and new Flutter widgets do not use two separate navigation systems. I bring both into the same Fluro registry:

```
Call sites + Deep links
          │
          ▼
     AppNavigator
          │
          ▼
  One Fluro registry
      ┌───┴─────────────┐
      │                 │
      ▼                 ▼
New WidgetBuilder   LegacyPageAdapter
      │                 │
      ▼                 ▼
Flutter Redux page  Fish Redux Page
```

Call sites only know the route contract. The registry decides whether a route currently builds a new widget or a Fish Redux `Page` behind a temporary adapter.

When I migrate a screen, I preserve:

* The route name.
* Arguments and navigation results.
* Whether the route uses `push`, `replace`, or replaces the root.
* Deep links, including path and query parameters.
* Transitions, access policies, and back-stack behavior.

What changes is the screen's internal implementation: Fish Redux `State`, `Action`, `Reducer`, `Effect`, and `View` move to a state slice, typed actions, a reducer, an Epic or middleware, and a new widget.

This approach lets me continue releasing the app instead of waiting for a complete rewrite. Each change affects one vertical slice and can be tested or rolled back at the route boundary.

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

## Problem

A Fish Redux screen is more than its UI. It commonly includes a `Page`, `State`, `Action`, `Reducer`, `Effect`, `View`, connectors, and child components. Page-level middleware may also handle lifecycle events, analytics, logging, or errors.

If I only rewrite the `View` and delete the old page, the app may still compile while silently losing behavior hidden in middleware. If I migrate state first but keep the old `Effect`, the new UI and legacy logic may both update the same data. If an `Effect` and an Epic receive the same intent, one tap may call an API twice.

I also do not want a second router for new code. Two routers create two route registries and two sets of policies:

| Two independent routers                | One router with an adapter              |
| -------------------------------------- | --------------------------------------- |
| Two places to register routes          | One registry is the source of truth     |
| Route names can collide or drift       | The route contract stays stable         |
| Deep links must select a router        | Deep links resolve through one registry |
| Observers and transitions can differ   | Policies live at one boundary           |
| Call sites must know old versus new    | Call sites only know `AppNavigator`     |
| Cross-router back stacks are difficult | One `Navigator` owns the stack          |

A nested `Navigator` still makes sense when a flow genuinely needs an independent stack. I do not create one only to hide the fact that two screens use different state-management frameworks.

The last problem is migration scope. If I organize the work by file type—for example, migrate all reducers before any views—a feature remains half-migrated for too long. I use a route and its vertical slice as the migration unit: one user flow moves completely before I start the next one.

## Solution

### Freeze the route contract before changing code

Before migrating a screen, I record the contract used by the rest of the app:

| Contract        | What I verify                                                  |
| --------------- | -------------------------------------------------------------- |
| Route name      | Which call sites and deep links use it?                        |
| Arguments       | What are the current types, nullable fields, and defaults?     |
| Result          | Does the caller await `pop(result)`?                           |
| Navigation mode | Does it push, replace, set the root, or clear the stack?       |
| Back behavior   | Where do system back and app-bar back return?                  |
| Transition      | Does the route use a shared or custom transition?              |
| Access policy   | Does it require login or a feature flag?                       |
| Shell           | Does it use a shared tab bar, app bar, or wrapper?             |
| Deep link       | How are path parameters, queries, and aliases mapped?          |
| Observability   | Where are screen views, lifecycle events, and errors recorded? |

The migration may replace the implementation, but it must not accidentally change this contract. When I want to change a route or its arguments, I make that a separate change with its own review and tests.

### Keep one navigation composition root

My app configures one top-level `MaterialApp`, one navigator key, one set of observers, and one `onGenerateRoute` callback:

```dart
import 'package:fluro/fluro.dart';
import 'package:flutter/material.dart';

final appNavigatorKey = GlobalKey<NavigatorState>();
final appRouter = FluroRouter();

MaterialApp(
  navigatorKey: appNavigatorKey,
  navigatorObservers: [analyticsObserver],
  initialRoute: AppRoutes.home,
  onGenerateRoute: appRouter.generator,
);
```

The global variable is not the important part. What I preserve is one boundary that owns app-level route generation and the back stack.

### Wrap a Fish Redux Page as a WidgetBuilder

The legacy Fish Redux registry is a `Map<String, Page<Object, dynamic>>`, while the new router needs a `Map<String, WidgetBuilder>`. I adapt the two interfaces at that boundary:

```dart
import 'package:fish_redux/fish_redux.dart';
import 'package:flutter/material.dart';

Map<String, WidgetBuilder> adaptLegacyPages(
  Map<String, Page<Object, dynamic>> legacyPages,
) {
  return legacyPages.map(
    (routeName, page) => MapEntry(
      routeName,
      (context) => LegacyPageAdapter(
        page: page,
      ),
    ),
  );
}

final class LegacyPageAdapter extends StatelessWidget {
  const LegacyPageAdapter({
    required this.page,
    super.key,
  });

  final Page<Object, dynamic> page;

  @override
  Widget build(BuildContext context) {
    final arguments = ModalRoute.of(context)?.settings.arguments;
    return page.buildPage(arguments);
  }
}
```

The registry key preserves the route name. The adapter reads arguments from `RouteSettings` and passes them to `buildPage`. If the app has a small shell policy, such as showing a tab bar for particular routes, the route name can also be passed into the adapter for that policy.

I do not put API calls, state mapping, or business logic in the adapter. This is transitional code, and it must remain small enough to delete after the final consumer is gone.

### Put legacy and new pages in one registry

I spread the legacy builders into the same map as new widgets:

```dart
final Map<String, WidgetBuilder> routeBuilders = {
  ...adaptLegacyPages(legacyPages),
  AppRoutes.home: (_) => const HomePage(),
  AppRoutes.profile: (_) => const ProfilePage(),
};
```

I then register the unified map with Fluro:

```dart
void registerRoutes() {
  for (final entry in routeBuilders.entries) {
    final routeName = entry.key;
    final builder = entry.value;

    appRouter.define(
      routeName,
      handler: Handler(
        handlerFunc: (context, parameters) {
          if (context == null) {
            throw StateError('Route $routeName requires a BuildContext');
          }

          return builder(context);
        },
      ),
      transitionType: transitionFor(routeName),
    );
  }
}
```

Fluro only sees a handler that returns a widget. It does not need to know whether that widget is a new Flutter Redux page or a Fish Redux `Page` behind an adapter.

When I migrate a route, I remove it from `legacyPages` and add the new widget under the same route name. I do not keep duplicate entries indefinitely and depend on map overwrite order.

### Hide the implementation behind AppNavigator

Call sites should not invoke a Fish Redux routing utility or Fluro directly. In my app, a navigation facade hides operations such as `push`, `replace`, setting the root, and popping a route.

The current source facade uses static methods and dynamically typed arguments. The public example below makes the boundary clearer with generic results and `Object?` arguments. This is an improvement I recommend, not a class that already exists unchanged in the source.

```dart
abstract interface class AppNavigator {
  Future<T?> push<T>(
    String routeName, {
    Object? arguments,
  });

  Future<T?> replace<T>(
    String routeName, {
    Object? arguments,
  });
}
```

The implementation always passes `RouteSettings`:

```dart
final class FluroAppNavigator implements AppNavigator {
  FluroAppNavigator({
    required this.router,
    required this.navigatorKey,
  });

  final FluroRouter router;
  final GlobalKey<NavigatorState> navigatorKey;

  BuildContext get _context {
    final context = navigatorKey.currentContext;
    if (context == null) {
      throw StateError('Navigator is not ready');
    }
    return context;
  }

  @override
  Future<T?> push<T>(
    String routeName, {
    Object? arguments,
  }) async {
    final result = await router.navigateTo(
      _context,
      routeName,
      routeSettings: RouteSettings(
        name: routeName,
        arguments: arguments,
      ),
    );

    return result as T?;
  }

  @override
  Future<T?> replace<T>(
    String routeName, {
    Object? arguments,
  }) async {
    final result = await router.navigateTo(
      _context,
      routeName,
      replace: true,
      routeSettings: RouteSettings(
        name: routeName,
        arguments: arguments,
      ),
    );

    return result as T?;
  }
}
```

Because call sites use the facade, they do not change when the builder behind a route is replaced:

```dart
final selectedProfile = await navigator.push<ProfileResult>(
  AppRoutes.profile,
  arguments: const ProfileArguments(userId: 'demo-user'),
);
```

My legacy code once had a navigation utility that held a Fish Redux `Context`, a global context, and several feature-specific flows. That utility kept the app running during the transition, but it is not the target architecture.

I remove this dependency in the following order:

1. Route basic navigation operations through `AppNavigator`.
2. Move feature-specific flows into a feature coordinator or service.
3. Do not let new code accept a `fish_redux.Context`.
4. Delete the global legacy context after its final consumer is gone.

### Migrate one complete vertical slice

I use a route as the unit for tracking progress:

```
Freeze the route contract
          │
          ▼
Lock current behavior with tests
          │
          ▼
Migrate state + actions + reducer
          │
          ▼
Migrate Effect + View
          │
          ▼
Swap the builder, keep the route name
          │
          ▼
Run parity tests
          │
          ▼
Remove the legacy route and Fish Redux files
```

Each Fish Redux concept has a reasonable destination, but I do not force a one-to-one mapping:

| Fish Redux      | Destination I use                             | Note                                          |
| --------------- | --------------------------------------------- | --------------------------------------------- |
| `Page`          | Widget page + route builder                   | Keep the route name                           |
| `State`         | Redux feature state or local widget state     | Do not put every UI state in the global store |
| `Action`        | Typed Redux action                            | Keep payloads explicit                        |
| `Reducer`       | Pure feature reducer                          | Do not include APIs or navigation             |
| `Effect`        | Redux middleware or Epic                      | Give each side effect one owner               |
| `Connector`     | Selector + ViewModel + `StoreConnector`       | Let the UI read only what it needs            |
| `PageRoutes`    | Unified registry + Fluro handler              | One source of truth                           |
| Page middleware | Observer, Redux middleware, or error boundary | Split by concern                              |
| Fish `Context`  | `AppNavigator` and the correct `BuildContext` | Do not spread the legacy dependency           |

Focus, animations, text drafts, and expand or collapse state that belong to one widget remain local state. Leaving Fish Redux is not a reason to move all state into global Redux.

The Redux/Flutter Redux article owns how I divide feature state, reducers, selectors, and `StoreConnector`s. This article only uses that structure as the migration destination.

{% content-ref url="/pages/flT55X8RgxKD0XpLXtUC" %}
[Redux/Flutter Redux at Scale](/flutter/my-flutter/architecture-state/redux-flutter-redux-large-app.md)
{% endcontent-ref %}

### Move Effects without running side effects twice

Before replacing a Fish Redux `Effect`, I inventory:

* Its input action.
* The API, stream, or timer it invokes.
* Its success and failure actions.
* Cancellation when the page is disposed or a new action arrives.
* Retry, debounce, throttle, and ordering behavior.
* Loading state and error reporting.

I only enable the new route after the old Effect can no longer receive the same intent. I do not let an Effect and an Epic both handle one user action when both can call an API.

If a side effect only waits for a Future, Redux middleware may be enough. For search, double submission, or competing requests, I move concurrency policy to an Epic and RxDart. The Redux Epics article owns how I choose `switchMap`, `exhaustMap`, `asyncMap`, and `flatMap`, so I do not repeat it here.

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

### Move behavior hidden in page middleware

Fish Redux pages in my app also attach middleware for lifecycle analytics, error handling, safety, and action logging. This is the easiest behavior to lose when a migration only focuses on widgets.

I move each concern to the appropriate owner:

| Legacy concern                   | New owner                             |
| -------------------------------- | ------------------------------------- |
| Page or screen view              | `NavigatorObserver`                   |
| Route transition                 | Route registry or Fluro configuration |
| API failure normalization        | Service, middleware, or Epic          |
| App-wide error UI                | Shared error presenter or boundary    |
| Action logging                   | Redux middleware with redaction       |
| Screen-specific widget lifecycle | New widget or page                    |

I do not copy all page middleware into a new “legacy middleware.” That would only move the old dependency. Each behavior must be able to survive after Fish Redux is removed.

### Define done for each route

I only consider a screen migrated when:

1. Its route name, arguments, result, and navigation mode still match the contract.
2. Related deep links still resolve to the correct screen.
3. The feature state has one owner.
4. The old Effect and new Epic or middleware do not run in parallel.
5. Analytics, lifecycle, logging, and error policies have new owners.
6. The route is gone from the legacy page map.
7. The feature no longer imports `fish_redux` or uses a Fish `Context`.
8. Old actions, effects, reducers, state, views, and components are removed.
9. Relevant unit, widget, route-contract, and smoke tests pass.

I remove the Fish Redux dependency from `pubspec.yaml` only when the entire app has no consumers. Completing one route does not mean the app-wide migration is complete.

### Verify behavior parity

I divide verification into four layers.

#### Route contract

* The legacy adapter builds the correct page.
* `RouteSettings.arguments` reach the legacy page unchanged.
* The new widget receives the same typed arguments.
* `push`, `replace`, setting the root, and clearing the stack preserve behavior.
* `pop(result)` returns the expected type to the caller.
* Unknown routes use the not-found policy.

#### Deep links

* A static path resolves to the correct route.
* Path parameters are parsed correctly.
* Query parameters are normalized correctly.
* Legacy aliases work during the compatibility window.
* Links that require login still pass through the shared access policy.

A test with a neutral route can look like this:

```dart
test('parses a path parameter', () {
  final navigation = Navigation.fromUrl(
    'myapp://open/profile/42?source=notification',
  );

  expect(navigation.routeName, '/profile/:id');
  expect(navigation.parameters, {
    'id': '42',
    'source': 'notification',
  });
});
```

#### State and side effects

* The new reducer preserves important state transitions.
* Middleware or an Epic calls the repository exactly once per intent.
* Cancellation, ordering, and retry behavior match the old Effect.
* Two frameworks do not both dispatch error and loading state.

#### UI and observability

* Widget tests cover important loading, success, empty, and failure states.
* The navigator observer still receives the screen transition.
* The global error policy still receives normalized failures.
* Logs do not contain route arguments or sensitive data.
* A smoke test navigates from a legacy page to a new page and back through the expected stack.

The reference source already contains tests for deep-link parameters, the navigation facade, typed arguments, and route-builder overrides in widget tests. However, I have not found a generic contract suite for `LegacyPageAdapter`. This is a gap I still need to close, not a test suite I claim to have completed.

### Common errors and trade-offs

#### Migrating the UI but forgetting page middleware

**Symptom:** the new screen renders correctly, but screen analytics disappear or errors no longer reach the shared handler.

**Fix:** inventory middleware before deleting the `Page`, then move each concern to an observer, Redux middleware, or an error boundary.

#### Letting an Effect and an Epic receive the same intent

**Symptom:** one tap creates two requests or updates state twice.

**Fix:** enable the new route only after the side effect has one owner, and test the repository call count.

#### Changing the route name with the framework

**Symptom:** existing call sites, deep links, or notifications can no longer open the new screen.

**Fix:** preserve the route name during migration. Change the route contract separately if it genuinely needs to change.

#### Putting business logic in the adapter

**Symptom:** every route adds feature-specific conditions to the adapter, and Fish Redux cannot be deleted even after the UI is migrated.

**Fix:** let the adapter translate interfaces only. Business behavior belongs to the new feature.

#### Using the global navigator key everywhere

**Symptom:** navigation runs before the navigator is ready or selects the wrong stack when nested navigators exist.

**Fix:** use the appropriate `BuildContext` when it is already available. Keep the global key as a fallback at the app boundary.

#### Moving every Fish State into global Redux

**Symptom:** focus, animations, and form drafts also need actions and reducers, making the new code heavier than the old code.

**Fix:** classify state by lifetime and scope. State owned by one widget remains local.

The adapter extends the lifetime of Fish Redux and introduces a transitional layer to maintain. In return, each migration is smaller, route contracts remain stable, and rollback is easier. The adapter only provides value when the legacy backlog and definition of done are tracked; otherwise, it becomes permanent architecture by accident.

### Verified versions

* Flutter: 3.41.2.
* Dart: 3.11 to before 4.0.
* `redux`: 5.0.0.
* `flutter_redux`: 0.10.0.
* `redux_epics`: 0.15.1.
* Fish Redux: legacy fork based on package 0.3.5.
* Fluro: legacy fork based on package 2.0.3.
* Platform: Shared.
* Verified on: 2026-08-25.

The reference app pins private Fish Redux and Fluro forks so it can continue running on its current Flutter version. I do not publish internal repositories or refs. The public Fish Redux package page currently marks the package as incompatible with Dart 3, so the adapter in this article is an exit strategy for a legacy dependency, not a recommendation to add Fish Redux to a new project.

### References

* [Fish Redux — source repository](https://github.com/alibaba/fish-redux)
* [Fish Redux — Flutter package](https://pub.dev/packages/fish_redux)
* [Fluro — source repository](https://github.com/lukepighetti/fluro)
* [Fluro — Flutter package](https://pub.dev/packages/fluro)
* [Flutter — MaterialApp.onGenerateRoute](https://api.flutter.dev/flutter/material/MaterialApp/onGenerateRoute.html)
* [Flutter — Navigator](https://api.flutter.dev/flutter/widgets/Navigator-class.html)
* [flutter\_redux — Flutter package](https://pub.dev/packages/flutter_redux)

## Conclusion

What makes my Fish Redux migration safer is not writing new code faster. I stabilize the boundary around each screen with one route contract, one Fluro registry, and one navigation facade. Remaining Fish Redux pages stay behind a small adapter until the new vertical slice reaches behavior parity.

This approach fits an app that must continue shipping while its architecture changes. If a project is small enough to rewrite and verify in one pass, the adapter may be unnecessary overhead.

After each route, I delete that slice's legacy entry and Fish Redux code. When the final consumer is gone, the adapter and old dependency must disappear as well. Otherwise, the migration has only reached “it runs,” not actual completion.

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