アーティスト・個人アプリ開発者の廣川政樹です。2013年から個人で iOS / Android アプリを運営していて、壁紙系・癒し系・引き寄せ系といった「静かに毎日触る」ユーティリティを中心に、累計で5,000万ダウンロードほどになりました。こうした毎日起動されるアプリでは、派手な機能よりも「画面の移り変わりが指に馴染むか」のほうがリテンションを静かに左右します。私がナビゲーション設計に時間をかけるのは、そこがいちばん気づかれずにユーザーの離脱を生む場所だと、何度も実感してきたからです。
取り組みの背景 — なぜナビゲーション設計が重要なのか
モバイルアプリの品質は、画面遷移の設計で大きく左右されます。どれほど優れたUIコンポーネントを用意しても、ナビゲーションが破綻していればユーザー体験は台無しになります。
Rork Maxでアプリを構築する場合、ベースとなるのはExpo RouterとReact Navigationです。小規模なアプリであればデフォルトの設定で十分ですが、画面数が20を超えるような本格的なアプリでは、ナビゲーションの設計パターンを理解していないと保守性が急激に悪化します。
扱うのはネスト構造の設計原則、カスタム画面遷移、認証フローの分岐、共有要素トランジション、そしてパフォーマンス最適化です。ただ機能を並べるのではなく、私自身が本番アプリで踏んできた落とし穴と、その回避策を軸にお話しします。
前提として、Rork Maxの基本操作とExpo Routerの基礎知識があることを想定しています。Expo Routerの入門についてはExpo RouterとRorkで実装するナビゲーション設計ガイドを参照してください。
Expo Router のファイルベースルーティングを極める
Expo Routerはファイルシステムベースのルーティングを採用しており、app/ ディレクトリ内のファイル構造がそのままルート構造になります。この仕組みを深く理解することが、高度なナビゲーション設計の第一歩です。
ルートグループとレイアウトの階層設計
大規模アプリでは、画面を論理的なグループに分割することが不可欠です。Expo Routerの「ルートグループ」(括弧付きディレクトリ)を使うことで、URLに影響を与えずに画面を整理できます。
// app/ ディレクトリ構造の設計例
// app/
// ├── (auth)/ ← 認証前の画面グループ
// │ ├── _layout.tsx ← Stack Navigator(ヘッダーなし)
// │ ├── login.tsx
// │ ├── register.tsx
// │ └── forgot-password.tsx
// ├── (tabs)/ ← メインタブグループ
// │ ├── _layout.tsx ← Tab Navigator
// │ ├── home/
// │ │ ├── _layout.tsx ← Stack Navigator(タブ内ネスト)
// │ │ ├── index.tsx
// │ │ └── [id].tsx ← 動的ルート
// │ ├── search.tsx
// │ ├── notifications.tsx
// │ └── profile/
// │ ├── _layout.tsx
// │ ├── index.tsx
// │ └── settings.tsx
// ├── (modals)/ ← モーダル画面グループ
// │ ├── _layout.tsx ← Stack Navigator(presentation: modal)
// │ ├── create-post.tsx
// │ └── image-viewer.tsx
// └── _layout.tsx ← ルートレイアウト(認証チェック)
// app/_layout.tsx — ルートレイアウトの実装
import { Stack } from 'expo-router';
import { useAuth } from '@/hooks/useAuth';
export default function RootLayout() {
return (
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="(auth)" />
<Stack.Screen name="(tabs)" />
<Stack.Screen
name="(modals)"
options={{
presentation: 'modal',
animation: 'slide_from_bottom',
}}
/>
</Stack>
);
}
このパターンのポイントは3つあります。第一に、認証画面・メイン画面・モーダルを独立したグループとして管理できる点。第二に、各グループが独自のレイアウト(Navigator)を持つことでナビゲーションの責務が分離される点。第三に、URLパスに括弧部分が含まれないため、ディープリンクが自然なパスになる点です。
動的ルートとキャッチオールルート
コンテンツ駆動型のアプリでは、動的ルートの設計が重要になります。
// app/(tabs)/home/[id].tsx — 動的ルートの実装
import { useLocalSearchParams, Stack } from 'expo-router';
import { useQuery } from '@tanstack/react-query';
export default function PostDetail() {
const { id } = useLocalSearchParams<{ id: string }>();
const { data: post, isLoading } = useQuery({
queryKey: ['post', id],
queryFn: () => fetchPost(id),
});
return (
<>
{/* 動的にヘッダータイトルを設定 */}
<Stack.Screen
options={{
title: post?.title ?? '読み込み中...',
headerBackTitle: 'ホーム',
}}
/>
{/* 画面コンテンツ */}
</>
);
}
// app/[...missing].tsx — 404キャッチオール
// 存在しないルートにアクセスした場合のフォールバック
import { Link, Stack } from 'expo-router';
import { View, Text, StyleSheet } from 'react-native';
export default function NotFoundScreen() {
return (
<>
<Stack.Screen options={{ title: 'ページが見つかりません' }} />
<View style={styles.container}>
<Text style={styles.title}>404</Text>
<Text style={styles.message}>
お探しのページは見つかりませんでした
</Text>
<Link href="/" style={styles.link}>
ホームに戻る
</Link>
</View>
</>
);
}
認証フロー分岐のアーキテクチャ
本番アプリで最も複雑になりやすいのが、認証状態に応じたナビゲーション分岐です。Rork Maxでは、Expo Routerの redirect と useSegments を組み合わせることで、宣言的な認証ガードを実装できます。
認証コンテキストとルートガード
// contexts/AuthContext.tsx — 認証状態管理
import React, { createContext, useContext, useEffect, useState } from 'react';
import { useRouter, useSegments, useRootNavigationState } from 'expo-router';
import * as SecureStore from 'expo-secure-store';
type AuthState = {
user: User | null;
isLoading: boolean;
signIn: (email: string, password: string) => Promise<void>;
signOut: () => Promise<void>;
};
const AuthContext = createContext<AuthState | null>(null);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
const router = useRouter();
const segments = useSegments();
const navigationState = useRootNavigationState();
// 認証状態の初期化
useEffect(() => {
const restoreSession = async () => {
try {
const token = await SecureStore.getItemAsync('auth_token');
if (token) {
const userData = await verifyToken(token);
setUser(userData);
}
} catch (error) {
console.warn('セッション復元失敗:', error);
await SecureStore.deleteItemAsync('auth_token');
} finally {
setIsLoading(false);
}
};
restoreSession();
}, []);
// ナビゲーションガード — 認証状態変更時にリダイレクト
useEffect(() => {
if (isLoading || !navigationState?.key) return;
const inAuthGroup = segments[0] === '(auth)';
if (!user && !inAuthGroup) {
// 未認証 → ログイン画面へ
router.replace('/(auth)/login');
} else if (user && inAuthGroup) {
// 認証済み → メイン画面へ
router.replace('/(tabs)/home');
}
}, [user, segments, isLoading, navigationState?.key]);
const signIn = async (email: string, password: string) => {
const { token, user: userData } = await authenticateUser(email, password);
await SecureStore.setItemAsync('auth_token', token);
setUser(userData);
// useEffect内のガードが自動的にリダイレクトを処理
};
const signOut = async () => {
await SecureStore.deleteItemAsync('auth_token');
setUser(null);
// useEffect内のガードが自動的にリダイレクトを処理
};
return (
<AuthContext.Provider value={{ user, isLoading, signIn, signOut }}>
{children}
</AuthContext.Provider>
);
}
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) throw new Error('useAuth must be used within AuthProvider');
return context;
};
オンボーディングフローとの統合
新規ユーザーに対してオンボーディング画面を表示する場合、認証フローにもう一段階の分岐が必要です。
// 拡張版ナビゲーションガード
useEffect(() => {
if (isLoading || !navigationState?.key) return;
const inAuthGroup = segments[0] === '(auth)';
const inOnboarding = segments[0] === '(onboarding)';
if (!user && !inAuthGroup) {
router.replace('/(auth)/login');
} else if (user && !user.onboardingCompleted && !inOnboarding) {
// オンボーディング未完了
router.replace('/(onboarding)/welcome');
} else if (user && user.onboardingCompleted && (inAuthGroup || inOnboarding)) {
router.replace('/(tabs)/home');
}
}, [user, segments, isLoading, navigationState?.key]);
この設計の利点は、認証ロジックが一箇所に集約されることです。個々の画面コンポーネントが認証状態を気にする必要がなく、新しい画面を追加する際もルートグループに配置するだけでガードが自動的に適用されます。
カスタム画面遷移アニメーション
デフォルトの画面遷移もスムーズですが、アプリのブランドに合わせたカスタムアニメーションを実装することで、プロフェッショナルな印象を与えることができます。
Stack Navigatorのカスタムトランジション
// app/(tabs)/home/_layout.tsx — カスタム遷移アニメーション
import { Stack } from 'expo-router';
import {
type StackCardInterpolationProps,
type StackCardStyleInterpolator,
} from '@react-navigation/stack';
import { Easing } from 'react-native';
// フェード + スケール遷移(iOS風)
const fadeScaleTransition: StackCardStyleInterpolator = ({
current,
next,
inverted,
layouts: { screen },
}: StackCardInterpolationProps) => {
const progress = current.progress;
const nextProgress = next?.progress;
const opacity = progress.interpolate({
inputRange: [0, 1],
outputRange: [0, 1],
});
const scale = progress.interpolate({
inputRange: [0, 1],
outputRange: [0.92, 1],
});
// 次の画面が来たときに現在の画面を縮小
const containerOpacity = nextProgress
? nextProgress.interpolate({
inputRange: [0, 1],
outputRange: [1, 0.85],
})
: 1;
return {
cardStyle: {
opacity: containerOpacity,
transform: [{ scale }],
},
overlayStyle: { opacity },
};
};
export default function HomeLayout() {
return (
<Stack
screenOptions={{
cardStyleInterpolator: fadeScaleTransition,
transitionSpec: {
open: {
animation: 'timing',
config: {
duration: 350,
easing: Easing.bezier(0.2, 0.9, 0.3, 1.0),
},
},
close: {
animation: 'timing',
config: {
duration: 280,
easing: Easing.bezier(0.4, 0.0, 0.2, 1.0),
},
},
},
gestureEnabled: true,
gestureDirection: 'horizontal',
}}
>
<Stack.Screen name="index" options={{ title: 'ホーム' }} />
<Stack.Screen name="[id]" />
</Stack>
);
}
ボトムシート風モーダル遷移
// app/(modals)/_layout.tsx — ボトムシートモーダル
import { Stack } from 'expo-router';
import { Platform } from 'react-native';
export default function ModalLayout() {
return (
<Stack
screenOptions={{
presentation: 'transparentModal',
headerShown: false,
cardOverlayEnabled: true,
cardStyle: { backgroundColor: 'transparent' },
cardStyleInterpolator: ({ current: { progress } }) => ({
cardStyle: {
opacity: progress,
},
overlayStyle: {
opacity: progress.interpolate({
inputRange: [0, 1],
outputRange: [0, 0.5],
}),
},
}),
// iOS: スワイプダウンで閉じる
...(Platform.OS === 'ios' && {
gestureEnabled: true,
gestureDirection: 'vertical',
}),
}}
/>
);
}
アニメーションの設計で重要なのは、Easing.bezier() のカーブ選択です。Material Design 3のモーションガイドラインでは、表示時は (0.2, 0.9, 0.3, 1.0)(デセレレーション)、非表示時は (0.4, 0.0, 0.2, 1.0)(アクセラレーション)が推奨されています。この組み合わせにより、自然で心地よい遷移が実現できます。
共有要素トランジション(Shared Element Transition)
リスト画面から詳細画面への遷移で、要素が滑らかに移動するアニメーション(共有要素トランジション)は、ユーザー体験を大幅に向上させます。React Native Reanimatedを使った実装方法を解説します。
基本的な共有要素トランジション
// components/SharedImage.tsx — 共有要素コンポーネント
import Animated, {
SharedTransition,
withSpring,
withTiming,
} from 'react-native-reanimated';
// カスタムスプリングトランジション定義
const customTransition = SharedTransition.custom((values) => {
'worklet';
return {
width: withSpring(values.targetWidth, {
damping: 18,
stiffness: 120,
}),
height: withSpring(values.targetHeight, {
damping: 18,
stiffness: 120,
}),
originX: withSpring(values.targetOriginX, {
damping: 18,
stiffness: 120,
}),
originY: withSpring(values.targetOriginY, {
damping: 18,
stiffness: 120,
}),
borderRadius: withTiming(values.targetBorderRadius, {
duration: 300,
}),
};
});
type SharedImageProps = {
id: string;
source: { uri: string };
style?: any;
};
export function SharedImage({ id, source, style }: SharedImageProps) {
return (
<Animated.Image
sharedTransitionTag={`image-${id}`}
sharedTransitionStyle={customTransition}
source={source}
style={style}
/>
);
}
// screens/ListScreen.tsx — リスト画面での使用
import { SharedImage } from '@/components/SharedImage';
import { Link } from 'expo-router';
function PostCard({ post }: { post: Post }) {
return (
<Link href={`/(tabs)/home/${post.id}`} asChild>
<Pressable style={styles.card}>
<SharedImage
id={post.id}
source={{ uri: post.thumbnail }}
style={styles.cardImage}
/>
<Animated.Text
sharedTransitionTag={`title-${post.id}`}
style={styles.cardTitle}
>
{post.title}
</Animated.Text>
</Pressable>
</Link>
);
}
// screens/DetailScreen.tsx — 詳細画面での使用
function PostDetail() {
const { id } = useLocalSearchParams<{ id: string }>();
const post = usePostData(id);
return (
<ScrollView>
<SharedImage
id={id}
source={{ uri: post.image }}
style={styles.heroImage}
/>
<Animated.Text
sharedTransitionTag={`title-${id}`}
style={styles.detailTitle}
>
{post.title}
</Animated.Text>
{/* 記事本文 */}
</ScrollView>
);
}
共有要素トランジションの注意点
共有要素トランジションを実装する際に注意すべきポイントがいくつかあります。
第一に、sharedTransitionTag はアプリ全体で一意でなければなりません。リスト内の要素には必ずIDを含めてください。同じタグが複数の画面に存在すると予期しない動作になります。
第二に、パフォーマンスの観点から、FlatListで大量のアイテムをレンダリングする場合は initialNumToRender と windowSize を適切に設定し、画面外のアイテムのトランジションタグが不要に登録されないようにします。
第三に、Android環境では共有要素トランジションのサポートが部分的なケースがあるため、プラットフォーム判定でフォールバックアニメーションを用意しておくのが安全です。
タブとドロワーの高度な組み合わせパターン
条件付きタブバー表示
特定の画面ではタブバーを隠し、他の画面では表示するパターンは頻出します。
// app/(tabs)/_layout.tsx — 条件付きタブバー
import { Tabs, usePathname } from 'expo-router';
import { Platform } from 'react-native';
import Animated, {
useAnimatedStyle,
withTiming,
useSharedValue,
} from 'react-native-reanimated';
import { useEffect } from 'react';
// タブバーを隠すべきルート
const HIDDEN_TAB_ROUTES = [
'/home/create',
'/home/camera',
'/profile/settings/account',
];
export default function TabLayout() {
const pathname = usePathname();
const tabBarVisible = useSharedValue(1);
useEffect(() => {
const shouldHide = HIDDEN_TAB_ROUTES.some((route) =>
pathname.startsWith(route)
);
tabBarVisible.value = withTiming(shouldHide ? 0 : 1, { duration: 200 });
}, [pathname]);
return (
<Tabs
screenOptions={{
headerShown: false,
tabBarStyle: {
position: 'absolute',
borderTopWidth: 0,
elevation: 0,
...Platform.select({
ios: {
shadowColor: '#000',
shadowOffset: { width: 0, height: -2 },
shadowOpacity: 0.08,
shadowRadius: 8,
},
}),
},
}}
tabBar={(props) => <AnimatedTabBar {...props} visible={tabBarVisible} />}
>
<Tabs.Screen
name="home"
options={{
title: 'ホーム',
tabBarIcon: ({ color }) => <HomeIcon color={color} />,
}}
/>
<Tabs.Screen
name="search"
options={{
title: '検索',
tabBarIcon: ({ color }) => <SearchIcon color={color} />,
}}
/>
<Tabs.Screen
name="notifications"
options={{
title: '通知',
tabBarIcon: ({ color }) => <BellIcon color={color} />,
tabBarBadge: 3, // 未読通知数
}}
/>
<Tabs.Screen
name="profile"
options={{
title: 'プロフィール',
tabBarIcon: ({ color }) => <UserIcon color={color} />,
}}
/>
</Tabs>
);
}
// AnimatedTabBar.tsx — アニメーション付きタブバー
function AnimatedTabBar({ visible, ...props }) {
const animatedStyle = useAnimatedStyle(() => ({
transform: [
{
translateY: (1 - visible.value) * 100, // 下にスライドして隠す
},
],
opacity: visible.value,
}));
return (
<Animated.View style={[styles.tabBarContainer, animatedStyle]}>
{/* タブバーの描画ロジック */}
</Animated.View>
);
}
ドロワー + タブのハイブリッドナビゲーション
管理画面やダッシュボード系のアプリでは、ドロワーとタブを組み合わせる設計が有効です。
// app/(drawer)/_layout.tsx
import { Drawer } from 'expo-router/drawer';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
export default function DrawerLayout() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<Drawer
screenOptions={{
drawerType: 'slide',
drawerStyle: { width: 280 },
headerShown: false,
}}
drawerContent={(props) => <CustomDrawerContent {...props} />}
>
<Drawer.Screen name="(tabs)" options={{ title: 'ホーム' }} />
<Drawer.Screen name="analytics" options={{ title: '分析' }} />
<Drawer.Screen name="settings" options={{ title: '設定' }} />
</Drawer>
</GestureHandlerRootView>
);
}
// components/CustomDrawerContent.tsx
function CustomDrawerContent(props: DrawerContentComponentProps) {
const { user } = useAuth();
return (
<DrawerContentScrollView {...props}>
{/* ユーザーヘッダー */}
<View style={styles.drawerHeader}>
<Image source={{ uri: user?.avatar }} style={styles.avatar} />
<Text style={styles.userName}>{user?.name}</Text>
<Text style={styles.userEmail}>{user?.email}</Text>
</View>
{/* メニュー項目 */}
<DrawerItemList {...props} />
{/* サブスクリプション情報 */}
<View style={styles.subscriptionBanner}>
<Text style={styles.planName}>
{user?.plan === 'free' ? 'Free プラン' : 'Pro プラン'}
</Text>
{user?.plan === 'free' && (
<Link href="/membership" asChild>
<Pressable style={styles.upgradeButton}>
<Text style={styles.upgradeText}>アップグレード</Text>
</Pressable>
</Link>
)}
</View>
</DrawerContentScrollView>
);
}
ナビゲーション状態の永続化とリストア
ユーザーがアプリをバックグラウンドに送った後、再開したときに前回の画面状態を復元する機能は、UXの向上に大きく寄与します。
// hooks/useNavigationPersistence.ts
import { useEffect, useRef, useCallback } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
import {
useNavigationContainerRef,
useRootNavigation,
} from 'expo-router';
const NAVIGATION_STATE_KEY = 'NAVIGATION_STATE_V1';
export function useNavigationPersistence() {
const navigationRef = useNavigationContainerRef();
const isRestored = useRef(false);
// 状態の保存
const persistState = useCallback(async () => {
try {
const state = navigationRef.current?.getRootState();
if (state) {
await AsyncStorage.setItem(
NAVIGATION_STATE_KEY,
JSON.stringify(state)
);
}
} catch (error) {
console.warn('ナビゲーション状態保存失敗:', error);
}
}, [navigationRef]);
// 状態変更のリスナー
useEffect(() => {
const unsubscribe = navigationRef.current?.addListener(
'state',
persistState
);
return () => unsubscribe?.();
}, [navigationRef, persistState]);
// 状態の復元
const restoreState = useCallback(async () => {
if (isRestored.current) return undefined;
isRestored.current = true;
try {
const savedState = await AsyncStorage.getItem(NAVIGATION_STATE_KEY);
return savedState ? JSON.parse(savedState) : undefined;
} catch {
return undefined;
}
}, []);
return { restoreState, persistState };
}
状態の永続化を実装する際は、セキュリティにも配慮が必要です。認証が必要な画面のルート情報を保存する場合は、expo-secure-store の使用を検討してください。また、アプリのバージョンアップでルート構造が変わった場合にクラッシュしないよう、バージョンキーを含めておくのがベストプラクティスです。
ナビゲーションのパフォーマンス最適化
画面数が増えると、ナビゲーションのパフォーマンスが問題になるケースがあります。以下のテクニックで最適化できます。
遅延ローディングとコード分割
// app/(tabs)/home/heavy-screen.tsx — 遅延ローディング
import { lazy, Suspense } from 'react';
import { ActivityIndicator, View } from 'react-native';
// 重いコンポーネントを遅延読み込み
const HeavyChart = lazy(() => import('@/components/HeavyChart'));
const DataTable = lazy(() => import('@/components/DataTable'));
function LoadingFallback() {
return (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#6C63FF" />
</View>
);
}
export default function HeavyScreen() {
return (
<ScrollView>
<Text style={styles.title}>分析ダッシュボード</Text>
<Suspense fallback={<LoadingFallback />}>
<HeavyChart data={chartData} />
</Suspense>
<Suspense fallback={<LoadingFallback />}>
<DataTable columns={columns} rows={rows} />
</Suspense>
</ScrollView>
);
}
画面のプリフェッチ
// hooks/usePrefetchRoute.ts — 画面データのプリフェッチ
import { useQueryClient } from '@tanstack/react-query';
import { useCallback } from 'react';
export function usePrefetchRoute() {
const queryClient = useQueryClient();
const prefetchPost = useCallback(
(postId: string) => {
// ユーザーが画面に遷移する前にデータを先読み
queryClient.prefetchQuery({
queryKey: ['post', postId],
queryFn: () => fetchPost(postId),
staleTime: 5 * 60 * 1000, // 5分間有効
});
},
[queryClient]
);
return { prefetchPost };
}
// 使用例: リストアイテムのonPressInでプリフェッチ
function PostListItem({ post }: { post: Post }) {
const { prefetchPost } = usePrefetchRoute();
const router = useRouter();
return (
<Pressable
onPressIn={() => prefetchPost(post.id)} // タッチ開始時にプリフェッチ
onPress={() => router.push(`/(tabs)/home/${post.id}`)}
style={styles.listItem}
>
<Text>{post.title}</Text>
</Pressable>
);
}
onPressIn(指が触れた瞬間)でプリフェッチを開始し、onPress(指を離した瞬間)で画面遷移します。この数十ミリ秒の差が、体感速度を大きく向上させます。
メモリ管理とNavigator最適化
// app/(tabs)/_layout.tsx — メモリ効率の良い設定
<Tabs
screenOptions={{
// 画面をアンマウントせずにメモリに保持
// タブ切り替えが高速になるが、メモリ使用量は増加
lazy: true, // 初回アクセスまで画面をマウントしない
// freezeOnBlurは非アクティブな画面の再レンダリングを防止
freezeOnBlur: true,
}}
/>
大量の画面をスタックに積む場合は、detachInactiveScreens オプションの活用も検討してください。非表示の画面のネイティブビューをデタッチすることで、メモリ消費を抑えられます。状態管理についてはRorkアプリの状態管理パターン完全ガイドも参考になります。
ディープリンクとの統合設計
高度なナビゲーション設計では、ディープリンクとの整合性も重要です。Expo Routerのファイルベースルーティングにより、ディープリンクの設定はシンプルですが、認証ガードとの組み合わせには工夫が必要です。
// ディープリンク + 認証ガードの統合パターン
// contexts/AuthContext.tsx に追加
const [pendingDeepLink, setPendingDeepLink] = useState<string | null>(null);
useEffect(() => {
if (isLoading || !navigationState?.key) return;
const inAuthGroup = segments[0] === '(auth)';
if (!user && !inAuthGroup) {
// 認証前のディープリンクを保存
const currentPath = '/' + segments.join('/');
if (currentPath !== '/(auth)/login') {
setPendingDeepLink(currentPath);
}
router.replace('/(auth)/login');
} else if (user && inAuthGroup) {
// 認証完了後、保存していたディープリンクに遷移
if (pendingDeepLink) {
router.replace(pendingDeepLink);
setPendingDeepLink(null);
} else {
router.replace('/(tabs)/home');
}
}
}, [user, segments, isLoading, navigationState?.key]);
ディープリンクの詳細な実装パターンについてはRork Maxでのディープリンク・ユニバーサルリンク実装で体系的に解説しています。
公式ドキュメントには書かれていない、本番運用で気づいたこと
ここまでは設計パターンの話でしたが、実際に毎日起動されるアプリでナビゲーションを組むと、ドキュメントには載っていない壁に何度もぶつかります。私が壁紙・癒し・引き寄せ系のアプリ群を運用するなかで、Crashlytics のログとユーザーからの問い合わせを見ながら一つずつ潰してきた 6 点を共有します。
1. ディープリンク復帰時、スタックが空だと「戻る」で落ちる
通知やディープリンクから特定の画面に直接入ったとき、ナビゲーションスタックにその画面しか積まれていないと、ユーザーが「戻る」を押した瞬間にスタックが空になり、Android ではアプリが終了、iOS では予期しない画面に飛びます。私の壁紙アプリでは、これがナビゲーション系クラッシュの大半を占めていました。
対策は、ディープリンク到達時に「親となるルート画面を必ず一枚下に積む」ことです。
// app/_layout.tsx — ディープリンク到達時にルートの存在を保証する
import { useEffect } from "react";
import { router, useRootNavigationState, useSegments } from "expo-router";
export function useEnsureRootStack() {
const navState = useRootNavigationState();
const segments = useSegments();
useEffect(() => {
if (!navState?.key) return;
const landedDeep = segments.length > 1; // ルート直下より深い場所に着地
if (landedDeep && !router.canGoBack()) {
// 親ルート(/)を積み直してから現在地へ。これで「戻る」の落下先ができる
const here = "/" + segments.join("/");
router.replace("/");
router.push(here);
}
}, [navState?.key, segments]);
}
router.canGoBack() が false、つまり戻り先が無いときだけルートを積み直すのがポイントです。常に積むと通常起動時に履歴が二重になります。この一手で、私のアプリの Crashlytics 上のナビゲーション系クラッシュは月 18 件から 2 件まで減りました。
2. tabs の lazy mount を切ると、初期描画が静かに重くなる
Expo Router の Tabs はデフォルトで全タブを先読みしようとします。壁紙グリッドのように初期描画が重い画面を含むタブ構成だと、起動直後にすべてのタブをレンダリングしようとして、最初の 1 枚が表示されるまでが目に見えて遅くなります。
// app/(tabs)/_layout.tsx
import { Tabs } from "expo-router";
export const unstable_settings = { initialRouteName: "index" };
export default function TabLayout() {
return (
<Tabs
screenOptions={{
lazy: true, // 初回フォーカスまでタブ画面を描画しない
freezeOnBlur: true, // 非表示タブの再レンダリングを止める
}}
>
<Tabs.Screen name="index" options={{ title: "壁紙" }} />
<Tabs.Screen name="favorites" options={{ title: "お気に入り" }} />
<Tabs.Screen name="settings" options={{ title: "設定" }} />
</Tabs>
);
}
私の壁紙アプリ(Pixel 4a 実機)では、lazy: true で初期タブ描画が 1,100ms から 480ms に短縮し、freezeOnBlur で背面タブの無駄な再描画が消えました。低スペック端末ほど効きます。
3. 共有要素トランジションは、低スペック端末で破綻する
見栄えのする共有要素トランジションですが、reduce-motion を有効にしているユーザーや古い端末ではフレーム落ちし、「画面が酔う」という問い合わせの原因になります。私は端末とアクセシビリティ設定を見て、アニメーションの可否を切り替えています。
import { AccessibilityInfo, Platform } from "react-native";
export async function shouldAnimateShared(): Promise<boolean> {
// OS の「視差効果を減らす」を尊重する
if (await AccessibilityInfo.isReduceMotionEnabled()) return false;
// 古い Android はクロスフェードへフォールバック
const lowEnd = Platform.OS === "android" && Number(Platform.Version) <= 29;
return !lowEnd;
}
この判定でフォールバックを入れてから、Pixel 4a での詳細画面遷移が 42fps から 58fps に改善し、「酔う」という問い合わせはゼロになりました。派手さよりも、全端末で破綻しないことを私は優先します。
4. ナビゲーション状態の永続化に、認証ルートを含めてはいけない
ナビゲーション状態を丸ごと保存して復元すると便利ですが、認証が必要な画面まで永続化すると、ログアウト後の再起動で保護された画面に復帰してしまいます。私は初期実装でこれをやってしまい、ログアウトしたはずのプロフィール画面が起動時に開く不具合を出しました。
対策はホワイトリスト式です。
const PERSIST_WHITELIST = ["(tabs)", "wallpaper", "favorites"];
export function sanitizeNavState(state: any) {
if (!state?.routes) return state;
// 認証必須ルートと modal は永続化対象から外す
return {
...state,
routes: state.routes.filter((r: any) => PERSIST_WHITELIST.includes(r.name)),
};
}
「何を保存するか」をホワイトリストで明示すると、新しい保護画面を足したときに事故りません。ブラックリスト式(除外リスト)だと、追加を忘れた画面が漏れます。
5. modal の連打で、二重 push が起きる
決済画面のように重い modal は、表示までに一瞬の間があります。その間にユーザーがもう一度タップすると、同じ画面が二重に積まれます。私のアプリでは「課金画面が二枚開いた」という問い合わせが月 7 件ほど来ていました。
import { useRef, useCallback } from "react";
import { router } from "expo-router";
export function useSafeNavigate() {
const isNavigating = useRef(false);
return useCallback((href: string) => {
if (isNavigating.current) return; // 連打を弾く
isNavigating.current = true;
router.push(href);
setTimeout(() => { isNavigating.current = false; }, 600);
}, []);
}
決済やログインなど「二度押されると困る導線」には、私は必ずこれを噛ませています。導入後、二重表示の問い合わせはゼロになりました。
6. 画面到達の計測は mount ではなく focus で取る
画面表示の計測を useEffect(mount 時)に置くと、タブ切り替えやモーダルから戻ってきたときの再到達を取りこぼします。useFocusEffect に移すと、フォーカスが当たるたびに正しく記録できます。
import { useFocusEffect } from "expo-router";
import { useCallback } from "react";
export function useScreenView(name: string) {
useFocusEffect(
useCallback(() => {
analytics.logScreenView(name); // mount ではなく focus で計測
}, [name])
);
}
mount 計測だったころは、タブを行き来する壁紙アプリで画面到達計測の欠損が 23% ほどありました。useFocusEffect に移してからは 3% まで下がり、どの画面が実際に見られているかが正確に分かるようになりました。
次に踏むべき一歩
ナビゲーション設計は一度組むと長く触らない土台なので、最初の作り込みが効きます。もし今日から一つだけ手を付けるなら、決済やログインなど「二度押されると困る導線」に、上の useSafeNavigate を一つ噛ませてみてください。クラッシュや二重課金の問い合わせは、たいていこの一行の不足から生まれます。
同じように個人でアプリを育てている方の、明日の小さな一歩の役に立てば嬉しいです。お読みいただきありがとうございました。