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

Singletons are a very controversial and debated topic in the software development community:

  • Some people say you should avoid them at all costs. ❌
  • Others are more pragmatic and use them only in specific cases. 🔍
  • And some use them willy-nilly like there's no tomorrow. 😅

To bring some clarity, this article will cover the following:

  • introduction to singletons in Dart/Flutter
  • what problems do they solve
  • what other problems they introduce and how to overcome them
  • alternatives to singletons

By the end, you'll better understand why singletons can make your code less maintainable and testable and what you can do instead.

Ready? let's go!

What Is a Singleton?

According to this page on Wikipedia:

The singleton pattern is a software design pattern that restricts the instantiation of a class to one "single" instance.

The page also says that the singleton pattern solves problems by allowing it to:

  • Ensure that a class only has one instance
  • Easily access the sole instance of a class
  • Control its instantiation
  • Restrict the number of instances
  • Access a global variable

In other words, the singleton pattern ensures that only one instance of a class is ever created, making it easy to access it as a global variable.

How to Implement a Singleton in Dart

This is the simplest way:

class Singleton { /// private constructor Singleton._(); /// the one and only instance of this singleton static final instance = Singleton._(); }

By making the constructor private, we ensure that the class cannot be instantiated outside the file where it is defined.

And as a result, the only way to access it is to call Singleton.instance in our code.

In some cases, it's preferable to use a static getter variable. For alternative ways of implementing a singleton in Dart, read this thread on StackOverflow.

Some Singleton Examples in Flutter

If you used Firebase before, you'll be familiar with this code, that can be used to sign in when a button is pressed:

ElevatedButton( // access FirebaseAuth as a singleton and call one of its methods onPressed: () => FirebaseAuth.instance.signInAnonymously(), child: Text('Sign in anonymously'), )

The singleton pattern is used by all the Firebase plugins. And the only way to call their methods is with the instance getter:

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

But hang on a second! If the official Firebase plugins are implemented as singletons, surely it's ok to design your classes in the same way, right? 🧐

Not so fast.

One Instance Only

You see, these classes were designed as singletons to prevent you from creating more than one instance in your code:

// Note: this code won't compile since the constructor is private // inside WidgetA final auth1 = FirebaseAuth(); // inside WidgetB - different instance: final auth2 = FirebaseAuth();

The code above won't compile. And it shouldn't, because you have only one authentication service, acting as a single source of truth:

// inside WidgetA final auth1 = FirebaseAuth.instance; // inside WidgetB - same instance: final auth2 = FirebaseAuth.instance;

This is a very noble goal, and singletons are often a reasonable solution for library or package design.

But when writing application code, we should be very careful about how we use them, as they can lead to many problems in our codebase.

Flutter apps have deeply nested widget trees. As a result, singletons make it easy to access the objects we need, from any widget. But singletons have many drawbacks and there are better alternatives that are still easy to use.

Singleton Drawbacks

To better understand why singletons are problematic, here's a list of common drawbacks, along with possible solutions.

1. Singletons are hard to test

Using singletons makes your code hard to test.

Consider this example:

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:

test('calls signOut', () async { final authRepository = FirebaseAuthRepository(); await authRepository.signOut(); // how to expect FirebaseAuth.instance.signOut() was called? });

A simple solution is to inject FirebaseAuth as a dependency, like this:

class FirebaseAuthRepository { // declare a FirebaseAuth property and pass it as a constructor argument const FirebaseAuthRepository(this._auth); final FirebaseAuth _auth; // use it when needed Future<void> signOut() => _auth.signOut(); }

As a result, we can easily mock the dependency in our test and write expectations against it:

import 'package:mocktail/mocktail.dart'; // declare a mock class that implements the type of our dependency class MockFirebaseAuth extends Mock implements FirebaseAuth {} test('calls signOut', () async { // create the mock dependency final mock = MockFirebaseAuth(); // stub its method(s) to return a value when called when(mock.signOut).thenAnswer((_) => Future.value()); // create the object under test and pass the mock as an argument final authRepository = FirebaseAuthRepository(mock); // call the desired method await authRepository.signOut(); // check that the method was called on the mock expect(mock.signOut).called(1); });

Check out the mocktail package for more info about how to write tests using mocks.

2. Singletons are Implicit Dependencies

Let's recall the previous example:

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

In this case, it's easy to see that FirebaseAuthRepository depends on FirebaseAuth.

But as soon as we have classes with a few dozen lines of code, it becomes much harder to spot the singletons.

On the other hand, dependencies are a lot easier to see when they are passed as explicit constructor arguments:

class FirebaseAuthRepository { // easy to find the dependencies here, // even if this class becomes very large const FirebaseAuthRepository(this._auth); final FirebaseAuth _auth; Future<void> signOut() => _auth.signOut(); }

3. Lazy Initialization

Initializing certain objects can be expensive:

class HardWorker { HardWorker._() { print('work started'); // do some heavy processing } static final instance = HardWorker._(); } void main() { // prints 'work started' right away final hardWorker = HardWorker.instance; }

In the example above, all the heavy processing code runs as soon as we initialize the hardWorker variable inside the main() method.

In such cases, we can use late to defer the object initialization until later (when it's actually used):

void main() { // prints nothing // initialization will happen later when we *use* hardWorker late final hardWorker = HardWorker.instance; ... // initialization happens here // prints 'work started' from the constructor hardWorker.logResult(); }

However, this approach is error-prone as it's too easy to forget to use late.

Note: In Dart, all global variables are lazy-loaded by default (and this is also true for static class variables). This means that they are only initialized when they are first used. On the other hand, local variables are initialized as soon as they are declared, unless they are declared as late.

As an alternative, we can use packages such as get_it, which makes it easy to register a lazy singleton:

class HardWorker { HardWorker() { // do some heavy processing } } // register a lazy singleton (won't be created yet) getIt.registerLazySingleton<HardWorker>(() => HardWorker()); // when we need it, do this final hardWorker = getIt.get<HardWorker>();

And we can do the same with Riverpod, since all providers are lazy by default:

// create a provider final hardWorkerProvider = Provider<HardWorker>((ref) { return HardWorker(); }); // read the provider final hardWorker = ref.read(hardWorkerProvider);

As a result, the object we need will only be created when we first use it.

One of my favourite things about Riverpod is that it makes it very easy to test code using providers. For more details, read the Riverpod documentation about testing.

4. Instance Lifecycle

When we initialize a singleton instance, it will remain alive until the end of time (a.k.a. when the application is closed 😅).

And if the instance consumes a lot of memory or keeps an open network connection, we can't release it early if we want to.

On the other hand, packages like get_it and Riverpod give us more control over when a certain instance is disposed.

In fact, Riverpod is quite smart and lets us easily control the lifecycle of the state of a provider.

For example, we can use the autoDispose modifier to ensure our HardWorker is disposed as soon as the last listener is removed:

final hardWorkerProvider = Provider.autoDispose<HardWorker>((ref) { return HardWorker(); });

This is most useful when we want to dispose an object as soon as the widget that was using it is unmounted.

5. Thread Safety

In multi-threaded languages, we need to be careful about accessing singletons across different threads, and some synchronization mechanism may be necessary if they share mutable data.

But in Dart, this is usually not a concern because all application code inside a Flutter app belongs to the main isolate.

Though if we end up creating separate isolates to perform some heavy computations, we need to be more careful:

Isolates should not modify any mutable data that may be held inside a singleton.
Isolates should not modify any mutable data that may be held inside a singleton.

For more info, watch this video about Isolates and Event Loops.

Alternatives to Singletons

Having reviewed the main drawbacks of using singletons, let's see what alternatives are well suited for Flutter app development.

Dependency Injection

Wikipedia defines dependency injection as:

a design pattern in which an object receives other objects that it depends on.

In Dart this is easily implemented with explicit constructor arguments:

class FirebaseAuthRepository { // inject the dependency as a constructor argument const FirebaseAuthRepository(this._auth); // this property is a dependency final FirebaseAuth _auth; // use it when needed Future<void> signOut() => _auth.signOut(); }

Dependency injection promotes good separation of concerns, making classes independent from the creation of the objects they depend on.

But how can we initialize the FirebaseAuthRepository class we created above, and use it inside deeply nested widgets (or elsewhere in our code)?

Use get_it as a Service Locator

If we use the get_it package, we can register our class as a lazy singleton when the app starts:

void main() { // GetIt itself is a singleton, see note below for more info final getIt = GetIt.instance; getIt.registerLazySingleton<FirebaseAuthRepository>( () => FirebaseAuthRepository(FirebaseAuth.instance), ); runApp(const MyApp()); }

And then we can access it like this when needed:

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

Note: the GetIt class is itself a singleton. But this is ok because what matters is that it allows us to decouple our dependencies from the objects that need them. For a more in-depth overview, read the package documentation.

Use Riverpod Providers

Riverpod makes it easy to create providers as global variables:

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

And if we have a ref object, we can easily read any provider to get its value:

final authRepository = ref.read(authRepositoryProvider);

As a result, we only need to call FirebaseAuth.instance once in the entire codebase (rather than many times), since we can now get or read its value (using either get_it or Riverpod).

Unlike solutions based on InheritedWidget or the Provider package, Riverpod providers live outside the widget tree. This makes them safe to use at compile-time, without runtime exceptions. For more info on this, read my Essential Guide to Riverpod.

Conclusion

Now that we've covered the main drawbacks of using singletons and their alternatives, I'd like to leave you with some practical tips based on personal experience.

1. Don't create your own singletons

Unless you're a package author and you have a good reason to do so, don't create your own singletons. Even if you access 3rd party APIs as singletons, don't use Singleton.instance everywhere, as this makes your code hard to test.

Instead, create your classes by passing any dependencies as constructor arguments.

Then, follow step 2. 👇

2. Use packages such as get_it or Riverpod

These packages give you much better control over your dependencies, meaning that you can easily initialize, access, and dispose them, without any of the drawbacks outlined above.

Once you get the hang of this, you need to figure out what dependencies exist between different kinds of objects (widgets, controllers, services, repositories, etc.). And this leads to step 3. 👇

3. Choose a good app architecture

When building complex apps, choose a good app architecture that helps you:

  • structure your code and support your codebase as it grows
  • decide what different objects should (and should not) depend on

By following this advice, I've built a medium-sized eCommerce app with testable and maintainable code without creating any singletons, using this reference app architecture based on Riverpod.

Wrap Up

Singletons make it easy to access dependencies in your code. But they create more problems than they solve.

A better alternative is to manage dependencies using battle-tested packages such as get_it and Riverpod.

So pick one and use it in your apps, along with a good architecture. By doing so, you'll avoid many pitfalls and end up with a much better codebase. 👍

Happy coding!

Flutter Foundations Course Now Available

I launched a brand new course that covers Flutter app architecture in great depth, along with other important topics like state management, navigation & routing, testing, and much more:

Want More?

Invest in yourself with my high-quality Flutter courses.

Flutter & Firebase Masterclass

Flutter & Firebase Masterclass

Learn about Firebase Auth, Cloud Firestore, Cloud Functions, Stripe payments, and much more by building a full-stack eCommerce app with Flutter & Firebase.

The Complete Dart Developer Guide

The Complete Dart Developer Guide

Learn Dart Programming in depth. Includes: basic to advanced topics, exercises, and projects. Fully updated to Dart 2.15.

Flutter Animations Masterclass

Flutter Animations Masterclass

Master Flutter animations and build a completely custom habit tracking application.