Flutter : How to Remove Singletons from your App and Why?
FlutterPulseThis article was translated specially for the channel FlutterPulseYou'll find lots of interesting things related to Flutter on this channel. Don't hesitate to subscribe!🚀

Stop Abusing Singletons. Learn Why They're Problematic and How to Replace Them with Better Code.
Singletons in Flutter, like in many other programming environments which are classes that can only be instantiated once. This single instance is then accessible globally throughout your application. While convenient, singletons can introduce significant problems as your app grows. I wrote this article to showcase how to remove unwanted/abused singletons from your flutter codebases (not all valid singletons from your project), which decreases the maintainability and the scalability of application architecture. By addressing the challenges raised by singletons, such as tight coupling and hidden dependencies, we can increase the modularity and testability of Flutter projects.Ok now Let's Start.
Why Developers Love and Hate Them
Let's start with a simple example. Imagine you have an ApiClient to handle network requests. You might create a singleton like this below.
class ApiClient {
static final ApiClient _instance = ApiClient._internal();
factory ApiClient() {
return _instance;
}
ApiClient._internal();
Future<List<Task>> fetchTasks() async {
// Simulate a network request
await Future.delayed(Duration(seconds: 1));
return [
Task(id: 1, title: "Learn Flutter"),
Task(id: 2, title: "Remove Singletons"),
];
}
}
class Task {
final int id;
final String title;
Task({required this.id, required this.title});
}You can use above Client as below.
void main() async {
final apiClient = ApiClient();
final tasks = await apiClient.fetchTasks();
for (var task in tasks) {
print("Task: ${task.title}");
}
}This works, and it's incredibly easy. You have a single point of access to your API client, and you don't need to worry about passing it around. You might think "Why not use a singleton if you have a single instance?". "It makes Dependency Injection (DI) so much simpler!"
This is where the problems begin.
The Dark Side of Singletons
Singletons, while initially convenient, introduce several issues, especially in larger, more complex applications. Let's explore some of these:
1. Hidden Dependencies and Reduced Clarity
Singletons are often abused as shortcuts. They're globally accessible, so any part of your code can use them without explicitly declaring them as dependencies.
- Hidden Dependencies
It becomes unclear which parts of your code rely on the singleton. This makes understanding, debugging, and refactoring much harder. You might think a class is self-contained, but it could be secretly using a singleton.
- Reduced Clarity
Even with interfaces or protocols, it's not always obvious what concrete implementation lies behind them. While this is true even without singletons, singletons exacerbate the problem by making it too easy to introduce hidden dependencies.
You think that in some cases, a singleton might make code easier to understand by avoiding complex DI. While this might be true in very small, isolated cases, the long-term drawbacks generally outweigh this short-term benefit. It's like taking on technical debt from day one.
2. The Illusion of "Forever Single"
"Some think it's okay to create a singleton where you only have a single instance. But the fallacy is that people assume there will be a single instance forever." This is a crucial point. It's a bold claim to say, "We only need one instance today, and that will never change in the future."
Lets conside belowe scenarios.
- Testing: You might want to use a mock
ApiClientfor unit tests. With a singleton, this becomes difficult or impossible without significant code changes. - Multiple Configurations: Perhaps you need different
ApiClientinstances for different environments (development, staging, production) or different users. A singleton makes this inflexible. - Feature Flags: You might want to A/B test different API implementations. Again, a singleton hinders this.
Saying "we'll only ever need one" is a common trap that leads to inflexible and hard-to-maintain.
3. How about Modularization?
Imagine you have an app with a User singleton and two modules:Payments and Profile.
// app/lib/main.dart
import 'package:my_app/user.dart'; // Singleton
import 'package:my_app/payments/payments_screen.dart';
import 'package:my_app/profile/profile_screen.dart';
import 'package:flutter/material.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: Column(
children: [
PaymentsScreen(),
ProfileScreen(),
],
),
),
);
}
}
// app/lib/user.dart (Singleton)
class User {
static final User _instance = User._internal();
factory User() => _instance;
User._internal();
String name = "Initial Name";
String email = "initial@email.com";
}
// app/lib/payments/payments_screen.dart
import 'package:flutter/material.dart';
import 'package:my_app/user.dart'; // Depends on the singleton
class PaymentsScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Center(
child: Text("Payments for ${User().name}"), // Using the singleton
);
}
}
// app/lib/profile/profile_screen.dart
import 'package:flutter/material.dart';
import 'package:my_app/user.dart'; // Depends on the singleton
class ProfileScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Center(
child: Text("Profile of ${User().email}"),// Using the singleton
);
}
}
Now, let's say you want to extract the Payments module into its own package. You can't easily do it because PaymentsScreen directly depends on the User singleton, which is defined in the main app.

As above, the graph is bidirectional due to the tight coupling. The modules need the User singleton from the app.
Quick Fix for this (not recommended)
- Move the singleton to a shared module: This creates a "junk drawer" module that everything depends on which defeats the purpose of modularization.
- Move the singleton to the
Paymentsmodule: Now, everything that needsUserhas to depend onPayments, which is inconsistent.
Better Solution is Dependency Injection
The key is to pass dependencies explicitly. Let's refactor our code.
// app/lib/main.dart
import 'package:my_app/user.dart'; // No longer a singleton!
import 'package:my_app/payments/payments_screen.dart';
import 'package:my_app/profile/profile_screen.dart';
import 'package:flutter/material.dart';
void main() {
final user = User(name: "John Doe", email: "john.doe@example.com"); // Create the instance
runApp(MyApp(user: user));
}
class MyApp extends StatelessWidget {
final User user;
const MyApp({Key? key, required this.user}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: Column(
children: [
PaymentsScreen(user: user), // Pass the dependency
ProfileScreen(user: user),// Pass the dependency
],
),
),
);
}
}
// app/lib/user.dart (No longer a singleton)
class User {
final String name;
final String email;
User({required this.name, required this.email});
}
// app/lib/payments/payments_screen.dart
import 'package:flutter/material.dart';
import 'package:my_app/user.dart'; // Now depends on the User class, not a singleton
class PaymentsScreen extends StatelessWidget {
final User user;
const PaymentsScreen({Key? key, required this.user}) : super(key: key);
@override
Widget build(BuildContext context) {
return Center(
child: Text("Payments for ${user.name}"), // Using the passed-in user
);
}
}
// app/lib/profile/profile_screen.dart
import 'package:flutter/material.dart';
import 'package:my_app/user.dart'; // Now depends on the User class, not a singleton
class ProfileScreen extends StatelessWidget {
final User user;
const ProfileScreen({Key? key, required this.user}) : super(key: key);
@override
Widget build(BuildContext context) {
return Center(
child: Text("Profile of ${user.email}"),// Using the passed user
);
}
}

Now, the graph is unidirectional. The App creates the User instance and passes it to the Payments and Profile modules. Payments and Profile no longer have any knowledge of a global singleton. You could now easily extract Payments into its own package.
4. Threading Issues and Race Conditions
Singletons can lead to threading problems. Let's illustrate this with a simple counter app example for the easiness.
import 'dart:async';
class Counter {
static final Counter _instance = Counter._internal();
factory Counter() => _instance;
Counter._internal();
int _value = 0;
int get value => _value;
// Non-atomic increment with a delay to allow interleaving.
Future<void> increment() async {
int temp = _value;
// Introduce a small delay to simulate a context switch.
await Future.delayed(Duration(milliseconds: 1));
_value = temp + 1;
}
}
Future<void> main() async {
final counter = Counter();
// Two asynchronous tasks simulating concurrent increments.
Future<void> task() async {
for (int i = 0; i < 100; i++) {
await counter.increment();
}
}
await Future.wait([task(), task()]);
// The final value may be less than 200 due to race conditions.
print("Counter value: ${counter.value}");
}
In this example, increment() is not thread-safe. Two threads could read the same value of _value, increment it, and then write back the same (incorrect) result. This is a classic race condition( While Dart's single-threaded model prevents concurrency issues in this specific example, the operation itself is unsafe in a multi-threaded context).
"A thread-safe singleton is not enough"
Even if you make the singleton itself thread-safe (eg like using locks or atomic operations( Dart does not natively support atomic operations but dart provides some mechanisms like Isolates, Future and async/await)), it doesn't solve all threading problems.
Consider a payment scenario below.
class User {
static final User _instance = User._internal();
factory User() => _instance;
User._internal();
int _balance = 100;
int _userId = 123;
int get balance => _balance;
int get userId => _userId;
//Imagine Setter methods also implemented.
// (Simplified) Assume this is made thread-safe with a lock
void deductBalance(int amount) {
_balance -= amount;
}
}
class PaymentProvider {
void processPayment(int amount) {
final user = User(); // Get the singleton
if (user.balance >= amount) {
// ... some other operations ...
user.deductBalance(amount); // Deduct even if user data change.
}
}
}Even if deductBalance is thread-safe, there's still a problem. Between the if (user.balance >= amount) check and the user.deductBalance(amount) call, another thread could change the User singleton. You might be deducting from the wrong user's balance! The User singleton data integrity handled but the operation of payment is buggy.
Removing the Singleton Dependency (Gradual Approach)
The best solution is to pass the User as a dependency below.
class PaymentProvider {
final User user; // Receive the user as a dependency
PaymentProvider({required this.user});
void processPayment(int amount) {
//You can verify the user id again before updating the balance
if (user.balance >= amount && user.userId == this.user.userId) {
// ... some other operations ...
user.deductBalance(amount);
}
}
}Now, PaymentProvider is no longer tied to a global singleton. You can easily test it with different User instances.
Gradual Phase-Out.
passing dependencies as singletons is a way to gradually phase out a singleton. This means you start by injecting the singleton instance itself, and then progressively replace it with proper dependencies.
- Identify "Leaf" Instances: Find classes that use the singleton but don't pass it around further (like our
PaymentProviderexample). - Inject the Singleton: Modify these classes to accept the singleton instance as a dependency.
- Replace with Proper Dependencies: Once you've injected the singleton, you can start replacing it with a true dependency (eg. a
Userobject created and managed elsewhere). - Repeat: Continue this process until the singleton is only instantiated at the app's entry point.
- Final Removal: Finally, you can remove singleton instance.
This approach allows you to refactor a large codebase incrementally, minimizing disruption and risk.
While I didn't explain about the use cases for singleton which is not our scope in this article( But I will write about that on later article. So Dont forget to follow and wait).
Singletons, while tempting for their simplicity, often introduce significant problems in the long run. They create hidden dependencies, make testing difficult, and can lead to subtle threading issues. By following dependency injection and following a gradual refactoring process, you can remove singletons from your Flutter app and build more maintainable, and testable codebase. The effort is well worth it.
Happy coding! 🚀✨ Also Don't forget to drop me a few claps 👏 👏 👏 … !
Contact me via LinkedIn
Thank you for being a part of the community
Before you go:
- Be sure to clap and follow the writer ️👏️️
- Follow us: X | LinkedIn | YouTube | Newsletter | Podcast
- Check out CoFeed, the smart way to stay up-to-date with the latest in tech🧪
- Start your own free AI-powered blog on Differ 🚀
- Join our content creators community on Discord 🧑🏻💻
- For more content, visit plainenglish.io + stackademic.com