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',
},
});
};
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>
);
}