Flutter Singletons: What to do Instead and How to Avoid Them

Let’s dive into the world of software development and explore the fascinating topic of singletons. This discussion is all about shedding light on the role of singletons in Dart/Flutter development. We’ll be delving into the different perspectives and lively debates surrounding this hot topic. So, whether you’re a developer who believes in their value or someone who thinks they should be avoided, this is the perfect opportunity to gain a deeper understanding. Let’s explore together!

By the end of this exploration, you’ll understand why singletons can make code less maintainable and testable and learn about alternative strategies to consider.

What is a Singleton?

A singleton is a design pattern that restricts the instantiation of a class to a single instance and provides a global point of access to it. According to Wikipedia, the singleton pattern:

  • Limits the number of instances of a class to one.
  • Provides easy access to that instance.
  • Controls the instantiation process.
  • Restricts the number of instances.
  • Acts as a global variable.

In essence, a singleton pattern guarantees that only one instance of a class is created, making it accessible globally. This is particularly useful for managing shared resources or coordinating certain actions across an application.

Implementing a Singleton in Dart

Here’s a straightforward implementation of a singleton in Dart:

```dart
class Singleton {
 Singleton._(); // private constructor
 static final instance = Singleton._(); // single instance
}

By making the constructor private, you ensure that the class cannot be instantiated outside the file where it is defined. Consequently, the only way to access the singleton is through `Singleton.instance`. In some cases, a static getter variable might be preferred for additional flexibility.

Examples of Singletons in Flutter

Firebase plugins in Flutter utilize the singleton pattern extensively. For instance, signing in anonymously can be achieved with the following code:

```dart
ElevatedButton(
 onPressed: () => FirebaseAuth.instance.signInAnonymously(),
 child: Text('Sign in anonymously'),
)

Other Firebase functionalities also rely on singletons, such as:

```dart
FirebaseFirestore.instance.doc('path/to/document');
FirebaseFunctions.instance.httpsCallable('createOrder');
FirebaseMessaging.instance.deleteToken();
```

Drawbacks of Singletons

Despite their convenience, singletons have several significant drawbacks:

Testing Difficulties

Singletons can make your code hard to test. Consider the following example where a singleton is used:

```dart
class FirebaseAuthRepository {
Future<void> signOut() => FirebaseAuth.instance.signOut();
}
```

In this case, it’s impossible to write a test to check that `FirebaseAuth.instance.signOut()` is called. A better approach is to inject FirebaseAuth as a dependency:

```dart
class FirebaseAuthRepository {
const FirebaseAuthRepository(this._auth);
final FirebaseAuth _auth;
Future<void> signOut() => _auth.signOut();
}
```

This allows for easier mocking and testing of the dependency.

Implicit Dependencies

Singletons can obscure dependencies within your code, making maintenance more challenging. Dependencies become much clearer when passed as explicit constructor arguments:

```dart
class FirebaseAuthRepository {
const FirebaseAuthRepository(this._auth);
final FirebaseAuth _auth;
Future<void> signOut() => _auth.signOut();
}
```

Enhance Your Flutter App Today. Hire Our Developers Now!

Lazy Initialization

Initializing certain objects can be resource-intensive. The `late` keyword in Dart allows for deferred initialization:

```dart
void main() {
late final hardWorker = HardWorker.instance;
hardWorker.logResult();
}
```

However, this approach can be error-prone. Packages like `get_it` offer better control over initialization, ensuring objects are only created when first used.

Instance Lifecycle

Singleton instances persist for the application’s lifetime, which can lead to resource wastage if they consume significant memory or maintain open network connections. Packages like `get_it` and Riverpod provide better lifecycle management.

Thread Safety

While Dart’s main isolate structure mitigates most thread safety concerns, using singletons in multi-threaded environments requires careful handling to avoid issues with mutable data. For heavy computations involving multiple isolates, developers must ensure that singletons do not modify shared mutable data.

Alternatives to Singletons

Given the drawbacks associated with singletons, several alternatives can be more effective:

Dependency Injection

This design pattern involves passing dependencies as constructor arguments, promoting a clean separation of concerns. It makes classes independent of the creation of their dependencies, enhancing testability and maintainability.

```dart
class FirebaseAuthRepository {
 const FirebaseAuthRepository(this._auth);
 final FirebaseAuth _auth;
 Future<void> signOut() => _auth.signOut();
}
```

Service Locator with get_it. This package allows you to register dependencies as lazy singletons when the application starts. For instance:

```dart
void main() {
 final getIt = GetIt.instance;
 getIt.registerLazySingleton<FirebaseAuthRepository>(
  () => FirebaseAuthRepository(FirebaseAuth.instance),
);
runApp(const MyApp());
}
```

Service Locator with GetIt

This package allows you to register dependencies as lazy singletons when the application starts. For instance:

```dart
void main() {
 final getIt = GetIt.instance;
 getIt.registerLazySingleton<FirebaseAuthRepository>(
  () => FirebaseAuthRepository(FirebaseAuth.instance),
 );
 runApp(const MyApp());
}
```

Accessing the dependency later is straightforward:

```dart
final authRepository = getIt.get<FirebaseAuthRepository>();
```

Riverpod Providers

Riverpod makes dependency management easier, especially for testing. Providers are created as global variables and accessed when needed:

```dart
final authRepositoryProvider = Provider<FirebaseAuthRepository>((ref) {
return FirebaseAuthRepository(FirebaseAuth.instance);
});

final authRepository = ref.read(authRepositoryProvider);
```

Related read: Exploring Riverpod Flutter: For Optimal State Management in Flutter Applications

coma

Conclusion

Singletons may seem attractive due to their simplicity and ease of use, but they introduce numerous challenges that can complicate code maintenance and testing. Alternatives such as dependency injection, `get_it`, and Riverpod offer more robust solutions for managing dependencies, resulting in cleaner, more maintainable, and testable codebases.

When developing complex Flutter applications, adopting a well-structured architecture that supports the growth of your codebase and ensures clear, manageable dependencies is crucial. You can build more robust and scalable applications by avoiding the pitfalls of singletons and leveraging effective dependency management strategies.

Happy Fluttering!!

Keep Reading

Keep Reading

  • Service
  • Career
  • Let's create something together!

  • We’re looking for the best. Are you in?