Home / Articles / Persisted Theme Switching in React Native with Context and Hooks

This article is published in English.

Persisted Theme Switching in React Native with Context and Hooks

Build a React Native theme switcher with Context, useState and useEffect: tab navigation, a theme picker, AsyncStorage persistence and a loading guard for startup.

1618 words

Theme switching looks like a styling problem, but it is really a state problem: the current theme must be readable from any screen, changeable from one of them, and still there after the app restarts. That makes it a good exercise for learning Hooks on something more realistic than a counter. This walkthrough builds a small React Native app with two tabs, a theme picker and persistent storage, using useContext, useState and useEffect to replace what used to need class components and a fair amount of boilerplate.

Why Hooks fit this problem

Hooks arrived in React 16.8, and React Native gained stable support with version 0.59. Before them, sharing a theme meant a class-based provider holding state, lifecycle methods to load saved preferences, and render props or consumers scattered through the tree. With Hooks, the provider becomes a plain function component, state lives in useState, loading happens in useEffect, and consumers read the value with useContext. The logic ends up in one small file instead of spread across lifecycle methods.

The versions mentioned here reflect when this approach first became possible; current React Native releases support Hooks out of the box, so any recent project works.

Setting up the project

You need a React Native project on 0.59 or later. A fresh one can be generated with react-native init RNThemeProvider (newer toolchains use the community CLI or Expo instead; check the current getting-started docs).

Two libraries are added on top:

  • react-navigation provides the tab-based navigation.
  • AsyncStorage stores the selected theme on the device. It was removed from React Native core and now ships as a separate community package (today published as @react-native-async-storage/async-storage), so it has to be installed on its own.

On older React Native versions, native modules also had to be linked manually after installation; with autolinking in modern releases this step is usually unnecessary.

Organizing the files

A small, predictable structure keeps the theme logic separate from screens and navigation:

- src
  — components
    — TabBar.js # custom bottom tabbar component
  — core
    — themeProvider.js # custom hook for theming
    — themes.json # JSON array containing our themes
  — screens
    — Main.js # first tab
    — Settings.js # second tab
  — App.js # navigation part
— index.js # entry point for react-native
— package.json # dependencies

The core folder holds everything about theming: themes.json with the theme definitions and themeProvider.js with the context, provider and helpers. Screens live in screens, the custom tab bar in components, and App.js wires up navigation.

Building the navigation shell

Start without any theming. In App.js, create a bottom tab navigator with two tabs: Main for the app's content and Settings for preferences. Each tab points to a simple function component in Main.js and Settings.js that, for now, renders only some placeholder text.

Run the app at this stage. If two tabs appear and you can switch between them, the skeleton is done and every later step only adds behavior. The navigation APIs have changed across major react-navigation versions, so follow the setup for the version you install rather than copying older examples verbatim.

Defining themes and the picker UI

Themes as data

Each theme is an object in themes.json with three fields: a unique key that identifies it, a background colour and a text colour. Keeping themes as JSON rather than code makes them easy to extend; a palette generator such as Coolors is a quick way to find colour pairs that work together.

themeProvider.js imports that file and exports two things for now: the full array of themes, which the settings screen lists, and a default theme (the second entry in the array). The provider logic comes later.

The screens and the tab bar

The Settings screen gets a FlatList that renders one row per theme, with the headline styled using the current theme. The Main screen also applies the current theme to its background and text.

Finally, the tab bar should reflect the theme too. A custom TabBar component in components/TabBar.js renders the tabs and uses the theme colour for the active tab. It is registered on the tab navigator in App.js through the navigator's options for a custom tab bar component.

At this point the app looks themed, but only with the hard-coded default. Nothing reacts to taps yet. That is where Hooks come in.

Sharing the theme with useContext

React Context lets a value flow through the tree without passing props through every level. If Context is new to you, the React Context documentation explains the model.

In themeProvider.js, create a theme context and a provider component, ThemeContextProvider. Wrap the navigator in App.js with that provider so every screen and the tab bar sit beneath it.

To make consuming the context convenient, add a withTheme higher-order component to the same file. It reads the context with useContext and passes the theme into the wrapped component as a prop. Update Main, Settings and TabBar to be exported through withTheme, and they receive the current theme without knowing where it comes from. The higher-order components guide covers the pattern in more detail.

A HOC works well when components already expect props. In a Hooks-first codebase, a small useTheme hook that returns useContext(ThemeContext) is often simpler, avoids an extra wrapper layer and makes the dependency visible inside the component. Our guide to custom hooks and logic reuse explains why hooks share logic rather than state, which is exactly why the context is still needed here.

Changing the theme with useState

Now make the picker work. Inside ThemeContextProvider, hold the current theme in useState, initialized with the default theme. Put both the theme and a setTheme function into the context value.

In the Settings screen, call setTheme when a row in the FlatList is tapped. Because the provider's state changes, every component reading the context re-renders with the new colours: the screens and the tab bar switch immediately.

One detail worth adopting: if the context value is a new object on every render of the provider, all consumers re-render whenever the provider does. Memoizing the value with useMemo, keyed on the theme, avoids that. In an app this small it hardly matters, but it becomes relevant once many components consume the context.

Persisting the choice with AsyncStorage

Tapping a theme now works, but the choice is lost on reload. Extend setTheme so that, besides updating state, it writes the selection to AsyncStorage. Storing the theme's key rather than the whole object is the more robust choice: if you later adjust a theme's colours in themes.json, users get the updated version instead of a stale copy.

Restoring the theme on launch with useEffect

The last step is reading the saved theme when the app starts. useEffect runs after the component renders, so it is the place for side effects such as reading storage. In class terms it covers what componentDidMount and componentDidUpdate used to handle; the sometimes-cited comparison with componentWillReceiveProps is misleading, because effects run after rendering rather than before new props arrive.

Inside ThemeContextProvider, add an effect that reads the stored key from AsyncStorage, finds the matching theme and calls the state setter. Two details make this work correctly:

  • Pass an empty dependency array. The effect should run once, when the provider mounts, not after every render. The Hooks API reference explains how the dependency array controls when an effect fires.
  • Handle the loading state. AsyncStorage is asynchronous, so the first render happens before the saved theme is known. Track whether loading has finished and render nothing (or a splash view) until it has. Otherwise users see a flash of the default theme before their own choice appears.

Also handle the case where nothing is stored yet, or the stored key no longer matches a theme, by falling back to the default. With that, the selection survives app restarts.

Wrapping up

The finished provider is a single function component that owns the theme state, persists changes, restores them on launch and exposes everything through context. Compared with a class-based version, the logic is shorter and reads top to bottom.

A few points carry over to other features:

  • Keep shared UI preferences in a context provider near the root, and expose a setter alongside the value.
  • Persist identifiers, not whole objects, so data changes do not leave stale copies on devices.
  • Treat asynchronous startup reads as a loading state rather than rendering defaults and swapping later.
  • Once comfortable with these three Hooks, look at useReducer for more complex state, useRef for mutable values that should not trigger renders, and useLayoutEffect for work that must happen before paint. Converting an existing class component is a good way to practise.