Managing 70+ Screens in the Naberise App

Published on September 13, 2025 3 min read

In Flutter projects, state management and context-dependent in-page operations are usually handled separately. While Riverpod provides a strong solution for state management, tasks that require a BuildContext—such as navigation, snackbars, or dialogs—often remain tied to the widget layer. In this article, I’ll walk through how we manage more than 70 screens in Naberise using the BaseController and BaseView architecture.

If I have 70 screens, I could just write a ChangeNotifier-based controller or view model for each one and live happily ever after :). But what happens when I need to make a change across all of them...?
https://medium.com/media/f10210af83ebb74c279c2ff1d9ed4946/href
abstract class BaseController extends ChangeNotifier {
BaseController({required this.ref});

final Ref ref;

BuildContext? get context =>
_contextStack.isEmpty ? null : _contextStack.last;


final List<BuildContext> _contextStack = [];


void addContext(BuildContext context) {
_contextStack.add(context);
}


void removeContext() {
if (_contextStack.isNotEmpty) {
_contextStack.removeLast();
}
}

Each of these controllers takes a Ref object, since we’re using Riverpod. On top of that, we also ask for a context stack that keeps track of every nested route navigated from that page. Why? Because sometimes I may need access to a specific inner context — for example, to show a dialog or a snackbar within a child page whenever necessary.

And of course, I also need something like a BaseView that directly works with this BaseController. This way, I don’t have to repeat the same boilerplate for every screen — the view and controller can stay tightly connected while keeping the code clean and consistent.
abstract class BaseView<F extends BaseController>
extends ConsumerStatefulWidget {
const BaseView({super.key, required this.controller});
final ProviderListenable<F> controller;
}

abstract class BaseViewState<B extends BaseView, S extends BaseController>
extends ConsumerState<BaseView> {
@override
B get widget => super.widget as B;
late S controller = ref.read(widget.controller as ProviderListenable<S>);

@override
void initState() {
super.initState();
if (context.mounted) {
controller.addContext(context);
} else {
controller.addContext(rootNavigatorKey.currentState!.context);
}
}

@override
void didChangeDependencies() {
super.didChangeDependencies();
if (context.mounted) {
controller.addContext(context);
} else {
controller.addContext(rootNavigatorKey.currentState!.context);
}
}

@override
void dispose() {
controller.removeContext();
super.dispose();
}
}

Since most pages will be stateful, this class extends ConsumerStatefullWidget. Inside it, we keep a ProviderListenable<F> controller, so that on the page I can simply call controller instead of having to do a ref.read() every time. The generic type F is constrained to extend our BaseController. Based on the page’s lifecycle, we also add the current BuildContext to our stack, which allows the controller to access context when needed. This way, whenever a method in the controller requires context, we don’t have to explicitly pass it down from the widget layer.

So, how do we actually use this setup?
final homeControllerProvider = ChangeNotifierProvider(
(ref) => HomeController(ref: ref),
);

class HomeController extends BaseController {
HomeController({required super.ref});

void goToDetail() {
if (context != null) {
Navigator.of(context!).pushNamed('/detail');
}
}
}

Let’s start with a tiny example controller. Then, right below it, we’ll build a small page to see how it all comes together.

class HomeView extends BaseView<HomeController> {
const HomeView({super.key}) : super(controller: homeControllerProvider);

@override
ConsumerState<HomeView> createState() => _HomeViewState();
}

class _HomeViewState extends BaseViewState<HomeView, HomeController> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Home')),
body: Center(
child: ElevatedButton(
onPressed: controller.goToDetail,
child: const Text('Go to Detail'),
),
),
);
}
}

using ref.read() effectively calls the controller directly. If we want to listen to something on the page, however,

final myProperty = ref.watch(widget.controller).myProperty

access like this.

In this article, I talked about the structure we used to manage over 70 screens in the Naberise social media app. I hope you found it helpful. See you in future articles, and have a great weekend 🎈.

Managing 70+ Screens in the Naberise App

Category: flutter-app-developmentTags: flutter-app-development, mobile-app-development, software-engineering, flutter, clean-code