🔄

Flutter経験者のためのReact Nativeキャッチアップ ── 同じアプリを両方で作って見えてきた対応関係

に公開

はじめに

先日、Hacobu が物流アプリを Flutter + WebView 構成から React Native へリプレイスしている記事 を読みました。Flutter のネイティブ画面と WebView が並ぶ構成を剥がして完全ネイティブ化したい、というケースです。

Flutter を5年書いてきた自分も気になったので、同じアプリを両方で作って比較してみました。実感としては、モデル層や状態管理の設計はそのまま持ち込める一方、UI層の書き味は結構違います。それでも Widget ツリーを組み立てる感覚自体は JSX(React のマークアップ構文)でも効くので、ゼロからの学び直しではなく「書き方とライブラリを置き換える」感覚でキャッチアップできました。

キャッチアップに役立ちそうな情報をこの記事にまとめました。公式の Flutter for React Native devs は RN → Flutter 方向のガイドなので、この記事は Flutter → RN 側として読んでいただければと思います。

この記事で分かること

  • 何が流用できて、何を書き換えるか
  • Flutter 概念の React Native 対応マップ
  • Flutter の感覚で書いて引っかかるポイント

前提

  • 同じアプリ(WorldBoard: 国別情報ダッシュボード)を Flutter版 と React Native版 の両方で実装して比較しました
  • 使用ライブラリは 2026 年時点の主流で揃えています: Riverpod 3.x / Expo SDK 54 / Expo Router 6 / Zustand 5 / TanStack Query v5 / Zod v4 / React Native Paper 5
  • ソースコード全文: frank-n-01/Learning/worldboard

対象読者

  • Flutter で実際にアプリを作ったことがある人で、React Native にも触ってみようか迷っている人
  • Flutter アプリの WebView 依存による UX 劣化が気になっている人
  • 「Flutter vs RN のどちらが優れているか」ではなく、両方を使えるエンジニアになりたい

まず動画で体感 ── WebView からネイティブへ

本題に入る前に、「確かに React Native に置き換えると UX は良くなるよね」というのを動画で。Flutter 版は詳細画面だけを WebView にしたハイブリッド構成、React Native 版は完全ネイティブ構成です。タップ反応・スクロール・戻る操作を同じ順序で、iOS → Android の両方で撮影しています。

  • 0:00 iOS ─ Flutter WebView 版
  • 0:28 iOS ─ React Native 版
  • 0:56 Android ─ Flutter WebView 版
  • 1:58 Android ─ React Native 版

Flutter のネイティブ描画と WebView の描画は別系統なので、遷移時の空白・スクロールの違和感・戻る後の再レイアウトが発生します。ネイティブ化でこれらが消えます。


結論: 設計思想はそのまま持ち込める

そもそも Flutter の宣言的 UI 自体が React の影響を受けて作られた ものなので、設計原則の大部分は両者で共通しています。Flutter でこの書き方が身についていれば、RN でも同じ思考で動けます。

  1. 関心の分離

    • UI / 状態 / API を別レイヤに置き、変更の影響範囲を局所化する
    • Flutter: models/ / services/ / providers/ / pages/ + Riverpod Provider
    • RN: 同じ分け方が効く
  2. イミュータブル前提

    • 既存の状態を書き換えず、新しいインスタンスを作って差し替える
    • Flutter: user.copyWith(name: '新名前')
    • RN: {...user, name: '新名前'}
    • メリット: どこで状態が変わったか追いやすく、意図しない書き換えによるバグを防げる
  3. 単方向データフロー (UDF)

    • 画面は state の鏡。ユーザー操作で state を更新すると画面は自動で書き換わる
    • 「画面と state が不整合」なバグが起きにくい
    • React/Redux 由来、Flutter の宣言的 UI もこのモデル、RN は出自そのもの
  4. 非同期の3状態

    • ローディング中 / エラー / 取得完了の3状態を必ず分岐させる
    • Flutter: AsyncValue.when(loading:, error:, data:)(3引数必須でコンパイル時にチェック)
    • RN: useQueryisLoading / error / data を早期リターンで分岐
    • メリット: 「ローディング中に空画面」「エラー時に永久ローディング」の書き忘れバグを防ぐ

必要なのは書き方とライブラリの置き換えだけです。以下、実際に両方で書いてみて「ここはこう置き換える」と見えてきたものをカテゴリごとに並べます。

概念マッピング

以下のセクションでは JSX のコード例が頻出するので、前提を短く押さえておきます。JSX は JavaScript ファイルの中に HTML ライクなタグを書ける構文です:

<View style={styles.box}>           {/* タグ = コンポーネント(= Widget 相当) */}
  <Text>{title}</Text>              {/* `{...}` で JS 式を埋め込む */}
  <Text style={{fontSize: 18}}>…    {/* `{{...}}` は「属性に渡す `{...}`」+「オブジェクトリテラル `{...}`」の入れ子 */}
</View>

スタイルは CSS 文字列ではなく JS オブジェクトを渡す設計です(Flutter の TextStyle(...) を style 属性に渡す感覚に近い)。

レイアウトとスタイル

レイアウトの考え方(flexbox / alignment / scroll 等)は Flutter とほぼ1対1対応です。ただし書き方の感覚が違うポイントが3つあります。ここを押さえれば、残りは命名の違いだけなので下の対応表で確認できます。

1. 文字列は <Text> でラップ必須

RN では生の文字列を <View> の中に書くとエラーになります。Flutter では Text('Hello') 以外の場所でも文字列を直接渡せる(AppBar(title:) 等)ので、移行初日に真っ先に躓くポイントです。

// ❌ エラー
<View>Hello</View>

// ✅ 正しい
<View>
  <Text>Hello</Text>
</View>

画面真っ白系のバグで何度か躓きます。

2. スタイルは StyleSheet.create に集約する文化

Flutter は Widget 引数でスタイルを書くのでツリーに埋まりますが、RN は StyleSheet.create({...}) で事前定義するのが慣習です。

const styles = StyleSheet.create({
  box: { padding: 16 },
  title: { fontSize: 18 },
});
<View style={styles.box}>
  <Text style={styles.title}>Hello</Text>
</View>

インライン style={{...}} でも動きますが、参照の安定性(再レンダー最適化)と可読性のために StyleSheet.create に集約するのが一般的です。

3. flex は style 属性、そして使用頻度が Flutter より高い

書き方自体は Flutter の Expanded(flex: N, child: ...) と同じですが、RN では子自身の style に flex: N を書くのと、そもそも使う頻度が Flutter より多い点が違います。

まず書き方。Row 内で比率配分する例:

// Flutter
Row(
  children: [
    Expanded(flex: 1, child: Text('A')),
    Expanded(flex: 2, child: Text('B')),  // A の2倍の幅
  ],
)
// RN
<View style={{ flexDirection: 'row' }}>
  <Text style={{ flex: 1 }}>A</Text>
  <Text style={{ flex: 2 }}>B</Text>  {/* A の2倍の幅 */}
</View>

次に使用頻度。Flutter は Scaffold や Widget のデフォルトサイズ挙動が画面いっぱいに広げてくれるので、Expanded は「残り領域を埋めたい特定の箇所」だけで使います。一方 RN は明示的に flex 指定しないと画面に広がらないので、ルートから flex: 1 を積み重ねるのが基本パターンです。

// Flutter: Scaffold が画面全体を広げるので Expanded は1箇所だけ
Scaffold(
  body: Column(
    children: [
      AppBar(...),
      Expanded(child: ListView(...)),  // ← Expanded はここだけ
      BottomBar(),
    ],
  ),
)
// RN: ルートから flex: 1 を積み重ねる
<SafeAreaView style={{ flex: 1 }}>    {/* ルートは必ず */}
  <Header />
  <ScrollView style={{ flex: 1 }}>    {/* 残り領域を埋める */}
    ...
  </ScrollView>
  <BottomBar />
</SafeAreaView>

まとめ:

  • flex: 0(デフォルト): コンテンツサイズに合わせる
  • flex: N(1以上): 残りのスペースを比率で取り合う(Flutter の Expanded(flex: N) と同じ)
  • 伸びる方向は親の flexDirection 次第(row なら横、column(デフォルト)なら縦)
  • RN では「ルートから flex を積み重ねる」癖を身につける必要がある

裏付け: WorldBoard の実装での比較

同じ UI を両版で実装した結果、flex 系の使用頻度はこれだけ違いました:

使用箇所
Flutter版 Expanded / Flexible 0 箇所
RN版 flex: 1 7 箇所(ルートコンテナ、ローディング中央寄せ、リスト行テキスト列、詳細画面ScrollView、リンクボタン等)

Flutter は Scaffold + Column のデフォルト挙動だけで画面が組めたのに対し、RN は各画面のルートや主要コンテナで flex を明示する必要がありました。


書き方の対応表

以下は「名前が違うだけ」でほぼ同じ発想のものです。辞書的に引いてください。

Flutter React Native
Column(children: [...]) <View>{...}</View>(flexDirection: column がデフォルト)
Row(children: [...]) <View style={{flexDirection: 'row'}}>
Padding(padding: EdgeInsets.all(16)) <View style={{padding: 16}}>
Container(padding:, color:, decoration:) <View style={{padding, backgroundColor, ...}}>
Expanded(flex: N) <View style={{flex: N}}>
SizedBox(height: 8) <View style={{height: 8}}> または親に gap: 8
Stack + Positioned position: 'absolute'
SingleChildScrollView ScrollView
ListView.builder FlatList
SafeArea SafeAreaView
GestureDetector / InkWell Pressable / TouchableOpacity
MainAxisAlignment.center justifyContent: 'center'
CrossAxisAlignment.center alignItems: 'center'
Theme.of(context).colorScheme.primary useTheme().colors.primary(react-native-paper)
MediaQuery.of(context).size Dimensions.get('window')
Platform.isIOS / isAndroid Platform.OS === 'ios' / 'android'

実装例: 国リストの1行

同じ「国リストの1行」を両版で実装した一部抜粋です。この1コンポーネントで、対応表の7項目が一度に確認できます。

// Flutter: ListTile が layout + padding + alignment を内包
ListTile(
  leading: ClipRRect(
    borderRadius: BorderRadius.circular(4),
    child: Image.network(country.flags.png, width: 48, height: 32),
  ),
  title: Text(
    country.name.common,
    style: const TextStyle(fontWeight: FontWeight.w600),
  ),
  subtitle: Text('$capital · ${country.region}'),
  trailing: const Icon(Icons.chevron_right),
  onTap: onTap,
)
// RN: Pressable + 横並び View で組み立てる
<Pressable onPress={onPress} style={styles.row}>
  <View style={styles.flagContainer}>
    <Image source={{ uri: country.flags.png }} style={styles.flag} />
  </View>
  <View style={styles.textCol}>
    <Text style={styles.title}>{country.name.common}</Text>
    <Text style={styles.desc}>{capital} · {country.region}</Text>
  </View>
  <IconButton icon="chevron-right" disabled />
</Pressable>;

const styles = StyleSheet.create({
  row: {
    flexDirection: 'row',
    alignItems: 'center',
    paddingHorizontal: 16,
    paddingVertical: 10,
  },
  flagContainer: {
    width: 48,
    height: 32,
    borderRadius: 4,
    overflow: 'hidden',
    marginRight: 12,
  },
  flag: { width: '100%', height: '100%' },
  textCol: { flex: 1 },
  title: { fontWeight: '600', fontSize: 16 },
  desc: { fontSize: 13 },
});

この1例で以下の対応関係が同時に確認できます:

  • ListTilePressable + 横並び View の組み立て
  • Row 相当の横並び → flexDirection: 'row' + alignItems: 'center'
  • leading / title / trailing の空間分配 → <View style={{flex: 1}}> で残り領域を埋める
  • PaddingpaddingHorizontal / paddingVertical
  • ClipRRect + Image の切り抜き → borderRadius + overflow: 'hidden'
  • onTapPressable.onPress
  • Icon(Icons.chevron_right)IconButton icon="chevron-right"

Flutter は完成した部品(ListTile)を渡すだけで済むのに対し、RN は素材を組み合わせる感覚が強いです。

状態管理(クライアント)

Flutter (Riverpod 3.x + @riverpod generator) React Native (Zustand)
@riverpod class Foo extends _$Foo create<State>((set) => ({...}))
ref.watch(fooProvider) useStore((s) => s.value)
ref.read(fooProvider.notifier).setX(v) useStore.getState().setX(v)
ref.watch(fooProvider.select(...)) selector で粒度制御
ProviderScope で囲む グローバルストア、Providerなし
// Flutter (@riverpod code generator)
// part 'search_query.g.dart';  // build_runner が生成するファイル

@riverpod
class SearchQuery extends _$SearchQuery {
  @override
  String build() => '';
  void set(String value) => state = value;
}
// `searchQueryProvider` が自動生成される

// UI 側
final q = ref.watch(searchQueryProvider);
ref.read(searchQueryProvider.notifier).set('Japan');
// RN
export const useSearchStore = create<{
  q: string;
  setQ: (v: string) => void;
}>((set) => ({
  q: '',
  setQ: (v) => set({ q: v }),
}));

// UI 側
const q = useSearchStore((s) => s.q);
useSearchStore.getState().setQ('Japan');

最初は ProviderScope 相当を探して戸惑いますが、Zustand はグローバルに1つあるだけuseStore((s) => s.q) の selector がサブスクライブ粒度を決めます(Riverpod の provider.select(...) と同じ考え方)。書いてみるとスコープ管理の負荷が下がる感覚がありました。

状態管理(サーバー)

Flutter (Riverpod + @riverpod generator) React Native (TanStack Query)
@riverpod Future<T> foo(Ref ref) useQuery({queryKey, queryFn})
AsyncValue<T> { data, isLoading, error }
async.when(loading:, error:, data:) 早期リターンで3分岐
ref.invalidate(provider) queryClient.invalidateQueries({queryKey})
キャッシュは Riverpod 本体が面倒を見る TanStack Query が独立してキャッシュ管理
// Flutter (@riverpod)
@riverpod
Future<List<Country>> countries(Ref ref) async {
  return CountryService().fetchAll();
}
// `countriesProvider` が自動生成される

final async = ref.watch(countriesProvider);
return async.when(
  loading: () => const CircularProgressIndicator(),
  error: (e, _) => Text('Error: $e'),
  data: (list) => ListView(children: list.map(CountryTile.new).toList()),
);
// RN
const { data, isLoading, error } = useQuery({
  queryKey: ['countries'],
  queryFn: fetchAllCountries,
});

if (isLoading) return <ActivityIndicator />;
if (error) return <Text>Error: {String(error)}</Text>;
return <FlatList data={data} renderItem={({ item }) => <CountryRow c={item} />} />;

モデル・バリデーション

Flutter (freezed + json_serializable) React Native (Zod)
コード生成で immutable class を作る スキーマから型推論 z.infer
Country.fromJson(map) CountrySchema.parse(raw)
copyWith 自動生成 スプレッド {...country, name: ...}
build_runner watch 必要 ランタイムのみ、ビルドパイプラインに追加なし
// Flutter
@freezed
class Country with _$Country {
  const factory Country({
    required CountryName name,
    required String cca3,
    required List<String> capital,
  }) = _Country;
  factory Country.fromJson(Map<String, dynamic> json) =>
      _$CountryFromJson(json);
}
// RN
export const CountrySchema = z.object({
  name: z.object({ common: z.string(), official: z.string() }),
  cca3: z.string(),
  capital: z.array(z.string()).default([]),
});
export type Country = z.infer<typeof CountrySchema>;

ルーティング

Flutter (go_router + @TypedGoRoute generator) React Native (Expo Router)
@TypedGoRoute<CountryRoute>(path: '/country/:code') app/country/[code].tsx(ファイル名がルート)
class CountryRoute extends GoRouteData のフィールド useLocalSearchParams<{code: string}>()
TypedShellRoute / TypedStatefulShellRoute app/_layout.tsx
CountryRoute(code: 'JPN').push(context) router.push('/country/JPN')
コードに書いた @TypedGoRoute から自動生成 ディレクトリ構造がそのままルートテーブル
// Flutter: lib/router/app_router.dart(ルート定義のみ。画面本体は別ファイルの CountryDetailPage クラス)
@TypedGoRoute<CountryRoute>(path: '/country/:code')
class CountryRoute extends GoRouteData with $CountryRoute {
  const CountryRoute({required this.code});
  final String code;  // 型安全なパスパラメータ

  @override
  Widget build(BuildContext context, GoRouterState state) =>
      CountryDetailPage(code: code);  // pages/country_detail_page.dart の StatelessWidget
}
// 遷移元
CountryRoute(code: 'JPN').push(context);
// RN: app/country/[code].tsx(ルート定義と画面本体が1ファイルにまとまる)
// `export default` された関数を Expo Router が画面本体として認識する
// (Flutter の `StatelessWidget` クラスに相当するのが React の関数コンポーネント。
//  大きな画面は別ファイルに切らず、<WeatherCard> のような小さな子コンポーネントに分解する)
export default function CountryDetailScreen() {
  const { code } = useLocalSearchParams<{ code: string }>();  // URL のパスパラメータ :code を取り出す
  const { data, isLoading } = useQuery({
    queryKey: ['country', code],
    queryFn: () => fetchCountryByCode(code),
  });

  if (isLoading || !data) return <ActivityIndicator />;

  return (
    <ScrollView>
      <Image source={{ uri: data.flags.png }} />
      <Text>{data.name.common}</Text>
      <WeatherCard lat={data.latlng[0]} lon={data.latlng[1]} />
      {/* KEY FACTS / LANGUAGES / GEOGRAPHY 等のセクションが続く */}
    </ScrollView>
  );
}
// 遷移元
router.push(`/country/${code}`);

HTTP クライアント

axios は JavaScript 界で定番の Promise ベースの HTTP クライアントです。Flutter でいう Dio と同じポジション(サードパーティ製の高機能 HTTP ライブラリ)で、baseURL・タイムアウト・インターセプタを一元化できます。RN には fetch が標準搭載されているので軽い用途なら不要ですが、インターセプタやリトライを揃えたくなったら axios が手早いので実務でもよく採用されます。

Flutter (Dio) React Native (axios)
Dio(BaseOptions(baseUrl: ...)) axios.create({baseURL: ...})
dio.get<T>('/path', queryParameters: {...}) client.get<T>('/path', {params: {...}})
Interceptor client.interceptors.request.use(...)
DioException AxiosError

WorldBoard でも REST Countries と Open-Meteo 用にそれぞれ axios.create({ baseURL, timeout }) でクライアントを作っています。API レベルで Dio とほぼ 1:1 対応するので、書き換えはほぼ機械的に進みます。

まとめ

設計思想は持ち込める

  • 単方向データフロー・不変データ・サーバーステートとクライアントステートの分離という考え方は共通
  • ライブラリも役割が 1:1 対応: Riverpod → Zustand + TanStack Query / freezed → Zod / go_router → Expo Router / Dio → axios

UI 層は書き直しが必要

  • StyleSheet・関数コンポーネント・JSX は慣らしが必要
  • ただし Widget ツリーを組む感覚そのものは JSX でも効きます

おまけ: WebView 構成の問題が構造的に消える

  • WebView 起因の jank・タップ遅延・スクロール引っかかりが、ネイティブ化でそのまま解消します(冒頭の動画参照)

「Flutter か RN か」ではなく、「自分の設計経験は両方で活きる」 というのが、同じアプリを両方で作ってみて得た結論です。この記事が、Flutter エンジニアの越境の心理的ハードルを少しでも下げられれば嬉しいです。

参考リンク

本記事のソースコード

Flutter 側

React Native 側

APIs

公式の逆方向ガイド

Discussion