📱

React Navigation から Expo Router に移行した話

に公開

はじめに

業務委託でお手伝いしている 株式会社Another works さんの React Native アプリケーションにおいて、React Navigation から Expo Router への移行をご依頼を受けて行いました。
この記事は、React Native アプリケーションにおいて React Navigation から Expo Router へ移行した際の対応内容の記録です。 (React Navigation や Expo Router が何なのか等の説明は省略します。)
この移行はスキマ時間でちょこちょこ進めて2024年8月 - 2025年4月の約8ヶ月間[1]で行いました。 規模感としては、100近くの画面がある比較的規模の大きなアプリケーションです。

大きいアプリを React Navigation -> Expo Router マイグレーションするの困ってる人はいそうだけど、正攻法を知っている人はいなさそうな雰囲気です。
https://github.com/expo/expo/discussions/27096

移行作業は基本 Expo 公式マイグレーションドキュメント に従って進めました。可能な限り小さい粒度で段階的に移行を進め、互換レイヤーを作成することで、既存のコードの大幅な変更を回避して移行を進めることを意識しました。
通常の機能開発は特に止めずに、並行で進めていた形になります。

移行の全体像

移行はざっくり以下のフェーズに分けて行いました。

  1. 準備フェーズ(2024年8月):コードベースの把握、基本的なファイル構造の整備と互換性レイヤーの作成
  2. ファイルベースルーティングの調整(2024年9月〜2025年2月):Expo Router 向けのファイルベースルーティングの導入
  3. ナビゲーション処理の修正(2025年2月〜3月):ナビゲーションの受け渡し方法の改善
  4. Expo Router の組み込みと最終調整(2025年3月〜4月):組み込みと最終調整

それぞれのフェーズについて詳しく見ていきます。
概ね実際の作業の時系列に沿って記載していますが、実際の作業は並行して進めている部分もありました。

準備フェーズ(2024年8月)

コードベースの把握

まず React Navigation を使用しているコードベースのお掃除をして手を動かしつつ、理解を進めて特徴を把握しました。
一部挙げてみると以下のような感じでした。

  • スタックナビゲーションとタブナビゲーションが使用されていた
  • 画面遷移は主に navigation.navigate を使用しており、ページパラメータの受け渡しに非 primitive な関数が渡されるなどしていた
    • navigationuseNavigation を使わずにエンドポイントのコンポーネントの Props から解決して子コンポーネントへバケツリレーしているケースがあった
  • ページのパラメータを子・孫コンポーネントにおける hooks で react-navigation の useRoute を利用して局所解決しているケースが散見された
  • NavigationContainer.onReady, NavigationContainer.onStateChange を利用して画面の遷移ログを記録していた。その際に RouteName で記録されていた

現状認識の確認

当初の自分の理解は、Expo Router は React Navigation をいい感じにラップしたスーパーセット?程度の認識で、きっと部分的に組み込みを進めて移行できるのでは?と思っていました。
影響の小さそうな末端の画面から小さく Expo Router を組み込んでいけるといいなぁ〜と思っていました。
が、少し実験をしてみるとアプリケーションのトップレベルで利用している NavigationContainerexpo-routerが内部的に組み立てているNavigatorContainer が NavigatorContainer を入れ子にできない制約とぶつかってビルドできないことに気づきました。
アプリケーションのトップレベルに Expo Router を組み込むことがマスト。と認識のズレを調整するなどしました。

画面遷移のログをどうするかの調整

React Navigation において https://reactnavigation.org/docs/screen-tracking/ の仕組みで画面遷移ログを記録していました。
Expo Router 移行後は https://docs.expo.dev/router/reference/screen-tracking/#migrating-from-react-navigation の通り、NavigationContainer.onReady, NavigationContainer.onStateChange が使えなくなるというところで、画面の名前ではなく画面へのパスで画面遷移ログが記録されるようになることを社員の方から了承してもらいました。

expo-routerのインストール

先んじで expo-router の API を解決できるようにインストールだけ済ませておきました

npx expo install expo-router

組み込みと有効化はこの段階ではしていません。

ファイルベースルーティングの調整(2024年9月〜2025年2月)

ファイルベースのルーティングの構想と修正

移行に向けてファイルベースのルーティングの構想と修正を行いました。 Expo Router では (foo) のようなディレクトリ名を使用して、ルーティングのグループ化を行うことができます。例えば src/app/(foo)/bar/index.tsx はURLパス /bar (/(foo)/bar でも OK)にマッピングできます。
作業としては以下のような内容を行いました。

  • 新たに作成した src/app ディレクトリに、Expo Router のルーティング構造に合わせたディレクトリ構成を準備
    • あとはひたすら、URLパスに合わせてディレクトリ構成の変更とエンドポイントを設置しました

上記の作業内容を擬似コードを用いつつもう少し詳しく示していきます。
React Navigation におけるルーティングはざっくりと以下のような形になっていました。
このファイルは __stack_legacy.tsx のような形にリネームして配置しました。
Expo Router 移行後に削除する予定です。

// src/app/(foo)/__stack_legacy.tsx
export const FooStack = () => {
  return (
    <FooStackNav.Navigator screenOptions={{ headerShown: true }}>
      {/* 移行するには HomeScreen, MessageScreen の単位で個別のファイルにエンドポイントとして切り出す必要がある */}
      <FooStackNav.Screen name="Home" component={HomeScreen} />
      <FooStackNav.Screen name="Message" component={MessageScreen} />
    </FooStackNav.Navigator>
  );
};

上記のルーティングに対して Expo Router では、以下のようなディレクトリ構成を適用するイメージです。

src/app
└── (foo)
    ├── message.tsx
    └── index.tsx

Expo Router ではファイルベースのルーティングとなるので、URL パスとディレクトリ構造が一致するように配置を検討します。
__stack_legacy.tsx で参照していた各スクリーンコンポーネントを薄くラップする形で、エンドポイントとなるファイルを新規で配置しました。

// src/app/(foo)/index.tsx
export default const Index = () => {
  return (
    <HomeScreen />
  );
};

// src/app/(foo)/message.tsx
export default const Message = () => {
  return (
    <MessageScreen />
  );
};

合わせて、Expo Router 向けのレイアウトファイル(_layout.tsx)を新規で配置しました。
Expo Router のAPIを利用していますが、配置するだけなら React Navigation を利用しているフェーズでもプロダクションに影響はないはずです。

// src/app/(foo)/_layout.tsx
import React from 'react';
import { Stack } from 'expo-router/stack';

export default function Layout() {
  return (
    <Stack screenOptions={{ headerShown: true }} />
  );
}

最終的なイメージは以下のような形になります。

src/app
├── (foo)
│   ├── __stack_legacy.tsx
│   ├── _layout.tsx
│   ├── index.tsx # / or /(foo)
│   └── message.tsx # /message or /(foo)/message
├── __stack_legacy.tsx
├── _layout.tsx
├── bar
│   ├── index.tsx # /bar
│   └── [barId].tsx # /bar/:barId
└── index.tsx # /

こんな感じを目指して、ひたすら調整をしていきました。
実際の移行作業では複数のグループを作成しました。上記はサンプル的に作業内容を抽象化してまとめたものになります。

あとは、BottomTab を利用している箇所については https://docs.expo.dev/router/advanced/tabs/ の構成に倣って (tabs) グループでまとめるなどしました。

Top Tabs の修正

React Navigation の material-top-tabs を Expo Router に移行するにあたってなんとかする必要があります。

  1. ファイルベースルーティングを適用する形に置き換える
  2. react-native-tab-view を使う

のどちらかで対応できます。
タブを1つのページとして取り扱いたい場合は 1.、それ以外は 2. を選びます。

下のような material-top-tabs を使ったルーティングを

import { createMaterialTopTabNavigator } from '@react-navigation/material-top-tabs';

const Tab = createMaterialTopTabNavigator();

function TobTabs() {
  return (
    <Tab.Navigator>
      <Tab.Screen name="Tab1" component={Tab1Screen} />
      <Tab.Screen name="Tab2" component={Tab2Screen} />
    </Tab.Navigator>
  );
}

1. の場合、対応する _layout.tsx を用意します。
https://reactnativepro.dev/posts/expo-router-top-tabs を参考に withLayoutContextNavigator を包む感じです。

import { withLayoutContext } from 'expo-router';
import {
  MaterialTopTabNavigationEventMap,
  MaterialTopTabNavigationOptions,
  createMaterialTopTabNavigator,
} from '@react-navigation/material-top-tabs';
import { ParamListBase, TabNavigationState } from '@react-navigation/native';

const { Navigator } = createMaterialTopTabNavigator();
const TopTabs = withLayoutContext<
  MaterialTopTabNavigationOptions,
  typeof Navigator,
  TabNavigationState<ParamListBase>,
  MaterialTopTabNavigationEventMap
>(Navigator);

export default function Layout() {
  return (
    <TopTabs>
      <TopTabs.Screen name="tab1" />
      <TopTabs.Screen name="tab2" />
    </TopTabs>
  );
}

2. の場合
react-native-tab-view を使って、以下のようになる感じ。

import React, { useState } from 'react';
import { Route, SceneMap, TabBar, TabView } from 'react-native-tab-view';

export function TabViewExample() {
  const [index, setIndex] = useState(0);
  const routes: Route[] = [
    { key: 'tab1', title: 'Tab 1' },
    { key: 'tab2', title: 'Tab 2' },
  ];

  const renderScene = SceneMap({
    tab1: () => <Tab1Screen />,
    tab2: () => <Tab2Screen />,
  });

  return (
    <TabView
      navigationState={{ index, routes }}
      renderScene={renderScene}
      renderTabBar={(props) => <TabBar {...props} />}
      onIndexChange={setIndex}
    />
  );
}

withLayoutContext への型の渡し方が複雑で、 @react-navigation/material-top-tabsexpo-router 間の橋渡しに危うさを感じるので、要件的にOKなら 2. の方が、良さそうな気がします。

useRouteを利用して局所解決しているケースの整理

react-navigation への依存が各所に散らばっている状態で、移行時の考慮事項を減らすために、事前にエントリポイントとなるファイルでページのパラメータを解決し、子コンポーネントにバケツリレーするように統一と整理をしました。

// src/app/(foo)/bar/[barId].tsx
export default function Bar({ route }: Props) {
  // FIXME: expo-router に移行したらrouteからの解決はできなくなるのでuseLocalSearchParamsを使うようにしてください
  // const { barId } = useLocalSearchParams();
  return <BarScreen barId={route.params.barId} />;
}

hooks や子・孫コンポーネントにおいては、以下の差分のように引数でパラメータを渡すように調整しました。

// src/features/bar/components/hooks/useBar.ts
- import { useRoute } from '@react-navigation/native';

- export function useBar() {
+ export function useBar({ barId }: { barId: string }) {
-  const route = useRoute();
-  const barId = route.params.barId;
  // ...
}

ファイル名の規則変更

https://note.com/yuyayamaki/n/n30f3d78027cc を参考に、エンドポイントに当たるファイル名をキャメルケースからケバブケースに変更する作業をしました。

ページパラメータに関数を渡している部分の修正

React Navigation では、ページパラメータに関数を渡すことができましたが、Expo Router では URL ベースのアプローチに変更されるあたりから、関数を含める非 primitive な値を渡すことができなくなります。
https://docs.expo.dev/router/migrate/from-react-navigation/#refactor-search-parameters

// React Navigation で route.params からページパラメータに指定した関数を解決していた例
// BazScreen.tsx で以下のようにページパラメータが渡されていた
// navigation.setParams({ onPress: () => /* 何らかの処理 */ });
<BazStackNav.Screen
  options={({ route }: { route: any }) => ({
    headerRight: () => (
      <Button onPress={() => route.params.onPress()} />
    ),
  })}
  name="Baz"
  component={BazScreen}
  />

Expo Router 向けに、navigation.setParamsBazStackNav.Screen.options の指定をやめて、以下のように Screen コンポーネント内で navigation.setOptions で onPress を直接渡すようにしました。

// BazScreen.tsx
export function BazScreen() {
  const navigation = useNavigation();
  const onPress = () => { /* 何らかの処理 */ };
  useLayoutEffect(() => {
    navigation.setOptions({
      headerRight: () => (
        <Button onPress={onPress} />
      )
    });
  }, [navigation]);
}

ナビゲーション処理の修正(2025年2月〜3月)

互換性レイヤーの作成

React Navigation から Expo Router への移行をスムーズにするため、互換性レイヤーとして useNavigation のカスタム hook を作成しました。この hook で React Navigation の API を直接使っている箇所を全て置き換えました。

// src/common/hooks/useNavigation.ts
import {
  NavigationProp,
  // eslint-disable-next-line no-restricted-imports
  useNavigation as useNavigationRN
} from '@react-navigation/native';

/**
 * useNavigation のラッパーhook
 * expo-router の移行に備えて、こちらを利用してください
 * 移行時にバチッと切り替える想定です
 */
export function useNavigation<T extends NavigationProp<any>>() {
  return useNavigationRN<T>();
}

また、ESLintのルールも追加して、直接 @react-navigation/native から useNavigation をインポートすることを警告するようにしました。

{
  name: '@react-navigation/native',
  importNames: ['useNavigation'],
  message: 'common/hooks/useNavigationを利用してください。'
}

アプリケーション全体で一貫して自前でラップした useNavigation を使用するように修正して、将来的な移行を容易にしようというのが狙いです。
ここで定義した useNavigation の中身は後ほど書き換えます。

navigationuseNavigation を使わずにエンドポイントのコンポーネントの Props から解決して子コンポーネントへバケツリレーしているケースがありました。
Expo Router 移行時に、route と共に navigation を Props として渡すことができなくなるため、これを解消する必要があります。

export default function InputName({ navigation, route }: Props) {
  const { name } = route.params;
  return <Foo name={name} navigation={navigation}/>;
}

このような場合、以下のように useNavigation を必要な場所で呼び出してナビゲーションオブジェクトを取得するようにしました。

export default function InputName() {
  // FIXME: expo-router 移行時には route.params からの解決はできなくなるので、useLocalSearchParamsを使うようにしてください
  // const { name } = useLocalSearchParams();
  const route = useRoute();
  return <Foo name={route.params.name} />;
}

// src/features/foo/components/Foo.tsx
import { useNavigation } from '@/common/hooks/useNavigation';

export function Foo() {
  const navigation = useNavigation();
  // ...
}

Expo Router の組み込みと最終調整(2025年3月〜4月)

App の初期化処理を共通処理として切り出し

アプリケーションの初期化処理を共通処理として切り出しました。
React Navigation におけるエントリポイントとなる index.js 向けに集約されていた初期化処理を、コンポーネントとして切り出しました。
そのうえで、Expo Router のエントリポイントとなる階層の src/app/_layout.tsx にも組み込みました。

初期化用の共通のコンポーネントを以下のような形で新規に作成しました。

// src/common/components/app.tsx
type Props = {
  expoRouter: boolean;
  children: ReactNode;
};
export function App({ expoRouter, children }: Props) {
  // アプリケーションの初期化処理
  // ...
  if (expoRouter) {
    // Expo Router の初期化処理
    // ...
    return (
      { /* Expo Router 向けのルートコンポーネント */ };
      <RootForExpoRouter>{children}</RootForExpoRouter>
    )
  }
  return (
    { /* React Navigation向けのルートコンポーネント */ };
    <RootForReactNavigation>{children}</RootForReactNavigation>
  )
}

React Navigation 向けのエントリポイントには以下のように組み込みました。

// App.ts
import { App as TApp } from './src/common/components/app';
import RootStackScreens from './src/app/__stack_legacy';

// index.js から参照されます
export const App = () => {
  // ここで定義されていた初期化処理を src/common/components/app.tsx に切り出しました
  return (
    <TApp expoRouter={false}>
      <RootStackScreens />
    </TApp>
  );
};
// index.js
import { registerRootComponent } from 'expo';
import { App } from './App';

registerRootComponent(App);

次に、Expo Router 向けのエントリポイントを以下のように組み込みました。

// src/app/_layout.tsx
import { App as TApp } from './src/common/components/app';

export default function Layout() {
  return (
    <App expoRouter>
      <Stack />
    </App>
  );
}

Expo Router の組み込み

expo-router は事前にインストールしてたものの、先送りにしていた組み込みをこのタイミングで行いました。

package.jsonmain フィールドを expo-router/entry に変更して、エントリポイントの index.js から脱却となります。

// package.json
{
+  "main": "expo-router/entry",
   "scripts": {

https://docs.expo.dev/router/reference/typed-routes/ の通り、 app.jsonexperiments.typedRoutes: true を追加しました。
これで、 .expo/types/router.d.ts に型ファイルを作ってくれるようになって、パスベースのルーティングの補完が効くようにしました。

// app.json
{
  "expo": {
+    "experiments": {
+      "typedRoutes": true
+    }
  }
}

用意した互換性レイヤーの実装をExpo Router用に書き換え

事前に用意していた useNavigation のラッパー hook を以下のように書き換えました。

// src/common/hooks/useNavigation.ts
import { useRouter, useNavigation as useNavigationER } from 'expo-router';

/**
 * react-navigation -> expo-router の移行において
 * 関数のシグネチャの差分を吸収するために用意したラッパーhook
 */
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- 変更差分を小さくするため一時的に無視。型パラメータは削除して良い
export function useNavigation<T>() {
  const router = useRouter();
  const { addListener, setOptions, isFocused, dispatch } = useNavigationER();

  return {
    // router のプロパティも直接アクセスできるようにする
    ...router,
    // 過去のシグネチャに合わせて router.back を wrap
    goBack: () => router.back(),
    addListener,
    setOptions,
    isFocused,
    dispatch
  };
}

パラメータの受け渡し方法の変更

React Navigation では、パラメータをオブジェクトとして渡していたのを、 URL クエリパラメータとして渡すようにしました。

例えば、以下のような感じ

React Navigation

navigation.navigate(‘FooDetail’, { userId: 123 });

Expo Router

navigation.navigate(`/foo/${userId}`);

パラメータの受け取り方は以下のような感じ

React Navigation

const { userId } = route.params;

Expo Router

import { useLocalSearchParams } from 'expo-router';

const { userId } = useLocalSearchParams<{ userId: string }>();

ひたすらこんな感じで修正していきました。
これで移行が完了しました。

おわりに

概ねこのような流れで移行しました。
進めていく中で、この記事で紹介した内容以外にも様々な課題に直面しました。例えば、 react-navigation/bottom-tabsexpo-router が内部で使っているものとコードベースで使っているバージョンで不一致があり、それに伴い連鎖的に exporeact-native 関連パッケージのバージョンアップが必要になるなど、検討すべきことや細かい修正がたくさん出てきました。
この記事を書いてる現在、移行時のつなぎ対応や不要になったコードの削除をしています。
この記事が、React Navigation から Expo Router への移行を検討している人に参考となるものが何かあれば幸いです。

脚注
  1. 振り返って稼働時間を確認してみたら、平均16時間/月でした。 ↩︎

Discussion