skip to content

In a React Native app, how do you theme colors through a context-provided palette while keeping layout in a static StyleSheet?

level: middleimportance: must knowfreq 58%

answer

  1. layout never changes with the scheme
  2. palette objects per scheme
  3. one useColorScheme at the root
  4. style arrays merge static and dynamic
  5. stable palette identity per scheme

basics

~10 s

Keep layout (padding, flex, radii, fonts) in a module-scope StyleSheet.create, select a light or dark palette object from useColorScheme in one provider, share it through context, and apply colours per render in style arrays.

solid answer

~40 s

Split what changes from what does not. Layout, spacing, radii and typography are the same in both schemes, so they live in a static `StyleSheet.create` at module scope. Colours come from two **palette objects**, `light` and `dark`, defined once as constants. A `ThemeProvider` at the app root calls `useColorScheme()` once, picks the matching palette and passes it through React context; components read it with a `useTheme()` hook and merge it in a style array, `style={[styles.row, { backgroundColor: theme.surface }]}`. Because each palette is a module-level constant, the context value only changes identity when the scheme actually flips. Use semantic names like `surface`, `textPrimary` and `negative` rather than `white` or `red`, so a dark palette can remap them without touching components.

code

tsx · 36 lines
tsx
import { createContext, useContext, type ReactNode } from 'react';
import { StyleSheet, Text, View, useColorScheme } from 'react-native';

type Palette = { surface: string; textPrimary: string; positive: string; negative: string };

const palettes: Record<'light' | 'dark', Palette> = {
  light: { surface: '#ffffff', textPrimary: '#11151a', positive: '#1a7f37', negative: '#c62828' },
  dark: { surface: '#15181d', textPrimary: '#eef1f5', positive: '#4ac26b', negative: '#ff6b6b' },
};

const ThemeContext = createContext<Palette>(palettes.light);

export function ThemeProvider({ children }: { children: ReactNode }) {
  const scheme = useColorScheme();
  const palette = palettes[scheme === 'dark' ? 'dark' : 'light'];
  return <ThemeContext.Provider value={palette}>{children}</ThemeContext.Provider>;
}

export const useTheme = () => useContext(ThemeContext);

export function TransactionRow({ label, amount }: { label: string; amount: number }) {
  const theme = useTheme();
  return (
    <View style={[styles.row, { backgroundColor: theme.surface }]}>
      <Text style={[styles.label, { color: theme.textPrimary }]}>{label}</Text>
      <Text style={{ color: amount < 0 ? theme.negative : theme.positive }}>
        {amount.toFixed(2)}
      </Text>
    </View>
  );
}

const styles = StyleSheet.create({
  row: { flexDirection: 'row', justifyContent: 'space-between', padding: 16 },
  label: { flexShrink: 1, fontSize: 16 },
});

go deeper

for a junior

Recall that colours come from a palette chosen by useColorScheme, while layout stays in a static StyleSheet, merged with a style array.

for a middle

Explain the provider, context and hook pieces, semantic palette keys, and why palette identity decides how often consumers re-render.

for a senior

Design the theme layer for a large app: one subscription at the root, semantic tokens, third-party components fed from the same palette, and tests in both schemes.

for a principal

Decide how the theme layer is owned across teams, how new colour roles are added, and how design and code stay in sync as the palette grows.

## Split what changes from what does not In a banking app, switching to dark mode changes **colours**: backgrounds, text, borders, the green of an incoming payment and the red of an outgoing one. It does not change **layout**: the padding of a transaction row, the radius of the balance card, font sizes and weights, flex directions. A theming design that treats both the same way rebuilds far more than it needs to. | Kind of style | Changes with the scheme | Where it lives | |---|---|---| | Padding, margin, gap, flex, radii | No | Module-scope `StyleSheet.create` | | Font size, weight, line height | No | Module-scope `StyleSheet.create` | | Background, text, border colours | Yes | Palette object from context | | Semantic accents (positive, negative, warning) | Yes | Palette object from context | ## The pieces 1. **Palettes**: two plain objects, `light` and `dark`, with the same keys. Define them at module scope so each has one stable identity for the life of the app. 2. **Provider**: one `ThemeProvider` near the root calls `useColorScheme()` and chooses the palette. It is the **only** subscriber to appearance changes. 3. **Context and hook**: `createContext` holds the palette; a `useTheme()` hook returns it with `useContext`. 4. **Components**: each keeps its static `styles` and merges colours at render time in a style array. When the phone switches to dark, the provider re-renders with the dark palette, the context value changes, and the components that read it re-render with new colours. The static layout objects are untouched. ## Semantic colour names Name palette keys by **role**, not by value: - `surface`, `surfaceRaised`, `textPrimary`, `textSecondary`, `border` - `positive` and `negative` for amounts, `accent` for primary actions A key called `white` must lie in dark mode; a key called `surface` can be light grey in one palette and near-black in the other. Designing which roles exist and how they contrast is a design-system decision; in code the point is that components never mention a literal colour. ## Why not rebuild the whole StyleSheet per scheme A common alternative is a function `makeStyles(theme)` that returns a full `StyleSheet.create({...})` with colours baked in, called inside components. It works, but: - every component recreates its whole style object when it renders, unless memoized on the palette, - layout values are duplicated into two style objects that differ only in colours, - it hides which values are actually theme-dependent. If you prefer that pattern, create both variants once, `lightStyles` and `darkStyles`, at module scope and pick one per render; that keeps object creation out of the render path. ## Keeping re-renders proportional - Keep the palette objects at module scope, so the provider's value keeps the same identity until the scheme really changes. - Do not wrap the palette in a new object per render in the provider, such as `value={{ palette }}`, which gives consumers a new value every time the provider renders. - Put the provider above navigation so every screen reads the same palette. ## Beyond the app's own components Libraries render their own UI and need their own theming inputs: a navigation library takes its own theme object, and native elements such as alerts and pickers follow the platform's appearance. Feed those from the same palette so the whole app changes together. ## Adding a new colour role Palettes grow. When a design adds, say, a colour for pending transactions: 1. Add the key to the `Palette` type, so TypeScript flags every palette that lacks it. 2. Add a value to **both** the light and the dark palette in the same change. 3. Use it in components through `useTheme()`, never as a literal. 4. Check its contrast on both surfaces before it ships. Keeping the palette type strict turns "forgot the dark value" from a visual bug found by a user into a compile error. ## Testing the theme - Render key screens in both schemes and compare them; a missed literal colour shows up as a light patch on a dark screen. - Switch the system setting while a screen is open to confirm it updates without navigating. - Check contrast of `positive` and `negative` amounts on both surfaces.

  • Why is value={palette} fine here while value={{ palette }} would not be?
    `palette` is one of two module-level objects, so its identity only changes when the scheme flips, and consumers re-render only then. `{{ palette }}` builds a new wrapper object on every render of the provider, so every consumer would re-render whenever the provider does, even with the same scheme.
  • How would you add a user-selectable theme on top of this design?
    Store the user's choice, system, light or dark, and apply it with `Appearance.setColorScheme('light' | 'dark' | 'auto')`. `useColorScheme()` then reports the effective scheme, so the provider needs no changes, and native elements such as alerts and pickers follow the same choice.

The static StyleSheet is the furniture layout of a room and the palette is the lighting: switching to evening lighting changes how everything looks without anyone moving a chair.

saying these in an interview costs you the question

  • Dark mode needs every StyleSheet recreated with new padding and font sizes.
  • Each component should call useColorScheme and hold its own colour logic.
  • Palette keys named white and black work fine in both schemes.
  • Wrapping the palette in a fresh object per render has no cost.
  • React Native styles cascade, so setting a dark background on the root recolours everything.