Igor's corner

Beyond the Empty Build: Evolving Riverpod Architecture

Published on

After developing for some time with BLoC, I finally made the switch to Riverpod. The benefits looked really clear as I saw Riverpod as an evolution of BLoC that goes beyond the abstractions of business logic and actually handles orchestration of the logic blocks. However, with this orchestration comes complexity, and not complexity of riverpod itself, but from the fact that you now can do the same thing in multiple ways, and it isn’t always obvious which path is the “right” one.

The Code with Andrea blog was a godsend during my initial search for a solid architecture. The system he came up with covers everything, but some things didn’t quite sit right with me. One such thing that immediately felt off was the empty build method in notifiers acting as view controllers.

The “Empty Build” Pattern

It may actually be the best way of handling a side effect’s (like signing in or submitting a form) state as opposed to the view state itself in the Riverpod 2.x, using an AsyncNotifier<void>. It looks like this:

@riverpod
class SignInScreenController extends _$SignInScreenController {
  @override
  FutureOr<void> build() {
    // no-op: This controller provides no initial data.
  }

  Future<void> signInAnonymously() async {
    state = const AsyncLoading();
    state = await AsyncValue.guard(() => ref.read(authRepositoryProvider).signInAnonymously());
  }
}

I think that at the time, this was a perfectly valid way to handle UI state. Thanks to Riverpod’s use of result wrapper to indicate if a side effect is ongoing or failed. But when migrating my logic from BLoCs I couldn’t get rid of feeling that the state has to be managed centrally without splitting the responsibility.

The Problem: Fragmented Responsibility

The empty build method essentially exports the state of the notifier back into the UI. Because the controller is “stateless” (returning void), it doesn’t actually hold the data the view needs. It only holds the status of the last action.

This forces the UI to manage a split personality:

  1. It watches the Controller to see if a button should show a spinner.
  2. It watches a different Provider elsewhere to get the actual data.

Instead of gracefully handling state transitions in a single place, the controller triggers an effect, updates a separate provider, and leaves the UI to interpret the result. This makes the UI a middleman for logic that the Notifier is perfectly capable of handling itself.

A Code Comparison: Split vs. Concentrated

To see why this matters, let’s look at how we update a user’s profile.

1. The Split State (Empty Build)

Here, the logic is fragmented. You have to manually keep two providers in sync.

FutureOr<void> build() {}
graph TD
    subgraph UI ["Widget Tree"]
        W[View / Widget]
    end

    subgraph Logic ["Riverpod Providers"]
        DP[Data Provider]
        Ctrl[Controller]
    end

    DB[(Database)]

    W -->|1. Call Action| Ctrl
    Ctrl -->|2. State = Loading| W
    Ctrl -->|3. Update DB| DB
    
    DB -.->|4. Emit Event| DP
    DP -->|5. Trigger Rebuild| W
    DP -.->|5. Trigger Rebuild| Ctrl
    
    style Ctrl fill:#f96,stroke:#333
    style DP fill:#bbf,stroke:#333

2. The Concentrated State (The BLoC-like Way)

By returning the actual data in the build method, the Notifier becomes the single source of truth.

FutureOr<User?> build() => userProvider.future;
graph TD
    subgraph UI ["Widget Tree"]
        W[View / Widget]
    end

    subgraph Logic ["Single Source of Truth"]
        N[User Notifier]
    end

    DB[(Database)]

    W -->|1. Call Action| N
    N -->|2. State = Loading| W
    N -->|3. Update DB| DB
    
    DB -->|4. Return Result| N
    N -->|5. State = Success + New Data| W
    
    style N fill:#dfd,stroke:#333

The Better Solution: Riverpod 3 Mutations

While the concentrated approach is cleaner, it has a downside: if the state is AsyncLoading, the entire UI usually replaces the data with a spinner. What if you want to keep showing the old data while the “Save” button spins?

This is where Remi, author of Riverpod got us covered with Mutations in version 3 solve this by separating the Application State (data) from the Transaction State (the side effect).

Instead of a “Void Controller,” you define a mutation:

// A standalone mutation to track the "Save" action
final updateNameMutation = Mutation<void>();

// UI Usage:
onPressed: () {
  updateNameMutation.run(ref, (tsx) => ref.read(userNotifierProvider.notifier).updateName(name));
}

// Watch 'updateNameMutation' for the button spinner, 
// while 'userNotifierProvider' continues to show the user data.

Conclusion

The “Empty Build” method was a necessary workaround in Riverpod 2.x for tracking side effects without a native tool. It was a bridge that helped us move away from manual setState logic in our widgets.

However, with the arrival of Riverpod 3, we can return to a more robust architecture. By using Mutations for side effects and keeping our Notifiers focused on holding data, we stop exporting state responsibility to the view. We can build upon the foundations laid by developers like Andrea while utilizing these new developments to keep our controllers meaningful and our UI predictable.