Implementing Dark Mode in React Native

Stephen Hanson

Modern devices allow users to pick their preferred color theme: dark mode or light mode. In dark mode, light text is shown over a dark background instead of dark text over a light background. Darkening the colors on the screen lessens eye strain and can be helpful for people with vision conditions.

As devices have rolled out support for setting dark mode preferences at the OS level, users have come to expect their apps to be compatible with and respect their preferences. It is more important than ever to build that support into your app!

React Native doesn’t have a built-in mechanism for supporting dark mode at the styling level. Instead, it’s up to the developer to build this functionality. This article will walk through an approach to implementing it. Here is a sneak peek of what we are about to build:

There is also a companion GitHub repo with the app that we are building at thoughtbot/react-native-example-dark-mode.

Let’s talk about color variable naming

Whether we’re supporting dark mode or not, color values should be extracted to variables so they can easily be reused throughout the app. The first step in supporting dark mode is taking a look at the variable names you are already using and ensuring they will make sense across both light and dark modes.

Variable names should not include words that indicate lightness or darkness if that naming might not be true in one of the color modes. Instead, try to name variables semantically, substituting words like “white”, “bright”, and “dark” with words like “strong”, “subtle”, “accent”, and “inverted”. Think about the purpose of the color variable, and name it accordingly. We’ll see some examples as we continue.

A naïve first implementation

Alright, let’s try to add some basic dark mode support to our app. Let’s say we already have some color variables defined that we use throughout our light themed app:

// colors.ts

export const colors = {
  text: '#222';
  textLight: '#444';
  backgroundWhite: '#fff'
}

The following change updates our color names so they make sense across both light and dark modes (i.e. textLight is renamed to textAccent and backgroundWhite is renamed to background) and then creates two separate sets of color constants: lightModeColors and darkModeColors. When this file is loaded for the first time, it looks at the phone’s color theme and chooses the appropriate set of constants, which are then used throughout the app:

// colors.ts

import { Appearance } from 'react-native';

type Colors = {
  text: string;
  textAccent: string;
  background: string;
}

export const lightModeColors: Colors = {
  text: '#222';
  textAccent: '#444';
  background: '#fff'
}

export const darkModeColors: Colors = {
  text: '#fff';
  textAccent: '#ccc';
  background: '#222'
}

const isDark = Appearance.getColorScheme() === 'dark'

// will always be the color theme from when file was first initialized
export const colors = isDark ? darkModeColors : lightModeColors;
// MyComponent.tsx
function MyComponent() {
  return <Text style={styles.myButton}>Text</Text>;
}

// these styles are computed statically when the file is first initialized
const styles = StyleSheet.create({
  myButton: {
    // will always be the color theme from when colors.ts was initialized
    backgroundColor: colors.background,
  },
});

This approach is a good first step toward dark mode support, but it has a couple major drawbacks:

  1. Whatever color theme that is in use when the app opens will be used until the app is quit and restarted. If a user’s device switches to dark mode in the evenings, for example, the app won’t recognize this.
  2. With this architecture, there is no way to add the ability for the user to change the dark mode setting in the app. The colors are initialized when the app loads, and the styles for each component are also computed statically at load time.

Note: make sure your iOs or Android project is not hard-coded to a light user-interface style, or the above will not work. If using Expo, you will need to set "userInterfaceStyle": "automatic" in your app.json under the expo key (the default is “light”).

Tip: if you are renaming variables in a TypeScript project, use the TypeScript “rename symbol” command to instantly rename it throughout your project.

A better, yet clunky implementation

To get around the limitations discussed above, we need to instead load the color theme dynamically and compute the styles based off of that. Any time the color theme changes, our component needs to re-render with the appropriate colors. This means that we must set color values dynamically via the style prop during component render instead of leveraging React Native’s statically computed StyleSheets.

In the below example, we use React Native’s useColorScheme hook. This ensures that the app re-renders anytime the color theme changes, and we can dynamically compute styles based on the color theme during render:

// MyComponent.tsx
import { useColorScheme } from 'react-native';
import { darkModeColors, lightModeColors } from 'src/colors';

function MyComponent() {
  const colorTheme = useColorScheme();

  const color =
    colorTheme === 'dark' ? darkModeColors.text : lightModeColors.text;

  return <Text style={{ color }}>Text</Text>;
}

This approach still has room for simplification, but it does work, and it demonstrates the foundation for how we build this feature.

Extracting the colors to a hook

One simplification of the above example would be to create an abstraction so we can just get the appropriate colors based on the current color theme. We’ll call our hook useColors:

import { useColorScheme } from 'react-native';
import { darkModeColors, lightModeColors } from 'src/colors';

function useColors() {
  const colorTheme = useColorScheme();

  return colorTheme === 'dark' ? darkModeColors : lightModeColors;
}

Our component can now be simplified to:

// MyComponent.tsx
import { useColors } from 'src/useColors';

function MyComponent() {
  const colors = useColors();

  return <Text style={{ color: colors.text }}>Text</Text>;
}

Creating more abstractions

While the useColors hook is convenient, it can still be cumbersome to have to use the hook and dynamically hard-code any styles that have to do with color. I like to build some abstractions so that usually only our core components have to use the useColors hook.

To do this, I build base components on top of core React Native components like Text, View, Pressable, etc. Here is an example:

// MyText.tsx

import { TextProps, StyleSheet, Text } from 'react-native';
import useColors from 'src/useColors';
import { Colors } from 'src/colors';

export type MyTextProps = TextProps & {
  color?: keyof Colors;
};

export default function MyText({
  color = 'text',
  style,
  ...props
}: MyTextProps) {
  const { colors } = useColors();

  return (
    <Text style={[styles.text, { color: colors[color] }, style]} {...props} />
  );
}

const styles = StyleSheet.create({
  text: {
    // base styles
  },
});

Now, we can use our custom MyText component and just pass the name of the color we’d like to use. TypeScript still verifies that it’s a valid color.

// MyComponent.tsx
import MyText from 'src/MyText';

function MyComponent() {
  return <MyText color="textAccent">Text</Text>;
}

Tip: while the above works, and TypeScript correctly validates the color string, TypeScript’s refactoring tools will not refactor that string if you ever rename the textAccent color. For that reason, you might have more success using enums for this approach instead of strings.

Allowing the user to set a dark mode preference in the app

So far, we’ve looked at how we can support dark mode based on the user’s device preference. Sometimes users might prefer to use your app in a certain color theme regardless of their system preference. For example, some users might prefer a reader app to always be in dark mode to reduce eye strain, despite their device being in light mode.

To support a user preference for dark mode, we will add a React Context to store the preference globally and then update our useColors hook to respect this preference instead of just looking at the system theme.

First, we build a React context to store the user’s preference. This example also persists it to AsyncStorage:

// ThemeProvider.tsx

import AsyncStorage from '@react-native-async-storage/async-storage';
import React, { ReactNode, useEffect, useMemo, useState } from 'react';
import { ColorSchemeName } from 'react-native';

const THEME_ASYNC_STORAGE_KEY = 'THEME_STATE';

type Props = {
  children: ReactNode;
};

export type ThemeContextState = {
  theme: ColorSchemeName;
  setTheme: React.Dispatch<React.SetStateAction<ColorSchemeName>>;
  loading: boolean;
};

export const ThemeContext = React.createContext<ThemeContextState | undefined>(
  undefined
);

export default function ThemeProvider({ children }: Props) {
  const [theme, setTheme] = useState<ColorSchemeName>();
  const [loading, setLoading] = useState(true);

  // load the preference from AsyncStorage on app launch
  useEffect(() => {
    const load = async () => {
      const storedTheme = (await AsyncStorage.getItem(
        THEME_ASYNC_STORAGE_KEY
      )) as ColorSchemeName;

      setTheme(storedTheme);
      setLoading(false);
    };

    void load();
  }, []);

  // update AsyncStorage when the theme preference changes
  useEffect(() => {
    if (theme) {
      void AsyncStorage.setItem(THEME_ASYNC_STORAGE_KEY, theme);
    } else {
      void AsyncStorage.removeItem(THEME_ASYNC_STORAGE_KEY);
    }
  }, [theme]);

  const contextState = useMemo(
    () => ({ loading, setTheme, theme }),
    [theme, loading]
  );

  if (loading) {
    return null;
  }

  return (
    <ThemeContext.Provider value={contextState}>
      {children}
    </ThemeContext.Provider>
  );
}
import { useCallback, useContext, useMemo } from 'react';
import { ColorSchemeName, useColorScheme } from 'react-native';
import { ThemeContext } from 'src/ThemeProvider';
import { darkModeColors, lightModeColors } from 'src/colors';

export default function useThemeContext() {
  const context = useContext(ThemeContext);
  const systemColorScheme = useColorScheme();

  if (context === undefined) {
    throw new Error('useThemeContext must be within ThemeProvider');
  }

  const { theme, loading, setTheme } = context;

  if (loading) {
    throw new Error('Tried to use ThemeContext before initialized');
  }

  // if theme is set, use that, otherwise, use system theme
  const colorTheme: NonNullable<ColorSchemeName> =
    theme ?? systemColorScheme ?? 'light';

  return {
    colors: colorTheme === 'dark' ? darkModeColors : lightModeColors,
    colorTheme,
    isSystemTheme: !theme,
    isDark: theme === 'dark',
    systemTheme: systemColorScheme,
    setColorTheme: useCallback(
      (themeName: ColorSchemeName) => setTheme(themeName),
      [setTheme]
    ),
  };
}

And finally, here is how we could use this context to build a settings screen for toggling between System theme, light mode, and dark mode:

// SettingsScreen.tsx

import { Switch, View } from 'react-native';
import BodyText from 'src/components/BodyText';
import Heading from 'src/components/Heading';
import theme from 'src/util/theme/theme';
import useThemeContext from 'src/util/useThemeContext';

export default function SettingsScreen() {
  const { colors, isSystemTheme, systemTheme, colorTheme, setColorTheme } =
    useThemeContext();

  return (
    <View style={{ backgroundColor: colors.backgrounds.default }}>
      <View>
        <Heading style={{ color: colors.text }}>Dark Mode</Heading>
      </View>
      <View>
        <BodyText>Automatic (follow device setting)</BodyText>
        <Switch
          trackColor={{
            false: colors.backgrounds.soft,
            true: colors.backgrounds.strong,
          }}
          ios_backgroundColor={colors.backgrounds.soft}
          thumbColor={colors.backgrounds.default}
          onValueChange={(on) => setColorTheme(on ? null : systemTheme)}
          value={isSystemTheme}
        />
      </View>
      <View>
        <BodyText>Dark Mode</BodyText>
        <Switch
          trackColor={{
            false: colors.backgrounds.soft,
            true: colors.backgrounds.strong,
          }}
          disabled={isSystemTheme}
          ios_backgroundColor={colors.backgrounds.soft}
          thumbColor={colors.backgrounds.default}
          onValueChange={(on) => setColorTheme(on ? 'dark' : 'light')}
          value={colorTheme === 'dark'}
        />
      </View>
    </View>
  );
}

For the complete runnable example, see the example repo thoughtbot/react-native-example-dark-mode.

Conclusion

Supporting dark mode is more important than ever as users come to expect this functionality. While most dark mode functionality must be built from scratch, it is a fairly straightforward process. I hope this article helped provide some guidance!