Search code examples
javascriptreactjstypescriptreact-nativezustand

How to Avoid Repeatedly Calling Hooks for Theme and Responsive Styles in React Native Components?


What I want to achieve: A performant react native theme manager/changer.

Styles should be usable like: <View style={styles.screen}> where styles comes from a styles.ts file where I define my style. There, I should have access to theme color (ex: Dark/Light) and dynamic dimensions (width, height), meaning that if I change to landscape and back, the styling updates accordingly.

Currently, I have this implementation. However, in every component I have to call this hook with my specific style const styles = useStyles();. I'd like to avoid that (or hide this) and simply only have to import them before using them, so I won't have to call such a hook for every single style I import in every component.

App.tsx

import { NavigationContainer, DefaultTheme } from '@react-navigation/native';
import { createStackNavigator, StackScreenProps } from '@react-navigation/stack';
import { Button, View, Text, TouchableOpacity } from 'react-native';
import { useThemeStore, colors } from './themeStore';
import { useStyles } from './styles';

type RootStackParamList = {
  Home: undefined;
  Settings: undefined;
};

const Stack = createStackNavigator<RootStackParamList>();

function HomeScreen({ navigation }: StackScreenProps<RootStackParamList, 'Home'>) {
  const styles = useStyles();
  const toggleTheme = useThemeStore(state => state.toggleTheme);

  return (
    <View style={styles.screen}>
      <View style={[styles.container, { marginTop: 40 }]}>
        <Text style={styles.header}>Home Screen</Text>
        <Text style={styles.text}>Current theme settings</Text>
      </View>
      
      <View style={styles.container}>
        <Button title="Toggle Theme" onPress={toggleTheme} />
        <TouchableOpacity
          style={[styles.button, { marginTop: 16 }]}
          onPress={() => navigation.navigate('Settings')}
        >
          <Text style={styles.buttonText}>Go to Settings</Text>
        </TouchableOpacity>
      </View>
    </View>
  );
}

function SettingsScreen({ navigation }: StackScreenProps<RootStackParamList, 'Settings'>) {
  const styles = useStyles();
  const theme = useThemeStore(state => state.theme);

  return (
    <View style={styles.screen}>
      <View style={styles.container}>
        <Text style={styles.header}>Settings Screen</Text>
        <Text style={styles.text}>Current Theme: {theme.toUpperCase()}</Text>
        
        <TouchableOpacity
          style={[styles.button, { marginTop: 16 }]}
          onPress={() => navigation.goBack()}
        >
          <Text style={styles.buttonText}>Go Back</Text>
        </TouchableOpacity>
      </View>
    </View>
  );
}

export default function App() {
  const theme = useThemeStore(state => state.theme);

  const navigationTheme = {
    ...DefaultTheme,
    dark: theme === 'dark',
    colors: {
      ...DefaultTheme.colors,
      ...colors[theme],
    },
  };

  return (
    <NavigationContainer theme={navigationTheme}>
      <Stack.Navigator
        screenOptions={{
          headerStyle: {
            backgroundColor: navigationTheme.colors.card,
          },
          headerTintColor: navigationTheme.colors.text,
        }}
      >
        <Stack.Screen name="Home" component={HomeScreen} />
        <Stack.Screen name="Settings" component={SettingsScreen} />
      </Stack.Navigator>
    </NavigationContainer>
  );
}

themeStore.ts

import { MMKVLoader } from 'react-native-mmkv-storage';
import {create} from 'zustand';
import { persist } from 'zustand/middleware';
import { storage } from './storage';

export const storage = new MMKVLoader()
  .withInstanceID('themeStorage')
  .initialize();

export const colors = {
  light: {
    primary: '#007AFF',
    background: '#FFFFFF',
    card: '#FFFFFF',
    text: '#000000',
    border: '#D3D3D3',
    notification: '#FF3B30',
  },
  dark: {
    primary: '#BB86FC',
    background: '#121212',
    card: '#1E1E1E',
    text: '#FFFFFF',
    border: '#383838',
    notification: '#CF6679',
  },
};

interface ThemeState {
  theme: 'light' | 'dark';
  toggleTheme: () => void;
}

export const useThemeStore = create<ThemeState>()(
  persist(
    (set) => ({
      theme: 'light',
      toggleTheme: () => set((state) => ({
        theme: state.theme === 'light' ? 'dark' : 'light'
      })),
    }),
    {
      name: 'theme-storage',
      storage: {
        getItem: async (name) => {
          const value = storage.getString(name);
          return value ? JSON.parse(value) : null;
        },
        setItem: async (name, value) => {
          storage.setString(name, JSON.stringify(value));
        },
        removeItem: async (name) => {
          storage.removeItem(name);
        },
      },
    }
  )
);

styles.ts

import { useThemeStore } from './themeStore';
import { useDimensionsStore } from './dimensionsStore';
import { StyleSheet } from 'react-native';
import { colors } from './themeStore';

export const useStyles = () => {
  const theme = useThemeStore(state => state.theme);
  const { width, height } = useDimensionsStore();
  const themeColors = colors[theme];

  return StyleSheet.create({
    screen: {
      flex: 1,
      backgroundColor: themeColors.background,
      width,
      height,
    },
    container: {
      padding: 16,
      backgroundColor: themeColors.card,
      borderRadius: 8,
      margin: 8,
    },
    text: {
      color: themeColors.text,
      fontSize: 16,
    },
    header: {
      color: themeColors.primary,
      fontSize: 24,
      fontWeight: 'bold',
    },
    button: {
      backgroundColor: themeColors.primary,
      padding: 12,
      borderRadius: 8,
      alignItems: 'center',
    },
    buttonText: {
      color: themeColors.background,
      fontWeight: 'bold',
    },
  });
};

Solution

  • Once you want to have the ability to switch colors dynamically based on the dark or light mode value in your store, it needs to be a hook to trigger a render cycle.

    The only thing that comes into my mind is a higher order component. Something like withStyles() that you can wrap around your component. But this has to be done on each and every component / screen again.

    The HOC would look somewhat like this:

    export const withStyles = <T,>(WrappedComponent: React.FC<T & styles: StyleSheet.NamedStyles<any>>) => {
    return (props: T) => {
        const styles = useStyles()
    
        // here you pass `styles` as an additional prop into your wrapped component
        return (
            <WrappedComponent {...props} styles={styles} />
        )
      }
    }
    

    Typing is probably not correct the way it is. But with that you could wrap a component like this:

    const HomeScreen = withStyles<React.FC<StackScreenProps<RootStackParamList, 'Home'>>>(({ navigation, styles }) {
      // styles gets passed in here as prop
    
      const toggleTheme = useThemeStore(state => state.toggleTheme);
    
      return (
        <View style={styles.screen}>
          <View style={[styles.container, { marginTop: 40 }]}>
            <Text style={styles.header}>Home Screen</Text>
            <Text style={styles.text}>Current theme settings</Text>
          </View>
          
          <View style={styles.container}>
            <Button title="Toggle Theme" onPress={toggleTheme} />
            <TouchableOpacity
              style={[styles.button, { marginTop: 16 }]}
              onPress={() => navigation.navigate('Settings')}
            >
              <Text style={styles.buttonText}>Go to Settings</Text>
            </TouchableOpacity>
          </View>
        </View>
      );
    }