React Native + Expo Router:ファイルベースルーティングでモバイルアプリ開発


React Native + Expo Router:ファイルベースルーティングでモバイルアプリ開発

React Nativeのナビゲーション管理は、従来React Navigationを使った命令的なアプローチが主流でした。しかし、Expo Routerの登場により、Next.jsのようなファイルベースルーティングがモバイルアプリ開発にもたらされました。本記事では、Expo Routerを使った現代的なReact Nativeアプリ開発を徹底解説します。

Expo Routerとは

Expo Routerは、ファイルシステムベースのルーティングをReact Nativeに実装するライブラリです。内部的にはReact Navigationを使用していますが、宣言的で直感的なAPIを提供します。

主な特徴

  • ファイルベースルーティング: ファイル構造がそのままルート構造に
  • TypeScript完全サポート: 型安全なナビゲーション
  • ディープリンク対応: URLから直接画面へ遷移
  • レイアウトコンポーネント: 共通UIの再利用
  • ネイティブナビゲーション: iOS/Androidのネイティブ動作
  • Web対応: 同じコードでWebアプリも構築可能

React Navigationとの比較

// React Navigation(従来)
const Stack = createNativeStackNavigator();

function App() {
  return (
    <NavigationContainer>
      <Stack.Navigator>
        <Stack.Screen name="Home" component={HomeScreen} />
        <Stack.Screen name="Profile" component={ProfileScreen} />
      </Stack.Navigator>
    </NavigationContainer>
  );
}

// Expo Router(新しいアプローチ)
// ファイル構造だけでルーティングが完成
// app/index.tsx → "/"
// app/profile.tsx → "/profile"

プロジェクトセットアップ

新規プロジェクトの作成

# Expo Routerテンプレートでプロジェクト作成
npx create-expo-app@latest my-app --template tabs

# または既存プロジェクトに追加
npx create-expo-app@latest my-app
cd my-app
npx expo install expo-router react-native-safe-area-context react-native-screens expo-linking expo-constants expo-status-bar

package.jsonの設定

{
  "name": "my-app",
  "version": "1.0.0",
  "main": "expo-router/entry",
  "scripts": {
    "start": "expo start",
    "android": "expo start --android",
    "ios": "expo start --ios",
    "web": "expo start --web"
  },
  "dependencies": {
    "expo": "~50.0.0",
    "expo-router": "~3.4.0",
    "react": "18.2.0",
    "react-native": "0.73.0",
    "react-native-safe-area-context": "4.8.2",
    "react-native-screens": "~3.29.0"
  },
  "devDependencies": {
    "@types/react": "~18.2.45",
    "typescript": "^5.3.0"
  }
}

app.jsonの設定

{
  "expo": {
    "name": "my-app",
    "slug": "my-app",
    "scheme": "myapp",
    "version": "1.0.0",
    "orientation": "portrait",
    "icon": "./assets/icon.png",
    "userInterfaceStyle": "automatic",
    "splash": {
      "image": "./assets/splash.png",
      "resizeMode": "contain",
      "backgroundColor": "#ffffff"
    },
    "plugins": [
      "expo-router"
    ]
  }
}

ファイルベースルーティングの基本

ディレクトリ構造

app/
├── _layout.tsx          # ルートレイアウト
├── index.tsx            # / (ホーム画面)
├── about.tsx            # /about
├── (tabs)/              # タブグループ
│   ├── _layout.tsx
│   ├── home.tsx         # /home
│   └── profile.tsx      # /profile
├── (auth)/              # 認証グループ
│   ├── _layout.tsx
│   ├── login.tsx        # /login
│   └── signup.tsx       # /signup
├── posts/               # 動的ルート
│   ├── [id].tsx         # /posts/:id
│   └── index.tsx        # /posts
└── [...missing].tsx     # 404ページ

基本的な画面の作成

app/index.tsx:

import { View, Text, StyleSheet } from 'react-native';
import { Link } from 'expo-router';

export default function HomeScreen() {
  return (
    <View style={styles.container}>
      <Text style={styles.title}>Welcome to Expo Router</Text>
      <Link href="/about" style={styles.link}>
        About Page
      </Link>
      <Link href="/posts/1" style={styles.link}>
        Post #1
      </Link>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
    padding: 20,
  },
  title: {
    fontSize: 24,
    fontWeight: 'bold',
    marginBottom: 20,
  },
  link: {
    fontSize: 18,
    color: '#007AFF',
    marginTop: 10,
  },
});

レイアウトコンポーネント

ルートレイアウト

app/_layout.tsx:

import { Stack } from 'expo-router';
import { useEffect } from 'react';
import * as SplashScreen from 'expo-splash-screen';

// スプラッシュスクリーンを表示し続ける
SplashScreen.preventAutoHideAsync();

export default function RootLayout() {
  useEffect(() => {
    // 初期化後にスプラッシュスクリーンを隠す
    SplashScreen.hideAsync();
  }, []);

  return (
    <Stack
      screenOptions={{
        headerStyle: {
          backgroundColor: '#007AFF',
        },
        headerTintColor: '#fff',
        headerTitleStyle: {
          fontWeight: 'bold',
        },
      }}
    >
      <Stack.Screen 
        name="index" 
        options={{ title: 'Home' }} 
      />
      <Stack.Screen 
        name="about" 
        options={{ title: 'About' }} 
      />
      <Stack.Screen 
        name="(tabs)" 
        options={{ headerShown: false }} 
      />
    </Stack>
  );
}

タブナビゲーション

app/(tabs)/_layout.tsx:

import { Tabs } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';

export default function TabLayout() {
  return (
    <Tabs
      screenOptions={{
        tabBarActiveTintColor: '#007AFF',
        tabBarInactiveTintColor: '#8E8E93',
        headerStyle: {
          backgroundColor: '#007AFF',
        },
        headerTintColor: '#fff',
        tabBarStyle: {
          backgroundColor: '#fff',
          borderTopWidth: 1,
          borderTopColor: '#E5E5EA',
        },
      }}
    >
      <Tabs.Screen
        name="home"
        options={{
          title: 'Home',
          tabBarIcon: ({ color, size }) => (
            <Ionicons name="home" size={size} color={color} />
          ),
        }}
      />
      <Tabs.Screen
        name="search"
        options={{
          title: 'Search',
          tabBarIcon: ({ color, size }) => (
            <Ionicons name="search" size={size} color={color} />
          ),
        }}
      />
      <Tabs.Screen
        name="profile"
        options={{
          title: 'Profile',
          tabBarIcon: ({ color, size }) => (
            <Ionicons name="person" size={size} color={color} />
          ),
        }}
      />
    </Tabs>
  );
}

ナビゲーション

プログラマティックナビゲーション

import { router, useRouter } from 'expo-router';
import { Button, View } from 'react-native';

export default function NavigationExample() {
  const router = useRouter();

  return (
    <View>
      {/* 基本的な遷移 */}
      <Button 
        title="Go to About" 
        onPress={() => router.push('/about')} 
      />

      {/* パラメータ付き遷移 */}
      <Button 
        title="Go to Post #42" 
        onPress={() => router.push('/posts/42')} 
      />

      {/* オブジェクト形式でのパラメータ渡し */}
      <Button 
        title="Go to User Profile" 
        onPress={() => router.push({
          pathname: '/user/[id]',
          params: { id: '123', from: 'home' }
        })} 
      />

      {/* スタックをリセット(戻れなくする) */}
      <Button 
        title="Login" 
        onPress={() => router.replace('/login')} 
      />

      {/* 戻る */}
      <Button 
        title="Go Back" 
        onPress={() => router.back()} 
      />

      {/* 履歴の確認 */}
      <Button 
        title="Can Go Back?" 
        onPress={() => console.log(router.canGoBack())} 
      />
    </View>
  );
}

Linkコンポーネント

import { Link } from 'expo-router';
import { Text } from 'react-native';

export default function LinkExample() {
  return (
    <>
      {/* 基本的なリンク */}
      <Link href="/about">
        <Text>About Page</Text>
      </Link>

      {/* パラメータ付き */}
      <Link href="/posts/42">
        <Text>Post #42</Text>
      </Link>

      {/* オブジェクト形式 */}
      <Link 
        href={{
          pathname: '/user/[id]',
          params: { id: '123' }
        }}
      >
        <Text>User Profile</Text>
      </Link>

      {/* replaceモード(履歴に残さない) */}
      <Link href="/login" replace>
        <Text>Login</Text>
      </Link>

      {/* 外部リンク */}
      <Link href="https://expo.dev" target="_blank">
        <Text>Expo Website</Text>
      </Link>
    </>
  );
}

動的ルートとパラメータ

動的セグメント

app/posts/[id].tsx:

import { useLocalSearchParams } from 'expo-router';
import { View, Text, StyleSheet } from 'react-native';

export default function PostDetail() {
  const { id } = useLocalSearchParams<{ id: string }>();

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Post #{id}</Text>
      <Text>Content for post {id}</Text>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    padding: 20,
  },
  title: {
    fontSize: 24,
    fontWeight: 'bold',
    marginBottom: 10,
  },
});

キャッチオールルート

app/blog/[...slug].tsx:

import { useLocalSearchParams } from 'expo-router';
import { View, Text } from 'react-native';

export default function BlogPost() {
  const { slug } = useLocalSearchParams<{ slug: string[] }>();
  
  // /blog/2025/02/my-post → slug = ['2025', '02', 'my-post']
  const [year, month, postSlug] = slug;

  return (
    <View>
      <Text>Year: {year}</Text>
      <Text>Month: {month}</Text>
      <Text>Post: {postSlug}</Text>
    </View>
  );
}

クエリパラメータ

import { useLocalSearchParams } from 'expo-router';

export default function SearchResults() {
  const { q, category, sort } = useLocalSearchParams<{
    q: string;
    category?: string;
    sort?: string;
  }>();

  // /search?q=expo&category=tech&sort=recent
  // q = 'expo'
  // category = 'tech'
  // sort = 'recent'

  return (
    <View>
      <Text>Search: {q}</Text>
      <Text>Category: {category || 'All'}</Text>
      <Text>Sort: {sort || 'relevance'}</Text>
    </View>
  );
}

認証フロー

認証状態の管理

contexts/AuthContext.tsx:

import { createContext, useContext, useState, useEffect } from 'react';
import { router } from 'expo-router';
import * as SecureStore from 'expo-secure-store';

type AuthContextType = {
  user: User | null;
  isLoading: boolean;
  signIn: (email: string, password: string) => Promise<void>;
  signOut: () => Promise<void>;
};

const AuthContext = createContext<AuthContextType | null>(null);

export function AuthProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    loadUser();
  }, []);

  async function loadUser() {
    try {
      const token = await SecureStore.getItemAsync('authToken');
      if (token) {
        // トークンからユーザー情報を取得
        const user = await fetchUserFromToken(token);
        setUser(user);
      }
    } catch (error) {
      console.error('Failed to load user:', error);
    } finally {
      setIsLoading(false);
    }
  }

  async function signIn(email: string, password: string) {
    const { token, user } = await authenticateUser(email, password);
    await SecureStore.setItemAsync('authToken', token);
    setUser(user);
    router.replace('/(tabs)/home');
  }

  async function signOut() {
    await SecureStore.deleteItemAsync('authToken');
    setUser(null);
    router.replace('/login');
  }

  return (
    <AuthContext.Provider value={{ user, isLoading, signIn, signOut }}>
      {children}
    </AuthContext.Provider>
  );
}

export function useAuth() {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error('useAuth must be used within AuthProvider');
  }
  return context;
}

認証ガード

app/_layout.tsx:

import { Slot, useRouter, useSegments } from 'expo-router';
import { useEffect } from 'react';
import { AuthProvider, useAuth } from '../contexts/AuthContext';

function RootLayoutNav() {
  const { user, isLoading } = useAuth();
  const segments = useSegments();
  const router = useRouter();

  useEffect(() => {
    if (isLoading) return;

    const inAuthGroup = segments[0] === '(auth)';

    if (!user && !inAuthGroup) {
      // 未認証なら認証画面へ
      router.replace('/login');
    } else if (user && inAuthGroup) {
      // 認証済みなら保護された画面へ
      router.replace('/(tabs)/home');
    }
  }, [user, segments, isLoading]);

  return <Slot />;
}

export default function RootLayout() {
  return (
    <AuthProvider>
      <RootLayoutNav />
    </AuthProvider>
  );
}

ディープリンクとユニバーサルリンク

スキーム設定

app.json:

{
  "expo": {
    "scheme": "myapp",
    "ios": {
      "bundleIdentifier": "com.mycompany.myapp",
      "associatedDomains": ["applinks:myapp.com"]
    },
    "android": {
      "package": "com.mycompany.myapp",
      "intentFilters": [
        {
          "action": "VIEW",
          "autoVerify": true,
          "data": [
            {
              "scheme": "https",
              "host": "myapp.com"
            }
          ],
          "category": ["BROWSABLE", "DEFAULT"]
        }
      ]
    }
  }
}

ディープリンクの処理

// myapp://posts/42 → /posts/42
// https://myapp.com/posts/42 → /posts/42

import { useEffect } from 'react';
import * as Linking from 'expo-linking';

export default function PostDetail() {
  useEffect(() => {
    const handleDeepLink = (event: { url: string }) => {
      const { hostname, path, queryParams } = Linking.parse(event.url);
      console.log(`Deep link opened: ${path}`);
      console.log('Query params:', queryParams);
    };

    // 初回起動時のURLを取得
    Linking.getInitialURL().then((url) => {
      if (url) handleDeepLink({ url });
    });

    // アプリ起動中のディープリンクをリッスン
    const subscription = Linking.addEventListener('url', handleDeepLink);

    return () => subscription.remove();
  }, []);

  return (
    // ...
  );
}

共有機能

import { Share } from 'react-native';
import * as Linking from 'expo-linking';

async function sharePost(postId: string) {
  const url = Linking.createURL(`/posts/${postId}`);
  
  await Share.share({
    message: 'Check out this post!',
    url: url,
  });
}

データフェッチングとローディング状態

useFocusEffectでのデータ取得

import { useFocusEffect } from 'expo-router';
import { useCallback, useState } from 'react';
import { View, Text, ActivityIndicator } from 'react-native';

export default function PostsList() {
  const [posts, setPosts] = useState([]);
  const [isLoading, setIsLoading] = useState(true);

  useFocusEffect(
    useCallback(() => {
      async function loadPosts() {
        setIsLoading(true);
        try {
          const response = await fetch('https://api.example.com/posts');
          const data = await response.json();
          setPosts(data);
        } catch (error) {
          console.error('Failed to load posts:', error);
        } finally {
          setIsLoading(false);
        }
      }

      loadPosts();
    }, [])
  );

  if (isLoading) {
    return <ActivityIndicator />;
  }

  return (
    <View>
      {posts.map((post) => (
        <Text key={post.id}>{post.title}</Text>
      ))}
    </View>
  );
}

React Queryとの連携

import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query';
import { View, Text } from 'react-native';

const queryClient = new QueryClient();

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <PostsList />
    </QueryClientProvider>
  );
}

function PostsList() {
  const { data, isLoading, error, refetch } = useQuery({
    queryKey: ['posts'],
    queryFn: async () => {
      const response = await fetch('https://api.example.com/posts');
      return response.json();
    },
  });

  if (isLoading) return <Text>Loading...</Text>;
  if (error) return <Text>Error: {error.message}</Text>;

  return (
    <View>
      {data.map((post) => (
        <Text key={post.id}>{post.title}</Text>
      ))}
    </View>
  );
}

モーダルとボトムシート

モーダル画面

app/modals/create-post.tsx:

import { View, Text, TextInput, Button, StyleSheet } from 'react-native';
import { router } from 'expo-router';
import { useState } from 'react';

export default function CreatePostModal() {
  const [title, setTitle] = useState('');
  const [content, setContent] = useState('');

  async function handleSubmit() {
    await createPost({ title, content });
    router.back(); // モーダルを閉じる
  }

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Create New Post</Text>
      <TextInput
        style={styles.input}
        placeholder="Title"
        value={title}
        onChangeText={setTitle}
      />
      <TextInput
        style={[styles.input, styles.textArea]}
        placeholder="Content"
        value={content}
        onChangeText={setContent}
        multiline
      />
      <Button title="Create" onPress={handleSubmit} />
      <Button title="Cancel" onPress={() => router.back()} />
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    padding: 20,
    backgroundColor: '#fff',
  },
  title: {
    fontSize: 24,
    fontWeight: 'bold',
    marginBottom: 20,
  },
  input: {
    borderWidth: 1,
    borderColor: '#ccc',
    borderRadius: 8,
    padding: 12,
    marginBottom: 16,
  },
  textArea: {
    height: 120,
    textAlignVertical: 'top',
  },
});

レイアウトでモーダルを定義:

// app/_layout.tsx
<Stack>
  <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
  <Stack.Screen 
    name="modals/create-post" 
    options={{ 
      presentation: 'modal',
      title: 'New Post'
    }} 
  />
</Stack>

Web対応とSEO

メタタグの設定

import { Stack } from 'expo-router';

export default function BlogPostLayout() {
  return (
    <Stack
      screenOptions={{
        headerShown: false,
      }}
    >
      <Stack.Screen
        name="[id]"
        options={{
          title: 'Blog Post',
          // Webでのメタタグ
          headerTitle: 'My Blog Post',
        }}
      />
    </Stack>
  );
}

ヘッドコンポーネント

import { Stack } from 'expo-router';

export default function PostDetail() {
  const { id } = useLocalSearchParams();
  const post = usePost(id);

  return (
    <>
      <Stack.Screen
        options={{
          title: post.title,
        }}
      />
      <View>
        <Text>{post.content}</Text>
      </View>
    </>
  );
}

まとめ

Expo Routerは、React Nativeアプリ開発に以下のメリットをもたらします:

  1. 開発速度の向上: ファイルベースルーティングによる直感的な構造
  2. 型安全性: TypeScriptとの完全な統合
  3. ディープリンク対応: URLベースのナビゲーション
  4. Web互換性: 同じコードでWebアプリも構築可能
  5. メンテナンス性: 明確なディレクトリ構造とレイアウトの再利用

Next.jsの経験があれば、学習コストは最小限です。モバイルアプリ開発の新しいスタンダードとして、Expo Routerの採用を検討してみてください。

参考リンク