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 でも同じ思考で動けます。
-
関心の分離
- UI / 状態 / API を別レイヤに置き、変更の影響範囲を局所化する
- Flutter:
models//services//providers//pages/+ Riverpod Provider - RN: 同じ分け方が効く
-
イミュータブル前提
- 既存の状態を書き換えず、新しいインスタンスを作って差し替える
- Flutter:
user.copyWith(name: '新名前') - RN:
{...user, name: '新名前'} - メリット: どこで状態が変わったか追いやすく、意図しない書き換えによるバグを防げる
-
単方向データフロー (UDF)
- 画面は state の鏡。ユーザー操作で state を更新すると画面は自動で書き換わる
- 「画面と state が不整合」なバグが起きにくい
- React/Redux 由来、Flutter の宣言的 UI もこのモデル、RN は出自そのもの
-
非同期の3状態
- ローディング中 / エラー / 取得完了の3状態を必ず分岐させる
- Flutter:
AsyncValue.when(loading:, error:, data:)(3引数必須でコンパイル時にチェック) - RN:
useQueryのisLoading/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例で以下の対応関係が同時に確認できます:
-
ListTile→Pressable+ 横並び View の組み立て -
Row相当の横並び →flexDirection: 'row'+alignItems: 'center' - leading / title / trailing の空間分配 →
<View style={{flex: 1}}>で残り領域を埋める -
Padding→paddingHorizontal/paddingVertical -
ClipRRect+Imageの切り抜き →borderRadius+overflow: 'hidden' -
onTap→Pressable.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 側
- Riverpod / flutter_hooks
- go_router
- freezed / json_serializable
- Dio
- webview_flutter / flutter_inappwebview
React Native 側
APIs
公式の逆方向ガイド
- Flutter for React Native developers ── RN → Flutter 方向の公式対応表
Discussion