Skip to main content
← back

the cleanest way to do dark/light mode in flutter

November 22, 2025

a simple and effective approach to implement dark and light themes in your flutter app using valuenotifier.

Hey, I'm Ekaksh.

In this post I'm going to walk you through how I set up dark mode and light mode in Flutter using nothing besides ValueNotifier and ValueListenableBuilder.

This is exactly the same setup I showed in my YouTube video.

why this pattern

I use this in basically every Flutter app I build because it's clean, simple, and works everywhere in the app without dragging in state-management packages just for toggling a theme.

Let's jump into it.

Create a new project and install dependencies (optional)

Create a new Flutter project:

flutter create theme_1
cd theme_1

Add shared_preferences if you want to persist the theme choice:

flutter pub add shared_preferences

Now, just remove the boilerplate from main.dart, create your usual app.dart and home.dart, and get a basic MaterialApp running.

Create the Theme Notifier (this is the core)

We need something to tell the entire app:

"Theme mode changed — rebuild yourself."

Flutter already gives us ValueNotifier, so we'll just use that. Let's create a theme_notifier.dart file and write the following code:

theme_notifier.dart
class ThemeNotifier {
  static final ThemeNotifier _instance = ThemeNotifier._internal();
  factory ThemeNotifier() => _instance;

  ThemeNotifier._internal();

  final ValueNotifier<ThemeMode> themeModeNotifier = ValueNotifier<ThemeMode>(
    ThemeMode.system,
  );

  Future<void> loadThemeMode() async {
    // TODO: Load the saved theme mode from local storage
  }

  void toggleTheme() {
    // TODO: Toggle between light and dark theme modes and save the updated preference
  }

  void saveThemeMode(ThemeMode mode) {
    // TODO: Save the selected theme mode to local storage and update the notifier
    // Updating the notifier to rebuild the listeners
  }
}

why a singleton?

Because I want to access this from anywhere in the app without passing BuildContext around like it's hot potato.

Persist the theme with SharedPreferences (optional)

If someone switches to dark mode, closes the app, opens it again — the app should stay in dark mode.

For this, we can use the shared_preferences package we added earlier. Let's create a class called LocalStorage in a file called local_storage.dart to handle saving and retrieving things from local storage:

local_storage.dart
class LocalStorage {
  static SharedPreferences? _prefs;
  static SharedPreferences? get prefs => _prefs;

  static Future<void> init() async {
    _prefs = await SharedPreferences.getInstance();
  }

  static Future<void> set(String key, dynamic value) async {
    if (prefs == null) await init();

    if (value is String) {
      await _prefs?.setString(key, value);
    } else if (value is int) {
      await _prefs?.setInt(key, value);
    } else if (value is bool) {
      await _prefs?.setBool(key, value);
    } else if (value is double) {
      await _prefs?.setDouble(key, value);
    } else if (value is List<String>) {
      await _prefs?.setStringList(key, value);
    } else {
      throw Exception("Unsupported value type");
    }
  }

  static T? get<T>(String key, {T? defaultValue}) {
    if (prefs == null) return defaultValue;

    if (T == String) {
      return _prefs?.getString(key) as T? ?? defaultValue;
    } else if (T == int) {
      return _prefs?.getInt(key) as T? ?? defaultValue;
    } else if (T == bool) {
      return _prefs?.getBool(key) as T? ?? defaultValue;
    } else if (T == double) {
      return _prefs?.getDouble(key) as T? ?? defaultValue;
    } else if (T == List<String>) {
      return _prefs?.getStringList(key) as T? ?? defaultValue;
    } else {
      throw Exception("Unsupported value type");
    }
  }

  static Future<void> remove(String key) async {
    if (prefs == null) await init();
    await _prefs?.remove(key);
  }

  static Future<void> clear() async {
    if (prefs == null) await init();
    await _prefs?.clear();
  }
}

Complete the theme notifier with persistence

Now let's complete the theme notifier by implementing loadThemeMode, saveThemeMode, and toggleTheme:

theme_notifier.dart
class ThemeNotifier {
  static final ThemeNotifier _instance = ThemeNotifier._internal();
  factory ThemeNotifier() => _instance;

  ThemeNotifier._internal();

  final ValueNotifier<ThemeMode> themeModeNotifier = ValueNotifier<ThemeMode>(
    ThemeMode.system,
  );

  Future<void> loadThemeMode() async {
    final savedTheme = LocalStorage.get<String>(
      "theme_mode",
      defaultValue: "system",
    );

    if (savedTheme == null) {
      themeModeNotifier.value = ThemeMode.system;
      return;
    }

    if (savedTheme == ThemeMode.light.name) {
      themeModeNotifier.value = .light;
    } else if (savedTheme == ThemeMode.dark.name) {
      themeModeNotifier.value = .dark;
    } else {
      themeModeNotifier.value = .system;
    }
  }

  void toggleTheme() {
    if (themeModeNotifier.value == ThemeMode.light) {
      saveThemeMode(.dark);
    } else {
      saveThemeMode(.light);
    }
  }

  void saveThemeMode(ThemeMode mode) {
    themeModeNotifier.value = mode;
    LocalStorage.set("theme_mode", mode.name);
  }
}

Setup the material app to listen to theme changes

In your app.dart, wrap your MaterialApp with a ValueListenableBuilder that listens to themeModeNotifier:

app.dart
class App extends StatelessWidget {
  const App({super.key});

  @override
  Widget build(BuildContext context) {
    return ValueListenableBuilder(
      valueListenable: ThemeNotifier().themeModeNotifier,
      builder: (_, themeMode, _) {
        return MaterialApp(
          debugShowCheckedModeBanner: false,
          themeMode: themeMode,
          theme: ThemeData.light(),
          darkTheme: ThemeData.dark(),
          home: const Home(),
        );
      },
    );
  }
}

Finally in your main.dart, initialize LocalStorage and call loadThemeMode before running the app:

main.dart
void main(List<String> args) async {
  WidgetsFlutterBinding.ensureInitialized();
  await bootStrap();
  runApp(const App());
}

Future<void> bootStrap() async {
  await LocalStorage.init();
  await ThemeNotifier().loadThemeMode();
}

Setup your home.dart

home.dart
class Home extends StatelessWidget {
  const Home({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text("Theme Notifier Example"),
        centerTitle: true,
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Text("Home Screen"),
            ElevatedButton(
              onPressed: () => ThemeNotifier().toggleTheme(),
              child: const Text("Toggle Theme"),
            ),
          ],
        ),
      ),
    );
  }
}

that's it

You now have a working dark mode and light mode toggle, with persistence via local storage and zero external state-management packages. Just simple ValueNotifiers doing their job.

Here is the full source code: Theme Notifier Tutorial.

Thanks for reading — hope this helps you keep your theming clean.