ModeProvider

PreviousNext

Holds the app-wide light, dark or system theme mode, resolves it to a concrete color scheme, and optionally persists it.

Installation

pnpm dlx bna-ui add mode-provider

Usage

ThemeProvider mounts this for you, so most apps never import it directly — and every scaffold from npx bna-ui init is already set up. Mount it yourself only if you are not using ThemeProvider:

import { ModeProvider } from '@/providers/mode-provider';
 
export default function RootLayout() {
  return (
    <ModeProvider>
      <Stack />
    </ModeProvider>
  );
}

Everything below it — useColorScheme, useColor, and therefore every component in the library — resolves its colors through this provider.

Persisting the Choice

Pass any key/value store. expo-secure-store — what every scaffold from npx bna-ui init uses — already matches the shape:

import * as SecureStore from 'expo-secure-store';
import { ThemeProvider } from '@/providers/theme-provider';
 
export default function RootLayout() {
  return (
    <ThemeProvider storage={SecureStore}>
      <Stack />
    </ThemeProvider>
  );
}

SecureStore has no web implementation, so on web this degrades to no persistence rather than erroring — the toggle itself still works there. Reach for AsyncStorage instead if persisting on web matters more to you than using the store the scaffolds already depend on.

The mode is restored once on mount and written on every change. Nothing is persisted unless you pass storage, which is why this file adds no dependencies — you choose the storage engine, and apps that do not want one do not pay for it. A missing, malformed or unreadable value falls back to defaultMode, and a store that throws — including one that is simply unavailable on the current platform — is swallowed, so persistence can never block startup.

API Reference

ModeProvider

Holds the app-wide theme mode (light, dark or system) and resolves it to a concrete scheme. useColorScheme reads it when mounted, so an in-app toggle works on native *and* web — react-native-web has no Appearance.setColorScheme to write an override through. On native the choice is also mirrored into React Native Appearance, so the status bar and Android navigation bar follow. ThemeProvider mounts this for you.

PropTypeRequiredDescription
childrenReact.ReactNodeYesThe wrapped app content.
storageModeStorageNoA key/value store — { getItem, setItem }, sync or async, which expo-secure-store and AsyncStorage both satisfy as-is — used to persist the choice across launches. Omit it and the mode resets to defaultMode on every launch. A missing, malformed or unreadable value falls back to defaultMode, so a store that is unavailable on the current platform degrades to no persistence rather than an error.
storageKeystringNoKey the mode is persisted under. Defaults to bna-ui.mode.
defaultMode'light' | 'dark' | 'system'NoMode used before anything is restored from storage. Defaults to system.

useModeContext

Returns { mode, setMode, scheme }, or null when no ModeProvider is mounted — which is what lets useColor work without one. scheme is the resolved light/dark value; prefer it over re-deriving from mode. Most apps want useModeToggle instead.

PropTypeDescription

ModeStorage

type ModeStorage = {
  getItem: (key: string) => string | null | Promise<string | null>;
  setItem: (key: string, value: string) => void | Promise<void>;
};

Deliberately the smallest surface that does the job, and sync-or-async on both methods, so expo-secure-store (sync getItem/setItem), AsyncStorage (promise-returning) and MMKV adapters all satisfy it with no shim.

How It Works

The provider keeps the requested mode in state and derives scheme from it — resolving 'system' against React Native's useColorScheme(). Reading scheme rather than re-deriving from mode matters: outside system mode there is no meaningful system value to fall back to.

On native, setMode additionally mirrors the choice into React Native's global Appearance, so UI that sits above this provider follows too — the status bar, the Android navigation bar, native sheet presentation, and root layouts that read the OS scheme to color system chrome.

That call is feature-detected. Appearance.setColorScheme landed in React Native 0.73 and react-native-web has never implemented it — on web Appearance is read-only, offering getColorScheme and addChangeListener and no setter. The context is what drives the theme there, which is why the toggle works on web at all.

Why Not Local State

The mode used to live in useModeToggle as local useState next to a global Appearance.setColorScheme call. Half the state was per-component and half was app-wide, which broke in three ways:

  • On web, nothing happened. There was no setter to write the override through, so pressing the toggle changed the icon and left every color alone.
  • Remounting reset the cycle. Navigate away from a screen holding the toggle and back, and mode returned to 'system' while the app stayed dark — so the next press jumped to the wrong mode.
  • Two toggles disagreed. Each call site had its own independent mode.

Moving the state into context fixes all three, and makes persistence a single prop rather than a hook every app has to write for itself.