How to Avoid Prop Drilling in Flutter Without External Packages 🪛
Prop drilling happens when you keep passing values down the widget tree just so a deeper widget can use them. It works, but as soon as a project grows a little, the widget tree becomes noisy and harder to maintain.
Flutter already gives you everything you need to avoid this, so if your app is small there’s no need for external packages.
Here are some of the simplest native techniques:
Use InheritedWidget for Shared Data
InheritedWidget lets you expose data to an entire subtree without passing it manually through every widget.
class RepoScope extends InheritedWidget {
final CarRepository carRepository;
const RepoScope({
super.key,
required this.carRepository,
required super.child,
});
static CarRepository of(BuildContext context) {
final scope = context.dependOnInheritedWidgetOfExactType<RepoScope>();
assert(scope != null, 'No RepoScope found in context');
return scope!.carRepository;
}
@override
bool updateShouldNotify(RepoScope oldWidget) {
return carRepository != oldWidget.carRepository;
}
}
Usage:
final repo = RepoScope.of(context);
Perfect for: repositories, configuration, small shared services.
Use InheritedNotifier for Simple Reactive State
If the state needs to notify the UI, wrap a ChangeNotifier in an InheritedNotifier.
class CounterNotifier extends ChangeNotifier {
int _count = 0;
int get count => _count;
void increment() {
_count++;
notifyListeners();
}
}
class CounterScope extends InheritedNotifier<CounterNotifier> {
const CounterScope({
super.key,
required CounterNotifier super.notifier,
required super.child,
});
static CounterNotifier of(BuildContext context) {
final scope =
context.dependOnInheritedWidgetOfExactType<CounterScope>();
return scope!.notifier!;
}
}
Usage:
final counter = CounterScope.of(context);
Text('Count: ${counter.count}');
Every time increment() is being called, this widget will be rebuilt.
Use Singletons for Stateless Services
Some objects do not belong in the widget tree at all.
class Logger {
Logger._();
static final Logger instance = Logger._();
void log(String message) {
debugPrint('[LOG] $message');
}
}
Usage:
Logger.instance.log('Hello');
Perfect for: logging, analytics, config, simple data fetchers.
Pass Data Only at Navigation Time
Instead of sending values through multiple widgets, give them directly to the screen that needs them.
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => CarDetailScreen(carId: car.id),
),
);
This avoids polluting parents with props they do not use. Good for: IDs, models, detail screens, edit screens.
When Prop Drilling Is Actually Fine
If a parent and child are tightly connected and the data is simple, passing props is totally acceptable.
Avoid solutions that are more complex than the problem.
Final Thought
You do not need Riverpod, Provider, or Bloc to keep a small Flutter project clean. But if your app is big or starting to grow, I recommend using one of them before it is too late 😅
Flutter’s native tools already solve prop drilling as long as the data lives in the right place and is exposed in the right way.