As a React Native developer committed to delivering exceptional user experiences, I always strive to optimize the UI by incorporating features like dark mode. This is my journey implementing a seamless dark mode experience.

The Challenge: Consistency in Theme Selection

#

When a user selects a preferred theme that doesn't align with the system theme, native components inadvertently adopt the system theme. This inconsistency disrupts the application's appearance.

Step 1: Creating a React Context for the Theme

#
import React, { createContext, useState, useContext } from "react";

const ThemeContext = createContext<
	{ theme: string; toggleTheme: () => void } | undefined
>(undefined);

export const useTheme = () => useContext(ThemeContext);

export const ThemeProvider = ({ children }: { children: React.ReactNode }) => {
	const [theme, setTheme] = useState("light");
	const toggleTheme = () => {
		setTheme((prevTheme) => (prevTheme === "light" ? "dark" : "light"));
	};
	return (
		<ThemeContext.Provider value={{ theme, toggleTheme }}>
			{children}
		</ThemeContext.Provider>
	);
};

Step 2: Enabling User Theme Persistence Using Storage

#
import React, { createContext, useState, useContext, useEffect } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";

const ThemeContext = createContext<
	{ theme: string; toggleTheme: () => void } | undefined
>(undefined);
export const useTheme = () => useContext(ThemeContext);

export const ThemeProvider = ({ children }: { children: React.ReactNode }) => {
	const [theme, setTheme] = useState("light");

	const toggleTheme = async () => {
		const newTheme = theme === "light" ? "dark" : "light";
		setTheme(newTheme);
		await AsyncStorage.setItem("user-theme", newTheme);
	};

	useEffect(() => {
		const loadTheme = async () => {
			const storedTheme = await AsyncStorage.getItem("user-theme");
			if (storedTheme) setTheme(storedTheme);
		};
		loadTheme();
	}, []);

	return (
		<ThemeContext.Provider value={{ theme, toggleTheme }}>
			{children}
		</ThemeContext.Provider>
	);
};

Step 3: Integrating the Appearance API

#

Incorporating the Appearance API allows us to explicitly inform native components about the user's color scheme preferences.

import { Appearance } from "react-native";

useEffect(() => {
	const loadTheme = async () => {
		const storedTheme = await AsyncStorage.getItem("user-theme");
		if (storedTheme) {
			setTheme(storedTheme);
		} else {
			const systemTheme = Appearance.getColorScheme();
			setTheme(systemTheme || "light");
		}
	};
	loadTheme();
}, []);

Exploring Theme Variations

#

Without Appearance API

#
  • System: Dark, User: Light means native components follow system (dark), while the app follows user (light) → mismatch.
  • System: Light, User: Dark causes the inverse mismatch.

With Appearance API

#
  • User selection consistently wins, regardless of system theme. Native components stay in sync.

Conclusion

#

Implementing dark mode significantly enhances UX. Through Context for theme management, persistence, and the Appearance API for native sync, you get a robust theming system that feels native.

Originally published at saeed.guru/blog/posts/dark-mode-react-native.