Tech talk: using React hooks to build a consistent dark mode 🎣

Tech talk: using React hooks to build a consistent dark mode 🎣

Our users love Dark Mode! So when we re-wrote the VibePay app in React Native, we knew we had to build it in from the get-go.

We wanted Dark Mode everywhere in our app, and importantly wanted most components to automatically adjust, without our dev team having to write any boilerplate.

So, here's how we wrote a number of React hooks to make it all possible:

1. useDarkMode

React Native includes a useColorScheme hook, by wrapping this up we can extend its functionality. For now lets just convert it into a boolean, we will do more with this hook later.

import { useColorScheme } from 'react-native';
export default function useDarkMode() {
  const colorScheme = useColorScheme();
  return colorScheme === 'dark';
};

2. useDarkModeSelect

Our next hook, useDarkModeSelect, simply lets us switch between the two inputs depending on the output of  useDarkMode. With some basic TypeScript generics we can also preserve the type safety.

import useDarkMode from './useDarkMode';

export default function useDarkModeSelect<T>(light: T, dark: T) {
  const darkMode = useDarkMode();
  return !darkMode ? light : dark;
}

We generally use this hook for things like switching images.

const image = useDarkModeSelect(<ImageUsersSkeleton />, <ImageUsersSkeletonDark />);

3. useDynamicColors

Last but not least we have useDynamicColors. This will allow us to switch colours depending on whether we're in Dark Mode. We define some default colour mappings that we use all over the app, but by taking an input and using some generics we can allow this hook to take additional colours. These additional colours may be used in one off places.

import { Colors } from '@app/styles';
import useDarkModeSelect from './useDarkModeSelect';

type DefaultColors = {
  color: string;
  backgroundColor: string;
  borderColor: string;
  dividerColor: string;
  surfaceColor: string;
};
export default function useDynamicColors<T>(light?: T, dark?: T): T & DefaultColors {
  return useDarkModeSelect<T & DefaultColors>(
    {
      color: Colors.dark,
      backgroundColor: Colors.white,
      borderColor: Colors.light,
      dividerColor: Colors.light,
      surfaceColor: Colors.white,
      ...(light as T),
    },
    {
      color: Colors.white,
      backgroundColor: Colors.black,
      borderColor: Colors.dark,
      dividerColor: Colors.light10,
      surfaceColor: Colors.dark,
      ...(dark as T),
    },
  );
}

For example here we are using the backgroundColor, but also passing in a backdrop colour to be used by this specific component.

  const { backgroundColor, backdropColor } = useDynamicColors(
    { backdropColor: Colors.dark },
    { backdropColor: Colors.light25 },
  );

Using useDynamicColors throughout our component library we get consistent dark mode behaviour. For example, since we wrap  <Text/> in our own <Typography /> component, we can just pass the color into the style prop.


const Typography: React.FC<Props> = ({ children, variant, style, onPress, ...props }) => {
  const { color } = useDynamicColors();
  return (
    <Text onPress={onPress} style={[styles[variant], { color }, style]} {...props}>
      {children}
    </Text>
  );
};

How exactly you implement this depends on how you use components.

🥇 Bonus: Supporting other devices

While most of ours users are on iOS13+ or Android 10+ we do have some, especially on Android, using older versions.

To support these users we added a switch in settings to set the colour preference manually. This also allows users who have native OS dark mode support to override the setting, if they wish.

Through the magic of hooks we can read this setting from the store (redux in our case) and then return this instead of the OS setting. All our components will automatically re-render when this setting changes.

import { RootState } from '@app/store/rootReducer';
import { useColorScheme } from 'react-native';
import { useSelector } from 'react-redux';

export default function useDarkMode() {
  const colorScheme = useColorScheme();
  const prefers = useSelector((state: RootState) => state.user.colorScheme);
  if (prefers) {
    return prefers === 'dark';
  }
  return colorScheme === 'dark';
};

Our app now looks just as good in dark mode, and our users eyes have never been more grateful!

Interested in joining our innovative tech team? See our job openings here.