はじめに
「iOSアプリ作りたいけどSwift覚えるの面倒だな」「Androidも対応するならKotlinも必要?」
モバイルアプリ開発を考えたとき、こういう壁にぶつかった経験ありませんか。
正直なところ、僕もWebエンジニアとしてReactを書いてきた身なので、ネイティブ開発の学習コストにはずっと二の足を踏んでいました。Swift触ってみたけど、なんか違う。Kotlin試してみたけど、やっぱりJavaScriptの方がしっくりくる。
そんなとき出会ったのがReact Native。GitHubで125,000スター、月間1,800万ダウンロード。Meta、Microsoft、Amazon、Shopify、Discord、Teslaなど、名だたる企業が採用しているフレームワークです。
10年以上の歴史があり、2,790人以上のコントリビューターがいる。これだけの実績があるなら、試す価値はあるだろうと思いました。
React Nativeとは
React Nativeは、Meta(旧Facebook)が開発したクロスプラットフォームのモバイルアプリ開発フレームワークです。
「Learn once, write anywhere」(一度学べば、どこでも書ける)というコンセプトを掲げていて、Reactの知識を使ってiOSとAndroidの両方のネイティブアプリを開発できます。
最新バージョンは0.82.1。MITライセンスで自由に使えます。
個人的に重要だと思うのは、「ネイティブアプリ」という点。WebViewでラップしただけのハイブリッドアプリとは違って、実際のネイティブUIコンポーネントを使ってレンダリングする。だからパフォーマンスも見た目も、ネイティブアプリと遜色ない。
特徴・メリット
1. Reactの知識がそのまま使える
これ、意外と大きいんですよ。
コンポーネント設計、hooks、状態管理、TypeScript対応。Webで培ったReactの知識がほぼそのまま使えます。新しいパラダイムを学ぶ必要がない。
// Reactとほぼ同じ書き方
function WelcomeScreen() {
const [count, setCount] = useState(0);
return (
<View>
<Text>Count: {count}</Text>
<Button title="増やす" onPress={() => setCount(c => c + 1)} />
</View>
);
}
divがView、pがTextになるくらいの違いです。
2. 1つのコードベースでiOS/Android対応
同じJavaScriptコードでiOSとAndroidの両方に対応できる。これ、開発コスト的に相当大きい。
もちろんプラットフォーム固有の処理が必要な場面もありますが、大部分のコードは共通化できます。
3. ホットリロード / Fast Refresh
コードを変更したら、アプリを再起動せずに即座に反映される。Webの開発体験がそのままモバイルでも得られる。
30代になって思うのは、開発中の「待ち時間」ってストレス源なんですよね。ホットリロードがあるだけで、開発体験が全然違います。
4. ネイティブコードへのアクセス
React Nativeで足りない機能があれば、Swift/Objective-CやKotlin/Javaで書いたネイティブモジュールを呼び出せます。
「JSだけで完結しないと困る」わけではない。必要に応じてネイティブの力を借りられる柔軟性がある。
5. 巨大なエコシステム
10年以上の歴史があるので、ライブラリやドキュメントが充実しています。だいたいの「やりたいこと」は先人が解決済み。Stack Overflowで検索すれば答えが見つかる安心感。
インストール方法
前提条件
- Node.js(LTS推奨)
- npm または yarn
- iOS開発:macOS + Xcode
- Android開発:Android Studio
Expoを使う方法(推奨)
公式が推奨しているのはExpoを使う方法。環境構築が圧倒的に楽です。
npx create-expo-app@latest my-app
cd my-app
npx expo start
これだけでプロジェクトが立ち上がります。
React Native CLIを使う方法
より細かい制御が必要な場合は、React Native CLIを直接使います。
npx @react-native-community/cli@latest init MyApp
cd MyApp
# iOSの場合
npx react-native run-ios
# Androidの場合
npx react-native run-android
個人的には、最初はExpoから始めることをおすすめします。ネイティブの設定で躓くと心が折れるので。
基本的な使い方
プロジェクト構成
my-app/
├── App.tsx # エントリーポイント
├── src/
│ ├── screens/ # 画面コンポーネント
│ ├── components/ # 共通コンポーネント
│ ├── hooks/ # カスタムフック
│ ├── utils/ # ユーティリティ
│ └── navigation/ # ナビゲーション設定
├── package.json
└── tsconfig.json
基本コンポーネント
import React from 'react';
import { View, Text, StyleSheet, SafeAreaView } from 'react-native';
export default function HomeScreen() {
return (
<SafeAreaView style={styles.container}>
<View style={styles.content}>
<Text style={styles.title}>React Native</Text>
<Text style={styles.subtitle}>モバイルアプリ開発、始めました</Text>
</View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
},
content: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
title: {
fontSize: 28,
fontWeight: 'bold',
color: '#333',
marginBottom: 8,
},
subtitle: {
fontSize: 16,
color: '#666',
},
});
StyleSheetでスタイルを定義するのがReact Native流。CSSに似てるけど、キャメルケースで書く。
主要なコンポーネント
import {
View, // divの代わり
Text, // テキスト表示
Image, // 画像表示
TextInput, // 入力フィールド
Button, // ボタン
Pressable, // タッチ可能な要素
ScrollView, // スクロール可能なビュー
FlatList, // 長いリストの効率的な表示
} from 'react-native';
リストの表示
import { FlatList, Text, View, StyleSheet } from 'react-native';
type Item = {
id: string;
title: string;
};
const DATA: Item[] = [
{ id: '1', title: 'アイテム 1' },
{ id: '2', title: 'アイテム 2' },
{ id: '3', title: 'アイテム 3' },
];
export default function ListScreen() {
const renderItem = ({ item }: { item: Item }) => (
<View style={styles.item}>
<Text style={styles.title}>{item.title}</Text>
</View>
);
return (
<FlatList
data={DATA}
renderItem={renderItem}
keyExtractor={(item) => item.id}
/>
);
}
const styles = StyleSheet.create({
item: {
backgroundColor: '#f9f9f9',
padding: 20,
marginVertical: 8,
marginHorizontal: 16,
borderRadius: 8,
},
title: {
fontSize: 16,
},
});
FlatListは長いリストを効率的に表示してくれる。画面外のアイテムは自動的にアンマウントされるので、メモリ効率が良い。
ナビゲーション
React Navigationを使うのが一般的です。
npm install @react-navigation/native @react-navigation/native-stack
npm install react-native-screens react-native-safe-area-context
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import HomeScreen from './screens/HomeScreen';
import DetailScreen from './screens/DetailScreen';
const Stack = createNativeStackNavigator();
export default function App() {
return (
<NavigationContainer>
<Stack.Navigator initialRouteName="Home">
<Stack.Screen
name="Home"
component={HomeScreen}
options={{ title: 'ホーム' }}
/>
<Stack.Screen
name="Detail"
component={DetailScreen}
options={{ title: '詳細' }}
/>
</Stack.Navigator>
</NavigationContainer>
);
}
// HomeScreen.tsx
import { View, Button } from 'react-native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
type Props = {
navigation: NativeStackNavigationProp<any>;
};
export default function HomeScreen({ navigation }: Props) {
return (
<View style={{ flex: 1, justifyContent: 'center', padding: 20 }}>
<Button
title="詳細画面へ"
onPress={() => navigation.navigate('Detail', { id: 123 })}
/>
</View>
);
}
実践的なユースケース
フォーム入力とバリデーション
import { useState } from 'react';
import { View, Text, TextInput, Button, StyleSheet, Alert } from 'react-native';
export default function ContactForm() {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [errors, setErrors] = useState<{ name?: string; email?: string }>({});
const validate = () => {
const newErrors: { name?: string; email?: string } = {};
if (!name.trim()) {
newErrors.name = '名前を入力してください';
}
if (!email.trim()) {
newErrors.email = 'メールアドレスを入力してください';
} else if (!/\S+@\S+\.\S+/.test(email)) {
newErrors.email = '有効なメールアドレスを入力してください';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = () => {
if (validate()) {
Alert.alert('送信完了', `${name}さん、ありがとうございます!`);
setName('');
setEmail('');
}
};
return (
<View style={styles.container}>
<Text style={styles.label}>名前</Text>
<TextInput
style={[styles.input, errors.name && styles.inputError]}
value={name}
onChangeText={setName}
placeholder="山田太郎"
/>
{errors.name && <Text style={styles.error}>{errors.name}</Text>}
<Text style={styles.label}>メールアドレス</Text>
<TextInput
style={[styles.input, errors.email && styles.inputError]}
value={email}
onChangeText={setEmail}
placeholder="taro@example.com"
keyboardType="email-address"
autoCapitalize="none"
/>
{errors.email && <Text style={styles.error}>{errors.email}</Text>}
<Button title="送信" onPress={handleSubmit} />
</View>
);
}
const styles = StyleSheet.create({
container: {
padding: 20,
},
label: {
fontSize: 16,
fontWeight: 'bold',
marginBottom: 8,
marginTop: 16,
},
input: {
borderWidth: 1,
borderColor: '#ccc',
borderRadius: 8,
padding: 12,
fontSize: 16,
},
inputError: {
borderColor: '#e74c3c',
},
error: {
color: '#e74c3c',
fontSize: 12,
marginTop: 4,
},
});
API呼び出しとデータ表示
import { useEffect, useState } from 'react';
import { View, Text, FlatList, ActivityIndicator, StyleSheet } from 'react-native';
type Post = {
id: number;
title: string;
body: string;
};
export default function PostsScreen() {
const [posts, setPosts] = useState<Post[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetchPosts();
}, []);
const fetchPosts = async () => {
try {
const response = await fetch('https://jsonplaceholder.typicode.com/posts?_limit=10');
const data = await response.json();
setPosts(data);
} catch (err) {
setError('データの取得に失敗しました');
} finally {
setLoading(false);
}
};
if (loading) {
return (
<View style={styles.center}>
<ActivityIndicator size="large" color="#007AFF" />
</View>
);
}
if (error) {
return (
<View style={styles.center}>
<Text style={styles.error}>{error}</Text>
</View>
);
}
return (
<FlatList
data={posts}
keyExtractor={(item) => item.id.toString()}
renderItem={({ item }) => (
<View style={styles.card}>
<Text style={styles.title}>{item.title}</Text>
<Text style={styles.body} numberOfLines={2}>
{item.body}
</Text>
</View>
)}
contentContainerStyle={styles.list}
/>
);
}
const styles = StyleSheet.create({
center: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
list: {
padding: 16,
},
card: {
backgroundColor: '#fff',
padding: 16,
marginBottom: 12,
borderRadius: 8,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
title: {
fontSize: 16,
fontWeight: 'bold',
marginBottom: 8,
},
body: {
fontSize: 14,
color: '#666',
lineHeight: 20,
},
error: {
color: '#e74c3c',
fontSize: 16,
},
});
ダークモード対応
import { useColorScheme } from 'react-native';
import { View, Text, StyleSheet } from 'react-native';
export default function ThemeScreen() {
const colorScheme = useColorScheme();
const isDark = colorScheme === 'dark';
const dynamicStyles = {
container: {
backgroundColor: isDark ? '#1a1a1a' : '#ffffff',
},
text: {
color: isDark ? '#ffffff' : '#333333',
},
};
return (
<View style={[styles.container, dynamicStyles.container]}>
<Text style={[styles.text, dynamicStyles.text]}>
現在のテーマ: {isDark ? 'ダーク' : 'ライト'}
</Text>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
text: {
fontSize: 18,
},
});
useColorSchemeでシステムのテーマ設定を取得できる。これだけで基本的なダークモード対応ができます。
Expoとの関係
React Nativeを使う場合、Expoを併用するかどうかという選択があります。
Expoを使う場合
- 環境構築が楽(Xcode/Android Studioの設定不要)
- 豊富なSDK(カメラ、プッシュ通知、位置情報など)
- OTAアップデート対応
- EASでクラウドビルド
素のReact Nativeを使う場合
- ネイティブコードを自由にカスタマイズ
- 既存のネイティブアプリへの統合
- より細かい制御が必要な場合
個人的には、特別な理由がなければExpoから始めることをおすすめします。後からejectしてネイティブコードを扱うこともできるので。
まとめ
React Nativeを使ってみて感じたこと:
- 学習コスト低い: Reactを知っていれば、すぐに開発を始められる
- 生産性が高い: 1つのコードベースでiOS/Android対応
- エコシステム充実: 10年の歴史による豊富なライブラリ・ドキュメント
- 実績十分: Meta、Microsoft、Amazonなど大企業が採用
- 柔軟性: 必要に応じてネイティブコードも書ける
正直なところ、「Webエンジニアがモバイルアプリを作る」という目的なら、React Nativeは最有力候補だと思います。SwiftやKotlinを学ぶ時間がなくても、今持っているスキルでネイティブアプリが作れる。
コスパ的に、これ以上の選択肢はなかなかない。
もちろん、複雑なアニメーションやゲームのような処理では、純粋なネイティブ開発に軍配が上がる場面もあります。でも、一般的なビジネスアプリやユーティリティアプリなら、React Nativeで十分。
「モバイルアプリ開発に興味があるけど、何から始めればいいかわからない」という人は、まずReact NativeとExpoの組み合わせで試してみてください。
5分後には実機でアプリが動いていますよ。