React Navigation (V6)の設計方針に関するTips 著者: tkow

React Nativeでよく利用されているReact Navigationのコンポーネントの導線設計には多少癖があり、設計方針を違えるとページ同士の遷移先を正しくするためによく構造の変更を強いられたり、余計なパラメータをScreenに追加することでページ同士の疎結合性を犠牲にしてしまうような歪な改修をしなければならない時があります。 極力このような場当たり的な対処を減らすためにいくつか注意点をまとめます。

React Navigationの概要

React NavigationはNavigationContainerという単位で独立して定義することができます。NavigationContainerの中にNavigationContainerをネストさせることはできません。このNavigationContainerの中に複数のNavigatorを記述することができます。

Navigatorの種類

React Navigationには大まかに三種類のNavigatorがあります。

  1. Tab Navigator
  2. Stack Navigator
  3. Drawer Navigator

それぞれの種類のNavigatorを用途に応じて組み合わせて利用します。

Tab Navigator

Tab Navigatorは名前の通り、Tab形式のページ遷移を実装できます。タブをクリックした時に指定のタブのコンポーネントに画面遷移することができます。

Stack Navigator

Stack NavigatorはWEBページのようにあるページからあるページへの画面遷移を実現します。また、このStack Navigator配下での画面遷移ではヒストリーを保持することができ、リンク先の例のようにスマホ端末などのバックボタンなどを押した時にも前の画面に戻ることができます。ページのヒストリーを作成したい場合は、必ずStack Navigator上に全てのコンポーネントを登録する必要があることに注意して下さい。

Drawer Navigator

Drawer Navigator サイドバーに画面遷移を行うナビリンクを作ります。ヘッダメニューなどと合わせて利用することもできます。

Navigatorの使い方

Navigatorは以下のように利用したいナビゲーターが定義されているライブラリから、Navigatorの生成関数を呼び出します。この生成関数を呼び出すとNavigator,Screen,Groupというコンポーネントが生成されます。このコンポーネントを役割に応じて配置することでNavigatorを構成することができます。簡単な具体例としては以下のようになります。

navigator.example.tsx
import { createStackNavigator } from '@react-navigation/stack'
import { Home as HomeScreen, Profile as ProfileScreen Login as LoginScreen } from './whatever-components'

const Stack = createStackNavigator();

function ExampleNavigator () {
  return (
    <Stack.Navigator>
      <Stack.Screen name="Home" component={HomeScreen} />
          <Stack.Group
        screenOptions={{
          headerShown: false,
        }}
      >
        <Stack.Screen name="Profile" component={ProfileScreen} />
        <Stack.Screen name="Login" component={LoginScreen} />
          </Stack.Group>
    </Stack.Navigator>
  )
}

Navigator

Navigatorのスコープを定義するコンポーネントです。実装はProviderを含んでおり、useNavigationなどのhooksによってアクセスできるnavigationオブジェクトを介してConsumerに画面遷移を実行するAPIをコンポーネントに提供します。このnavigationオブジェクトは状態を保持しており、Navigatorがネストされている場合、Comsumerから最も近い親コンポーネントのnavigationオブジェクトが利用されることに注意してください。getParent()メソッドによって、親のnavigationオブジェクトを辿ることも可能ですが、双方向bindingになってしまうため、利用しない方がよいでしょう。

Screen

componentというpropsを通してNavigatorにコンポーネントを登録して遷移を可能にします。登録したコンポーネントは、mount時にroute、navigationというオブジェクトがpropsに渡されてきます。これらのpropsを直接利用しなくても問題はないですが、これらのpropsの型をインターフェースで引き受けられるようにするためscreenに利用するコンポーネントは汎用的なコンポーネントを利用するよりも独立に切り出して実装する方が良いでしょう。

ScreenのcomponentプロップにはNavigatorを配置することも出来ます。これによってTabが表示された画面の中でStackNavigatorを構成したり、StackNavigatorからTabNavigatorに遷移するといった実装が可能になります。また、comonentを指定する代わりに以下のようにchildrenをConsumer形式で利用することによって、配置されているNavigatorの位置によって設定を変更することができます。

<Stack.Screen>
  {screenProps => <PlaceScreen {...screenProps} showMyData={true} />}
</Stack.Screen>

この記述方法を用いる場合、公式のドキュメントにも言及されていますが、差し込むscreenがReact.memoなどでpureコンポーネントにしないと、Navigatorのレンダリングのたびに再レンダリングが走ってしまうことに注意してください。

後述しますが、画面遷移元からnavigateメソッドによってparameterを渡してコンポーネントの表示の出し分けなども出来ますが、多用してしまうと画面同士の依存関係を複雑にしてしまい管理しにくくなっていくことから、静的な設定で十分な場合はnavigateのパラメータを利用せずに以上のようなScreenのPropsに設定するのが良いでしょう。

screenに設定したコンポーネント内ではNavigatorから提供されるnavigationオブジェクトのnavigation.navigate('name')メソッドでそのNavigatorに登録されている他のcomponentへと画面遷移することができます。以上の例ではHomeScreenのコンポーネントスコープ内で、

const navigation = useNavigation()
navigation.navigate('Profile')

を実行すると、HomeScreenから、Profile画面に遷移することができます。

Group

Groupはそのchildrenに配置した全てのScreenに対してGroupのpropsで指定した設定を共通化します。この設定はそれぞれのScreenのpropsでオーバーライドすることもできます。

Navigationの制約

navigateメソッドではNestしたナビゲーターが存在している場合、以下のように第二引数のフォーマットを変えることでnavigation先を指定することができます。また、さらにこのparamsにscreenプロパティをネストさせていくことも出来ます。

navigation.navigate('current_navigator_screen', {
   screen: 'screenName',
   params: {...screenParameter}
 }
)

また、navigateメソッドは現在のnavigatorにscreenが存在しない場合、親をたどって第一引数に指定されたscreenに合致しているスクリーンが親に存在しないか探します。この挙動によって今いる階層からナビゲーションツリーのどのスクリーンにも簡潔に画面遷移ができるようになっています。

しかし、ネストしたNavigatorにはいくつかの制約があります。Navigatorはそれぞれ固有にstateを保持しているため、それぞれ直近のNavigatorからNavigatorに遷移すると利用しているNavigatorが変更されるためstateが引き継がれません。これによって、goBackの挙動が想定と違うものになる可能性があります。

例えば、StackNavigatorから別のStackNavigatorに遷移するなどを行った後、さらに元のStackNavigatorに遷移するといったような挙動をおこなった場合、この直後にgoBackを行うと、ナビゲーターを切り替えて前の画面に戻ってくれるわけではなく、元のStackNavigatorのヒストリーを辿って一番最初のページに戻ってしまいます。

この挙動に問題がある場合には、ヒストリーをたどりたいコンポーネントは全て同じStackNavigatorにコンポーネントを差し込む必要があります。そのため、アプリケーションの要求によっては至る所に同じスクリーンを差し込むという作業が必要になります。

Navigationのベストプラクティス集(?)

運用上の感触から、React Navigationの導線設計はTabの動線が起点になることも多いと思います。ここではいくつか試したパターンを紹介します。

StackNavigatorの中にTabNavigatorとその他Screenを全て並列に配置する(A)

おそらくこのパターンが最も問題が起きにくいと思います。TabNavigatorからの遷移も親のStackNavigatorに記録されるので、どこからでもヒストリーが作成できます。問題点は同じTab項目に表示したまま遷移したいスクリーンがある場合は、この配置とBのバターンを併用する必要があります。Tabに設定したい画面が1:1に対応していて、その他のスクリーンでタブ表示を消したい場合は、この配置が一番適しているでしょう。

stack_tab_navigator.example.tsx
import { createStackNavigator } from '@react-navigation/stack'
import { Home as HomeScreen, Profile as ProfileScreen Login as LoginScreen } from './whatever-components'

const Stack = createStackNavigator();

function ExampleStackNavigator () {
  return (
    <Stack.Navigator>
      <Stack.Screen name="Home" component={HomeScreen} />
      <Stack.Screen name="Tab" component={SomeTabNavigator} />
          <Stack.Group
        screenOptions={{
          headerShown: false,
        }}
      >
        <Stack.Screen name="Signin" component={ProfileScreen} />
        <Stack.Screen name="Login" component={LoginScreen} />
          </Stack.Group>
    </Stack.Navigator>
  )
}

TabNavigatorの中にStackNavigatorを配置する(B)

このパターンでは、TabNavigator内で遷移したStackNavigatorの画面ではタブが常に表示されたままになります。

stack_tab_navigator.example.tsx
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'
import { Home as HomeScreen, Profile as ProfileScreen Login as LoginScreen } from './whatever-components'

const Stack = createBottomTabNavigator();

function ExampleTabNavigator () {
  return (
    <Tab.Navigator>
      <Tab.Screen name="Home" component={HomeScreen} />
      <Tab.Screen name="SomeStack" component={SomeStackNavigator} />
          <Tab.Group
        screenOptions={{
          headerShown: false,
        }}
      >
        <Tab.Screen name="Signin" component={ProfileScreen} />
        <Tab.Screen name="Login" component={LoginScreen} />
          </Tab.Group>
    </Tab.Navigator>
  )
}

問題点はStack Navigatorの中で仮にTabを消したいページが存在する場合は、TabNavigatorのnavigationオブジェクトにアクセスしないといけないため、実装が煩雑になりやすいです。一番限定的なスコープで処理する方法としては、StackNavigatorを包含するコンポーネント内でTabNavigatorのnavigationオブジェクトを受け取り、route名に応じてTabを非表示にする方法です。

useNavigationTabHidden.ts
import { getFocusedRouteNameFromRoute, NavigationProp, RouteProp } from '@react-navigation/core'
import { useEffect } from 'react'

export const useNavigationTabBarHidden = (
  matchRoutes: RegExp,
  {
    navigation,
    route,
  }: {
    navigation: NavigationProp<any>
    route: RouteProp<any>
  },
) => {
  useEffect(() => {
    const routeName = getFocusedRouteNameFromRoute(route)
    if (routeName?.match(matchRoutes)) {
      navigation.setOptions({
        tabBarStyle: { display: 'none' },
      })
    } else {
      navigation.setOptions({
        tabBarStyle: { display: 'flex' },
      })
    }
  }, [navigation, route, matchRoutes])
}

以上のようなutilityを作り、以下のようにStackNavigatorのスコープ内でTabNavigatorのnavigationオブジェクトで動的に設定を変更します。

function Home({navigation, route}: { navigation: NavigationProp<any>; route: RouteProp<any> }) {
  useNavigationTabBarHidden(/^(map|direct-message$)/, { navigation, route })

  return (
    <Stack.Navigator initialRouteName="Home">
       ...略
  )
}

また、TabNavigator内のStackNavigatorにコンポーネントを配置していくため、他のタブ内でも同じページに遷移したいというような要件があった場合には、重複するコンポーネントをそれぞれのStackNavigatorに登録しなければなりません。ただし、後述するCパターンのような、別々のStackNavigatorに遷移を繰り返した時におけるヒストリー状態の混乱は起きにくいため、ある程度規模の大きくなったアプリケーションで、タブ内で画面遷移を行う必要があり、かつ一部のページではタブを非表示にしたい場合はこちらの方が管理しやすいでしょう。

AとBの動線を併用するパターン(C)

AとBのパターンで解決できない場合に消極的にこのような動線にする必要があるかもしれません。ただし、このパターンでは、StackNavigatorが複数存在しているため、今まで述べてきたように異なるStackNavigatorから遷移を繰り返した時に優先される履歴の挙動を常に意識する必要がありお勧めできません。

このパターンを用いる場合は、必ず、今いるStackNavigatorから別のStackNavigatorの折り返しを行わないか、履歴を遷移毎に取り消すように注意した方が良いでしょう。多くの場合、このパターンを採用する必要がないはずなので、ヒストリーの制御を簡単にするためにStackNavigatorを一個にまとめられる場合は一個にまとめてしまい、複数に分かれる場合でも、同じコンポーネントを全てのNavigatorに登録してしまい今動作しているスクリーンがどのNavigatorにいるのかを意識しなくて済むように設計した方が良いです。

ただし、例外的にこのパターンが適するケースがあります。

例えば、Anonymousログインなどユーザーによって操作できる権限が違うようなアプリケーションを利用する場合は、一切のNavigator同士のstateやscreenを共有したくない場合は、このパターンが適します。あくまでナビゲーター同士でヒストリーが共有されないことを前提で動線が設計してある場合は用いても大丈夫です。

まとめ

react-navigationには様々なハマリポイントがあり、後から設計の変更がしづらい側面があるため、利用する場合はまず、これらの知識を入れておくだけで利用がしやすくなると思います。React Nativeでは特にデファクトスタンダードであるライブラリでもあるので、設計に困っている皆さんのお役に立てれば光栄です。