PR
Expo bareExpo RouterReact Context APIReact NativeReact Native PaperTypeScript Light/Dark Theme

[How-to] Light・Dark テーマを手動で切り替える実践方法〜React Native Paper〜

jogging-4211946_1280 Expo bare
スポンサーリンク

開発環境

Expo Router 環境です。詳細は ⬇︎

app/ フォルダー構造

try🐶everything myproject$ tree app
app
├── +html.tsx
├── [...missing].tsx
├── _layout.tsx
└── index.tsx

src/ フォルダー構造

try🐶everything myproject$ tree src
src
├── common
│   ├── hooks
│   │   ├── index.ts
│   │   ├── themeContext.ts
└── components
    ├── ExternalLink.tsx
    └── index.ts

try🐶everything myproject$ 

実装する

ThemeContext を作成する

コンテキストを作成するには、コンポーネントの外部で createContext を呼び出します。

// src/common/hooks/themeContext.ts

import {createContext} from 'react';

export interface ThemeContextProps {
  preferredTheme: string;   // <-- preferredTheme は任意
  toggleTheme: () => {};       // <-- toggleTheme は任意
}


export const ThemeContext = createContext({   // <-- ThemeContext は任意
  preferredTheme: 'light',
  toggleTheme: () => {},
});

コンポーネントをラップする

コンポーネントをコンテキスト プロバイダーにラップして、内部のすべてのコンポーネントにこのコンテキストの値を指定します。

toggleTheme() を実行する度に、preferredTheme 値がトグルされ、paperTheme 値も更新されます。

ThemeContext.Providervalue 値として preferences を渡します。

ラップする際の注意点は、Line 17,18 のように PaperProvider もラップすることです。

// app/_layout.tsx

...
import {ThemeContext} from 'common/hooks/themeContext';
...

export default function RootLayout() {
  const [preferredTheme, setTheme] = React.useState('light');
  const toggleTheme = () => {
    setTheme(preferredTheme => (preferredTheme === 'light' ? 'dark' : 'light'));
  };
  const preferences = React.useMemo(
    () => ({preferredTheme, toggleTheme: toggleTheme}),
    [preferredTheme, toggleTheme],
  );

  const paperTheme =
    preferredTheme === 'dark'
      ? {...MD3DarkTheme, colors: jtheme.dark.colors}
      : {...MD3LightTheme, colors: jtheme.light.colors};

  ...
  return (
    <ThemeContext.Provider value={preferences}>
      <PaperProvider theme={paperTheme}>
        <RootLayoutNav />
      </PaperProvider>
    </ThemeContext.Provider>
  );
}

function RootLayoutNav() {
  return (
    <Stack>
      <Stack.Screen name="index" />
      ...
    </Stack>
  );
}

テーマをトグルする

メニューを実装するファイルで行ってください。

backgroundColor は変わらないので Line 15 で設定を加えています。(仕様?不明!)

// app/index.tsx

 // コピペで使用可能

import React, {memo} from 'react';
import {StyleSheet, View} from 'react-native';
import {Button, useTheme} from 'react-native-paper';
import {Ionicons} from '@expo/vector-icons';
import {ThemeContext} from 'common/hooks/themeContext';

const HomeScreen = () => {
  const theme = useTheme();
  const {preferredTheme, toggleTheme} = React.useContext(ThemeContext);

  return (
    <View
      style={[styles.container, {backgroundColor: theme.colors.background}]}>
      <View style={{position: 'absolute', top: 0, right: 0, margin: 20}}>
        {preferredTheme == 'dark' ? (
          <Ionicons
            name="sunny"
            size={24}
            color={theme.colors.tertiary}
            onPress={toggleTheme}
          />
        ) : (
          <Ionicons
            name="moon"
            size={24}
            color={theme.colors.tertiary}
            onPress={toggleTheme}
          />
        )}
      </View>
      <Button
        icon="camera"
        mode="contained"
        onPress={() => console.log('Pressed!')}>
        Press me
      </Button>
    </View>
  );
};
const styles = StyleSheet.create({
  container: {flex: 1, alignItems: 'center', justifyContent: 'center'},
});
export default memo(HomeScreen);

ボタンを押しながら動作を確認できます。

実際に組み込むときの補足(2026 年 7 月 追記)

Context でテーマを配り、PaperProvider に渡す——という構成は今も同じです。そのうえで、実際のアプリに入れるときに気づいた点を 4 つ書いておきます。

1. useMemo が効いていません

本文のこの部分です。

const preferences = React.useMemo(
  () => ({preferredTheme, toggleTheme: toggleTheme}),
  [preferredTheme, toggleTheme],
);

toggleTheme再描画のたびに新しく作り直される関数です。それを依存配列に入れているので、毎回「変わった」と判定されます。つまり useMemo でキャッシュしているつもりが、毎回作り直されている状態です。

関数のほうを useCallback で固定すれば、意図どおりに動きます。

const toggleTheme = React.useCallback(() => {
  setTheme(prev => (prev === 'light' ? 'dark' : 'light'));
}, []);

const preferences = React.useMemo(
  () => ({preferredTheme, toggleTheme}),
  [preferredTheme, toggleTheme],
);

更新関数の中で前の値を受け取る形(setTheme(prev => ...))にしているので、依存配列は空で構いません。Context を購読しているコンポーネントが無駄に再描画されなくなります。

2. インターフェースの戻り値の型

細かい点ですが、型定義がこうなっています。

toggleTheme: () => {};   // ← 何かを返すことになっている

実際の toggleTheme は何も返しません。void が正しい書き方です。

toggleTheme: () => void;

3. 選んだテーマが保存されません

本文の実装では、アプリを閉じて開き直すと light に戻ります。ユーザーからすると「設定したのに戻っている」という体験になります。

@react-native-async-storage/async-storage に保存して、起動時に読み戻してください。

// 起動時に読み込む
React.useEffect(() => {
  AsyncStorage.getItem('theme').then(saved => {
    if (saved) setTheme(saved);
  });
}, []);

// 切り替えたときに保存する
const toggleTheme = React.useCallback(() => {
  setTheme(prev => {
    const next = prev === 'light' ? 'dark' : 'light';
    AsyncStorage.setItem('theme', next);
    return next;
  });
}, []);

読み込みが終わるまで一瞬 light で表示されるので、気になる場合は読み込み完了までスプラッシュを出したままにしておくとちらつきません。

4. 端末の設定に合わせる

本文は「手動で切り替える」方法ですが、多くのアプリは端末のダークモード設定に自動で追従します。React Native の useColorScheme() で取得できます。

実用的には、次の 3 択にしておくと親切です。

  • 端末の設定に合わせる(既定)
  • 常にライト
  • 常にダーク

保存する値を 'light' | 'dark' | 'system' の 3 種類にして、'system' のときだけ useColorScheme() の値を使う、という形にすれば、本文の構成をほぼそのまま拡張できます。

おまけ:ファイル名の変更

本文のフォルダ構成にある [...missing].tsx は、Expo Router の古い書き方です。現在は +not-found.tsx になっています。テーマとは関係ありませんが、構成をそのまま真似すると動かないのでご注意ください。

※ 1〜3 は本文に掲載したコードを読み直して整理したものです。4 とおまけを含め、改めて動作検証を行ったものではありません。

参考文献

createContext()

Theming // <– ⬆︎ コード内の jtheme.{dark|light}.colors を作成する方法

コメント

タイトルとURLをコピーしました