DEV Community

Cover image for React Native - How to build a simple and scalable app theming strategy
Alex R.
Alex R.

Posted on • Updated on

React Native - How to build a simple and scalable app theming strategy

React Native - How to build a simple and scalable app theming strategy

Welcome to yet another entry from my React Native series!

This time around we'll build together a complete theming strategy for your app that is easily configurable, scalable and is a "plug-and-play" solution to almost any React Native project.

In this part of the series we will build a practical solution to our theming needs.

🙋🏻‍♂️ Shameless promotion - before going any further be sure to read my React Native - How to approach design collaboration with Figma to get a better view of what we will try to achieve.

In this post we will not use any of the already amazing libraries out there, but instead we'll explore how to build one and maybe learn something new!

Acknowledging challenges

  1. More and more apps need to support different accessibility setups in terms of typography and colours.
  2. Integrating design updates can be a difficult task.
  3. Maintaining a design system which needs to be aware of multiple themes can be an overwhelming tasks for most.

Decorators, Providers, Proxies, Hooks and Fixtures

What a word salad, right? 😅

These are some of the main ingredients that we will use during the course of this journey. We'll unpack every item in the list one by one, no worries!

The key components of our theming strategy need to support use cases like:

  • Being able to inject "theme" information (typography, colour namespaces, colour information, spacing information, etc.). We'll be implementing the solution to both classes and functions by leveraging the Inversion of Control pattern.

As you guessed it, we will be writing our own decorators and hooks for this.

  • Then as we also expose setters/getters and other data on the React Context object, we would also need to guard this "theme" context object from any potential malformed mutation (bad entries, values, deletions, etc.).

We will be leveraging the Proxy and Reflection APIs - this is where we'll learn and write our context with Proxy objects.

Be careful - if you have hermes enabled in your build, please take a look here first Hermes Language Features.

In order for Reflection (Reflect and Proxy) to work you need to use hermes@0.7.0 or above.

  • Often overlooked - the fixtures files - Carefully decoupling and organising our theme's core data structures in separated fixture files each with its own concern so that they get updated, tested and configured with ease.

Let's jump right in:

It's often a good practice to think in advance about the APIs that we try to build and what we want to achieve - think of this process step as "scope boxing".
This is where we decide on how we want to expose the theme information to our components and how a component can interact with our theme context.

Here is how I would want to consume a `theme` and have access to its properties and methods but also benefit from it automatically switching to the appropriate theme mappings (fonts, colour sets, etc.).
Enter fullscreen mode Exit fullscreen mode
@WithTheme() // Notice this bad body here - if you're into that, I got you.
class App extends React.Component<AppProps, AppState> {
  render() {
    const { theme } = this.props // => `theme` is guaranteed by the `WithTheme` class decorator. If you don't resonate with the decorator pattern, think of it as a higher-order function, and that would work the same.
    const styleView = {
      backgroundColor: theme.primaryBackgroundColor // => This is how a theme backround colour would be consumed
    };
    const styleText = [
      theme.fonts.BodyRegular, // => This is how I would want an entire typography style applied (family, size, letter spacing, etc).
      { color: theme.primaryFontColor } // => This is how I would subscribe to a theme colour - the actual color value (depending on which theme is active) will be handled in the context itself.
    ];

    return (
      <View style={styleView}>
        <Text style={styleText}>
          Hello world
        </Text>
      </View>
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

1. Our files and folders structure:

The main focus for us today is to have our theme provider setup together with our fixtures sorted out.

├── providers
│   ├── index.ts
│   └── theme
│       ├── ThemeContext.Provider.const.ts
│       └── ThemeContext.Provider.tsx
├── theme
│   ├── fixtures
│   │   ├── colors.json
│   │   ├── colors.standard.json
│   │   ├── typography.json
│   │   └── themes.json
│   ├── index.ts
│   ├── theme.const.ts
│   ├── theme.test.ts
│   ├── theme.ts
│   └── theme.utils.tsx

Enter fullscreen mode Exit fullscreen mode

2. The fixtures that we'll use the role they actually play

Although their file name is self explanatory, we've covered in detail the contents and purpose of these files and how they get generated in the React Native - How to approach design collaboration with Figma post.

Besides these basic, but very important fixtures the second most fixture is the one that maps our Figma namespaces directly with the theme variants (light, dark, or whatever we need because we're dealing with a hash-map at the end of the day).

For simplicity's sake, each theme variant holds the following information:

1. Three font colour variants (two colour alternatives and a disabled colour version for that specific theme variant, it really depends on your design);
2. Three background colour variants;
3. Three border colour variants;
4. Optional - box shadow colour information - depends on the design you have, but usually shadow is important to communicate elevation and it does not hurt to have it declared here.
Enter fullscreen mode Exit fullscreen mode

As you can see below, this pattern is repeated for each theme variant - and this is very important as we will see later. It allows us to be consistent with our style data in our entire components library.

{  
  "@class": "Theme",  
  "@version": "0.0.1",  
  "light": {  
    "primaryFontColor": "Color1",  
    "secondaryFontColor": "Color2",  
    "disabledFontColor": "Color3",  

    "primaryBackgroundColor": "#fff",  
    "secondaryBackgroundColor": "Grey2",  
    "disabledBackgroundColor": "Grey3",  

    "primaryBorderColor": "Grey1",  
    "secondaryBorderColor": "Grey2",
    "disabledBorderColor": "Grey3",  

    "disabledColor": "Grey3",  
    "boxShadowColor": "rgba(1, 10, 20, 0.1)"  
 },  
  "dark": {  
    "primaryFontColor": "ColorAlternative1",  
    "secondaryFontColor": "ColorAlternative2",  
    "disabledFontColor": "ColorAlternative3",  

    "primaryBackgroundColor": "#fff",  
    "secondaryBackgroundColor": "ColorAlternative2",  
    "disabledBackgroundColor": "ColorAlternative3",  

    "primaryBorderColor": "ColorAlternative1",  
    "secondaryBorderColor": "ColorAlternative2",
    "disabledBorderColor": "ColorAlternative3",  

    "disabledColor": "ColorAlternative3",  
    "boxShadowColor": "rgba(1, 10, 20, 0.4)"   
 }
}
Enter fullscreen mode Exit fullscreen mode

The following code is something like a bridge between our existing exported fixtures and our final component context object.
It is our chance to describe exactly what we want from our React context structure.
To simplify, it prepares the context object to be consumed. It is also a great place to start writing tests around it.

// theme.ts

// [...]
import Themes from './fixtures/themes.json'  
import Colors from './fixtures/colors.json'  
import ColorsStandard from './fixtures/colors.standard.json'  
import Typography from './fixtures/typography.json'

const ThemeFixtureProvider: ThemeFixtureProvider = (() => {  
    const { light, dark } = Themes  
    const colors: FixtureColor = merge(ColorsStandard, Colors)  
    const typography: FixtureTypography = Typography  
    const platformTypography: { [font in ThemePlatformTypography]: ThemePlatformTypographyProps } = Typography[getPlatform()]

    // Extra step here to traverse and process your fixtures (scale your fonts or normalise your colour information, etc.)
        // fancyColourProcessor(colors)
        // fancyTypographyProcessor(platformTypography)

    return {  
      [ThemeModes.Light]: {  
        ...light,  
        colors,
        typography: platformTypography
      },  
      [ThemeModes.Dark]: {  
        ...dark,  
        colors,
        typography: platformTypography,
      },
      /* 👉🏻 You can add other keys here, but having at least these two will help us work more easily with most platforms (web & native) as these property names (light, dark) are ubiquitous.**/
    }  
  })()
Enter fullscreen mode Exit fullscreen mode

3. Writing our React context with Proxy and object reflection:

What are proxies? In a nutshell you can think of them as objects that can wrap themselves around your original object and then intercept any activity like set or get properties for your original object structure.

This is ideal if you want to protect the original object from any malformed data or enforce some sort of validation when setting or getting properties.

Here's a short example where we implement a custom get() handler for our example and we then intercept the name of the property that we want to access and overwrite the return value for the prop === 'prop2' case:

const originalObject = {
  prop1: "ABC",
  prop2: "DEF"
};

const proxyHandler = {
  get: (target, prop, receiver) => {
    if (prop === 'prop2') {
        return '🚀';
    }

    return target[prop];
  }
};

const proxyExample = new Proxy(originalObject, proxyHandler);

console.log('proxyExample', proxyExample.prop1) // 'ABC'
console.log('proxyExample 2', proxyExample.prop2) // '🚀'

// As you can see, the original object remains intact:
console.log('originalObject', proxyExample.prop2) // 'DEF'
Enter fullscreen mode Exit fullscreen mode

This mechanism turns out to be ideal for constructing our theme related React context as we need to do some nice validations against this object (eg. check that actual theme keys exist, before setting them, etc.).

Having these extra validations and fallback mechanisms will make the app much more resilient to crashes - trust me.

Now that we have our theme context structure defined (see the ThemeFixtureProvider above) & know how to use a Proxy object - we can easily hook up everything in our React context object.

4. Writing our React Provider

This step should be self explanatory. We will build a React context based on what we've previously explored, namely a theme context structure wrapped by a Proxy object.

If you are not familiar with this powerful pattern, please read the official documentation for the Context first just to make sure you have everything fresh in your mind as we progress.

import React, { useEffect, useMemo } from 'react'
import { useColorScheme } from 'react-native'
import { Theme, ThemeFixtureProvider, ThemeModes } from '@/themes'
import { ThemeContext } from './ThemeContext.Provider.const'

interface PropsThemeContextProvider {
  children?: React.ReactChildren
  onChange?: (themeId: ThemeModes) => Theme
}


const themeContextProxyHandler = {

    /**
     * @description
     * A simple getter interceptor that returns a default in case the `themeId` does not match what is in our original `ThemeFixtureProvider`.
     */
    get: function(target, prop, receiver) {
        if (prop === 'themeId' && !Reflect.has(ThemeFixtureProvider, prop)) {
            return ThemeFixtureProvider.Light
        }

        return Reflect.get(...arguments)
    },

    /**
     * @description
     * A simple setter interceptor that prevents setting an inexistent `themeId` wrt. to what is declared in `ThemeFixtureProvider`.
     */
    set: function(target, prop, receiver) {
        if (prop === 'themeId' && !Reflect.has(ThemeFixtureProvider, prop)) {
            return
        }

        Reflect.get(...arguments)
    },
}

const themeContextProxy = new Proxy(Object.create(null), themeContextProxyHandler)
const ThemeContext = React.createContext(themeContextProxy)

export const ThemeContextProvider = (props: PropsThemeContextProvider) => {
  const themeId = useColorScheme() // Fetch the current system theme.
  const theme = useMemo<Theme>(() => ThemeFixtureProvider[themeId as ThemeModes], [themeId]) // Extract the entire theme information.

  useEffect(() => {
    props.onChange(theme, themeId)
  }, [theme, themeId])

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


export const withThemeContext = (ChildComponent: React.FC<any> | React.ComponentClass<any>, options?: any) => {
      return (props: any) => (
        <ThemeContext.Consumer>
          {(context) => <ChildComponent {...props} {...context} {...options} />}
        </ThemeContext.Consumer>
      )
}
Enter fullscreen mode Exit fullscreen mode

5. Writing our class decorator and hook

Let's start out with the hook implementation example. Remember, you would need to further evolve the hook to cover for edge cases.

import { useContext } from 'react'
import { 
    Theme, 
    ThemeFixtureProvider, 
    ThemeModes 
} from '@/themes'
import { ThemeContext } from '@providers/theme/ThemeContext.Provider.const'



interface UseThemeHook {
    theme: Theme
    themeId: ThemeModes
    setTheme: (themeId: ThemeModes) => Theme
}

export function useTheme(): UseThemeHook {
    const { theme, setTheme, themeId } = useContext(ThemeContext)

    return {
        theme,
        themeId,
        setTheme
    }
}

Enter fullscreen mode Exit fullscreen mode

As you can see in the above example, this is a pretty trivial process.

Now, let's also cover for the class decorator example as some people still organise their code this way. If you take a closer at the example code below, we will use two utility functions withThemeContext and isClassComponent to make our lives easier.

We'll use these little utilities to make our life a little bit easier:


// Our class decorator (yes, some still prefer class based implementations)
export function WithTheme() {
    return (target: new (...args: any[]): any): any => {

        if (!isClassComponent(target)) {

            throw TypeError(
                'Invalid type, pass a React `class` instead.'
            )

        }

        const Component = withThemeContext(target, options)

        return class WithThemeDecorator extends target<any, any> {

            render() {
                return <Component {...this.props} />
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

6. Putting it all together

Now that we have our list of tools complete, we should just go ahead and write a basic example.

Basic functional component with a hook (not optimised):


const Header: React.FC<{title: string}> = ({title}) => {
    const {theme} = useTheme()

    return <View>
        <Text style={[
            theme.typography.BodyRegular,
            { color: theme.primaryFontColor}
        ]}>
            {title}
        </Text>
    <View>
}
Enter fullscreen mode Exit fullscreen mode

Basic class component with a decorator:

//app.ts

@WithTheme() 
class App extends React.Component<any, any> {

    render() {
        const {theme} = this.props

        return (
            <View style={{backgroundColor: theme.primaryBackgroundColor}}>
                <Header title={'Hello world'}/>
            </View>
        )
    }
}

Enter fullscreen mode Exit fullscreen mode

And finally, our root index example where we render our entire app structure under our ThemeContextProvider.

//index.ts

export const AppExample: React.FC<any> = () => (
  <ThemeContextProvider>
    <App />
  </ThemeContextProvider>
)
Enter fullscreen mode Exit fullscreen mode

Amazing! Now give yourself a nice pat on the back, you've now built a scalable, lightweight and flexible app theming solution that enables you to do some really cool things like:

  1. being able to react to outside changes (from user or system);
  2. has support for adding multiple themes without touching the component code;
  3. enables complete control over the colour & typography within your app without too much hustle;
  4. covers both functional and class components (just in case);

Thanks for reading and see you in the next one

I really hope you enjoyed this post and if you like to see more content from me, you can show your support by liking and following me around. I'll try my best to keep articles up to date.

As always, stay humble, learn.

👋 Hey, if you want to buy me a coffee, here's the Link

Top comments (0)