React Native ( TypeScript ) × microCMS でつくるニュースアプリ
作ったもの
Gif画像: 完成したニュースアプリ(iOS)
フロントエンドはReact Native(TypeScript)。
記事の入稿とデータ提供のためのバックエンドとしてmicroCMSを使いました。
上記の構成で作るニュースアプリの事例は見当たらなかったため、自分で作ってみました。
設定
package.json
{
"name": "my-first-react-native-app",
"version": "1.0.0",
"main": "node_modules/expo/AppEntry.js",
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web"
},
"dependencies": {
"@react-native-community/masked-view": "^0.1.11",
"@react-navigation/native": "^6.1.6",
"@react-navigation/stack": "^6.3.16",
"axios": "^1.4.0",
"expo": "~48.0.18",
"expo-status-bar": "~1.4.4",
"react": "18.2.0",
"react-native": "0.71.8",
"react-native-elements": "^3.4.3",
"react-native-gesture-handler": "~2.9.0",
"react-native-reanimated": "~2.14.4",
"react-native-safe-area-context": "4.5.0",
"react-native-screens": "~3.20.0"
},
"devDependencies": {
"@babel/core": "^7.20.0",
"@types/react": "~18.0.14",
"react-native-dotenv": "^3.4.9",
"typescript": "^4.9.4"
},
"private": true
}
ExpoでReact Nativeの環境を構築する
Expoとは?
Web開発がメインのエンジニアにとってのReact Native開発の面倒な部分は、JavaScript以外のモバイルアプリ開発の知識も必要なところです。例えば、Android SDKやiOS SDKの機能を利用するためには、GradleやXcodeに関連するツールやビルドに関する知識が必要になります。
そこで、Expoというツールを用いることにしました。
なぜExpo?
ExpoとはReact Nativeベースのモバイルアプリケーション開発フレームワークです。これがReact Nativeの面倒な部分を抽象化して、JavaScriptを用いたUI開発に注力できるようサポートしてくれます。
Expoは、JavaScriptライブラリの追加は簡単ですが、ネイティブで作られたライブラリはExpo内に存在するものしか使用できません。そういった制約がAndroid SDKやiOS SDKを使える開発者には煩わしいかもしれません。
React Native 公式チュートリアルでも採用されてます。
ExpoでReact Nativeの環境構築
Expoのコマンドラインツールをインストール
npm install -g expo-cli
プロジェクトを作成
expo init my-second-react-native-app
すでにグローバルにExpo CLIがインストールされていることを前提としています。
これでもいける
npx create-expo-app my-second-react-native-app
npxはnpmパッケージランナーで、ローカルにExpo CLIがインストールされていなくても直接実行することができます。このコマンドは一時的にExpo CLIをダウンロードして、my-second-react-native-app
という名前の新しいExpoプロジェクトを作成します。
デバッグ
公式Expoアプリでデバッグする
例えば、iPhoneアプリ開発であれば、Xcodeでヒルドして。手元のiPhoneにデータを送って実機確認するが、Expoの場合、公式アプリでデバッグできます。
Expoアプリがブラウザのような役を果たし、開発PCにあるビルドサーバーからダウンロードしデバッグができます。
ビルドサーバーを立ち上げる。
yarn start
- ターミナルからQRコードでる
- iPhoneのカメラアプリでQR認識させてタップ
- 公式Expoアプリ立ち上がる
- ビルド始まる
- 実機確認できた
こんな流れて立ち上がると思います。
ちなみに、実機をシェイクするとデバッグメニューが立ち上がります。
補足
開発環境を作る過程のメモとしてスクラップも残しています。
アプリの階層構造
主なファイルとその階層です。
アプリの階層構造
- ルートディレクトリ (Root Directory)
- App.tsx # アプリのエントリーポイント
- node_modules/
- src/
- components/
- Home/
- index.tsx # ホーム画面 (一覧画面)
- renderItem.tsx # リストアイテム
- Detail/
- index.tsx # 記事 (詳細画面)
- lib/ # ライブラリやAPI関連のファイルを格納
- api.ts # API関連のユーティリティ
- Types/ # アプリで使用される型定義が格納されるファイル
microCMSとの連携
microCMSの詳細な使い方は、調べてればいろいろと出てきますのでここでは最低限に書いてます。
microCMSを用意
リスト形式を選択
APIスキーマ定義
APIキーの取得
microCMSではリクエストにAPIキーを含める事で特定のデータを取得できます。
具体的なやり方は
APIキーをenvファイルで保護しますが、React Nativeの場合どうやるか?
envファイルの読み込み
自分は、.envファイルとreact-native-dotenvパッケージを使用して環境変数を設定しました。
まずはインストール
yarn add -D react-native-dotenv
プロジェクトのルートにあるbabel.config.jsにreact-native-dotenvプラグインを追加する。
module.exports = function (api) {
api.cache(true);
return {
presets: ["babel-preset-expo"],
plugins: [
[
"module:react-native-dotenv",
{
moduleName: "@env", // @envという名前のモジュールを作成し、環境変数をそのモジュールから参照できるようにした
path: ".env", // 環境変数が定義された.envファイルのパスを指定
},
],
],
};
};
これでReact Nativeのコード内で、dotenvを使用して環境変数を読み込むことができます。
例えば
import { MICROCMS_API_KEY, MICROCMS_API_BASE_URL } from "@env";
console.log(MICROCMS_API_KEY, MICROCMS_API_BASE_URL);
React NativeからmicroCMSにAPIリクエストしてデータ取得
Next.jsアプリ開発などで用いるmicrocms-js-sdk
はFetch API等のネットワーク通信に関する処理を隠蔽できて、冗長になりがちな処理をスッキリ簡単に書くことができます。
便利なので使いたいのですが、今回は使えない(と思ってる)。
なぜなら、microcms-js-sdk
はNode.jsやブラウザベースのJavaScriptアプリケーションでの使用を想定している(と思っているから)です。
React NativeはJavaScriptを使用してモバイルアプリケーションを開発するフレームワークであり、その実行環境はNode.jsではなく、モバイルデバイス(AndroidまたはiOS)です。なのでNode.js環境で提供されるAPIを使用することはできない(と思ってます)。
HTTPリクエストはReact Nativeでも利用できます。通常はfetch
関数やaxios
ライブラリなどを使って行います。今回はaxios
を使って、React NativeからmicroCMSにAPIリクエストしてデータを取得する処理を書きます。
microCMSからデータを取得するための関数
import axios, { AxiosInstance } from "axios";
import { MICROCMS_API_KEY, MICROCMS_SERVICE_DOMAIN } from "@env";
// axios.createを使用してAxiosのインスタンスを作成
const client: AxiosInstance = axios.create({
baseURL: `https://${MICROCMS_SERVICE_DOMAIN}.microcms.io/api/v1`,
headers: { "X-API-KEY": MICROCMS_API_KEY },
});
// 記事の一覧を取得
export const fetchPosts = async () => {
try {
const response = await client.get("/news");
return response.data.contents;
} catch (error: unknown) {
console.error(error);
throw error;
}
};
// IDを使って1つの記事を取得
export const fetchPostById = async (id: string) => {
try {
const response = await client.get(`/news/${id}`);
return response.data;
} catch (error: unknown) {
console.error(error);
throw error;
}
};
以下のようなことを行っています。
axios.create
を使用してAxiosのインスタンスを作成しています。作成する際に、基本的なURLとヘッダー情報(APIキー)を設定しています。これらの情報は@envからインポートした環境変数を用いて設定しています。
fetchPosts関数はmicroCMSの/newsエンドポイントからデータを取得します。client.get("/news")で/newsエンドポイントにGETリクエストを送り、そのレスポンスからデータを取り出して返しています。
fetchPostById関数は指定したIDを持つポストを取得します。client.get(/news/${id})で/news/{id}エンドポイントにGETリクエストを送り、そのレスポンスからデータを取り出して返しています。
これらの関数は非同期(async)で定義されており、HTTPリクエストのレスポンスを待つためにPromiseを返します。これにより、呼び出し元で非同期処理を簡単に扱うことができます。また、最低限のエラーハンドリングも行っており、エラーが発生した場合にはコンソールにエラーを出力し、そのエラーを呼び出し元に投げています。
画面遷移の実装
React Navigationで画面遷移を実装しました。
画面遷移に関わる記述や型定義をピックアップすると以下の通りです。
準備
React Navigationのコアライブラリをインストール
yarn add @react-navigation/native
React Navigationの依存ライブラリをインストール
expo install react-native-gesture-handler react-native-reanimated react-native-screens react-native-safe-area-context @react-native-community/masked-view
1つの画面遷移の種類であるstackを提供するReact Navigationのライブラリをインストール
yarn add @react-navigation/stack
Expoを使用してReact Nativeアプリケーションを開発している場合、Expoは独自のビルドおよびパッケージングシステムを提供しています。Expoのビルドシステムと連携してパッケージを適切にインストールおよびリンクするため、依存関係の管理が簡単になります。また、Expoが提供するSDKやライブラリとの互換性も考慮されています。
自分は上記のようにインストールしたが一貫性を保つために全部expo installでもよかったかもしれない。
App.tsx
React Native アプリケーションのエントリーポイントのファイルです。
- NavigationContainerとStackNavigatorのインポートと実装
- React Navigationライブラリを使用して、アプリのナビゲーションスタックを定義します
- RootStackParamList型を用いたcreateStackNavigatorの呼び出し
- ここで定義されたスタックはアプリケーションの全ての画面遷移を制御します
- NavigationContainerコンポーネントで画面遷移したいUIを囲む
コード
import "react-native-gesture-handler";
import { NavigationContainer } from "@react-navigation/native";
import { createStackNavigator } from "@react-navigation/stack";
import { RootStackParamList } from "./src/Types";
const Stack = createStackNavigator<RootStackParamList>();
export default function App() {
return (
<NavigationContainer>
<Stack.Navigator initialRouteName="Home">
<Stack.Screen name="Home" component={Home} />
<Stack.Screen name="Detail" component={Detail} />
</Stack.Navigator>
</NavigationContainer>
);
}
ちなみに1行目は React Native Gesture Handlerと言って、React Nativeでジェスチャーを実装できるライブラリで、React Navigation を使用する際に必須となります。
このライブラリはアプリ全体で一度だけインポートする必要があります。なので今回はApp.tsx といったルートレベルのファイルでインポートしました。これにより、全てのコンポーネントでこのライブラリが適用され、正しいジェスチャーハンドリングとナビゲーション動作が保証されるでしょう。
src/components/Home (一覧画面)
- useNavigationフックを使用してnavigationオブジェクトを取得
- これを通じて、遷移関数にアクセスできます
- HomeScreenNavigationProp型を定義
- これにより、Home画面で利用可能なナビゲーション機能をTypeScriptに明示します。
コード
import { useNavigation } from "@react-navigation/native";
import { StackNavigationProp } from "@react-navigation/stack";
import { RootStackParamList } from "../../Types";
type HomeScreenNavigationProp = StackNavigationProp<RootStackParamList, "Home">;
export const Home = () => {
const navigation = useNavigation<HomeScreenNavigationProp>();
...
return (
<FlatList
data={posts}
renderItem={({ item }) => (
<RenderItem item={item} navigation={navigation} />
)}
keyExtractor={(item) => item.id}
refreshing={refreshing}
onRefresh={onRefresh}
/>
);
};
// src/components/Home/renderItem.tsx
export const RenderItem = ({ item, navigation }: RenderItemProps) => (
<ListItem
key={item.id}
bottomDivider
onPress={() => navigation.navigate("Detail", { id: item.id })}
>
<ListItem.Content>
<View style={styles.itemContainer}>
<View>
<Avatar source={{ uri: item.thumbnail.url }} size="large" />
</View>
<View style={styles.textContainer}>
<ListItem.Title>{item.title}</ListItem.Title>
</View>
</View>
</ListItem.Content>
</ListItem>
);
RenderItemコンポーネントにnavigationオブジェクトをpropsとして渡しています。
ListItemコンポーネントにonPressプロップを通じて、リストアイテムがタップされるとnavigation.navigate("Detail", { id: item.id })が実行され、Detailという名前の画面に遷移します。
遷移する際、item.idをパラメータとして遷移先の画面に渡します。これにより、遷移先の画面で特定のアイテムの詳細情報を表示するためにこのIDを使用することができます。
src/components/Detail (詳細画面)
RoutePropを使ってrouteオブジェクトの型を定義。これにより、ナビゲーションから渡されるパラメータ(route.params)の型が明示されます。
コード
import { RouteProp } from "@react-navigation/native";
import { RootStackParamList } from "../../Types";
type PostScreenRouteProp = RouteProp<RootStackParamList, "Detail">;
interface PostScreenProps {
route: PostScreenRouteProp;
}
export const Detail = ({ route }: PostScreenProps) => {
...
};
src/Types
アプリケーション全体で使用される画面遷移の型を定義。
コード
export type RootStackParamList = {
Home: undefined;
Detail: { id: string };
};
React Navigationでは、各画面に遷移する際にパラメータを指定することが可能です。RootStackParamListは、各画面に遷移する際にどのようなパラメータが必要なのかを定義するためのものです。
Home: undefined
という記述は、Home画面に遷移する際に特別なパラメータを必要としないことを意味しています。
これで画面遷移を確認できると思います。
装飾のためのスタイル
コンポーネント同士の位置関係を調整して配置し、コンポーネント自身を加工できます。コンポーネントのstyle属性に所定のパラメータを持ったオブジェクトを渡すことで、見た目を加工できます。
React Nativeでは、主に2つの方法でスタイルを適用します。
- インラインスタイル
- StyleSheetオブジェクト
インラインスタイル
<Image
source={{ uri: post.thumbnail.url }}
style={{ width: "100%", height: 200 }}
/>
StyleSheetオブジェクト
const styles = StyleSheet.create({
titleWrapper: {
marginBottom: 20,
},
loadingContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
},
});
// 使用例
<View style={styles.loadingContainer}>
ここでは、StyleSheet.createメソッドを使用して、スタイルシートオブジェクトを作成しています。このオブジェクトには複数のスタイルを定義でき、それらのスタイルは全体で再利用可能です。Viewコンポーネントでは、作成したスタイルシートオブジェクトからloadingContainerスタイルを適用しています。
いろいろ書きましたが、CSSが経験がある方であれば、すぐ慣れます。
僕はまだ慣れてません。
レイアウトのためのスタイル
React Nativeのレイアウト機能は、Facebookの「Yoga」エンジンを用い、コンポーネントの座標とサイズを決定します。YogaはFlexboxモデルを基にしたプロパティをサポートし、複数のコンポーネントが混在する画面内で、それぞれの座標とサイズを調整します。
レイアウトが期待通りにならないときは、スタイルで必要な情報を記述し忘れていないか確認するといいかもしれません。
Webとの違いも少なからずあるので、React Native のスタイリングで困ったら、以下の記事を見ようと思います。
上記の記事を見ると、React Native では必ず 1 番親のコンポーネント(今回ならApp.tsx)で flex: 1
が書いてあり、こうすることで、画面の幅と高さ目一杯に要素が引き伸ばされます(下に続く)。
App.tsx
import "react-native-gesture-handler";
import React from "react";
import { NavigationContainer } from "@react-navigation/native";
import { createStackNavigator } from "@react-navigation/stack";
import { Home } from "./src/components/Home";
import { Detail } from "./src/components/Detail";
import { RootStackParamList } from "./src/Types";
const Stack = createStackNavigator<RootStackParamList>();
export default function App() {
return (
<NavigationContainer>
<Stack.Navigator initialRouteName="Home">
<Stack.Screen name="Home" component={Home} />
<Stack.Screen name="Detail" component={Detail} />
</Stack.Navigator>
</NavigationContainer>
);
}
ですが、今回はその設定は書かれていません。
今回は<NavigationContainer>
と<Stack.Navigator>
が自動的に画面全体を埋めるスタイルを持っているので、明示的にflex: 1
を設定していません。
これは<NavigationContainer>
や<Stack.Navigator>
などの特定のコンポーネントに依存した挙動ですので、他のコンポーネントを親コンポーネントとする場合にはflex: 1
の設定が必要となるケースもあります。
UIライブラリ
特にこだわりがなかったので、この記事を見てReact Native Elements
にしました。
一覧画面のUI作成
src/components/Home (一覧画面)
コード
import React, { useEffect, useState, useCallback } from "react";
import { useNavigation } from "@react-navigation/native";
import { StackNavigationProp } from "@react-navigation/stack";
import { FlatList, Alert } from "react-native";
import { RenderItem } from "./renderItem";
import { Post, RootStackParamList } from "../../Types";
import { fetchPosts } from "../../lib/api";
type HomeScreenNavigationProp = StackNavigationProp<RootStackParamList, "Home">;
export const Home = () => {
// Post型の配列であるpostsと、その更新関数setPostsを定義
const [posts, setPosts] = useState<Post[]>([]);
// リフレッシュ中かどうかを示すrefreshingという状態と、その更新関数を定義
const [refreshing, setRefreshing] = useState(false);
const navigation = useNavigation<HomeScreenNavigationProp>();
// 非同期関数で、APIからデータを取得し、取得したデータをpostsステートにセットする関数を定義
const fetchAndSetPosts = async () => {
try {
const fetchedPosts = await fetchPosts();
setPosts(fetchedPosts);
} catch (error) {
Alert.alert(
"エラー",
(error as Error)?.message || "記事の取得に失敗しました"
);
}
};
// コンポーネントの初回レンダリング時に、fetchAndSetPosts関数を呼び出す
useEffect(() => {
fetchAndSetPosts();
}, []);
// コンポーネントのマウント時(初回レンダリング時)に、fetchAndSetPosts関数を呼び出す
// リフレッシュ開始時にはrefreshingステートをtrueに、完了したらfalseにする
const onRefresh = useCallback(() => {
setRefreshing(true);
fetchAndSetPosts().then(() => setRefreshing(false));
}, []);
return (
<FlatList
data={posts}
renderItem={({ item }) => (
<RenderItem item={item} navigation={navigation} />
)}
keyExtractor={(item) => item.id}
refreshing={refreshing}
onRefresh={onRefresh}
/>
);
};
returnの中は、FlatListコンポーネントを使って、記事データをリスト表示しています。各リスト項目のレンダリングはRenderItemコンポーネントが担当
src/components/Home/renderItem
コード
import React from "react";
import { ListItem, Avatar } from "react-native-elements";
import { View, StyleSheet } from "react-native";
import { StackNavigationProp } from "@react-navigation/stack";
import { Post, RootStackParamList } from "../../Types";
type HomeScreenNavigationProp = StackNavigationProp<RootStackParamList, "Home">;
type RenderItemProps = {
item: Post;
navigation: HomeScreenNavigationProp;
};
// ListItemコンポーネントを使用して、一つ一つのリストアイテムを表示
// 各リストアイテムは一意のkeyを持ち、ユーザーがアイテムをタップしたときにDetail画面に遷移
export const RenderItem = ({ item, navigation }: RenderItemProps) => (
<ListItem
key={item.id}
bottomDivider
onPress={() => navigation.navigate("Detail", { id: item.id })}
>
<ListItem.Content>
<View style={styles.itemContainer}>
<View>
<Avatar source={{ uri: item.thumbnail.url }} size="large" />
</View>
<View style={styles.textContainer}>
<ListItem.Title>{item.title}</ListItem.Title>
</View>
</View>
</ListItem.Content>
</ListItem>
);
const styles = StyleSheet.create({
itemContainer: {
flexDirection: "row",
alignItems: "center",
justifyContent: "flex-start",
paddingEnd: 50,
},
textContainer: {
marginLeft: 10,
},
});
- Imageコンポーネントを使用して、各記事のサムネイルを表示
- ListItem.Titleコンポーネントを使用して、各記事のタイトルを表示
詳細画面のUI作成
src/components/Detail (詳細画面)
コード
import React, { useEffect, useState } from "react";
import {
View,
ScrollView,
StyleSheet,
Alert,
ActivityIndicator,
Image,
} from "react-native";
import { RouteProp } from "@react-navigation/native";
import { Text } from "react-native-elements";
import { Post, RootStackParamList } from "../../Types";
import { fetchPostById } from "../../lib/api";
type PostScreenRouteProp = RouteProp<RootStackParamList, "Detail">;
interface PostScreenProps {
route: PostScreenRouteProp;
}
export const Detail = ({ route }: PostScreenProps) => {
const [post, setPost] = useState<Post | null>(null);
// コンポーネントがマウントされた時にAPIから投稿の詳細を取得
// 成功した場合はstateに設定
// エラーが発生した場合はアラートを表示
useEffect(() => {
const fetchAndSetPost = async () => {
try {
const fetchedPost = await fetchPostById(route.params.id);
setPost(fetchedPost);
} catch (error) {
Alert.alert(
"エラー",
(error as Error)?.message || "記事の詳細の取得に失敗しました"
);
}
};
fetchAndSetPost();
// 第二引数として空の配列[]ではなく[route.params.id]を渡していますので
// route.params.idが変更された時(つまり新しい投稿の詳細画面に遷移した時)にのみ
// このAPIリクエストが再度行われます
}, [route.params.id]);
// APIからデータを取得中の間、スピナー(ActivityIndicator)を表示
if (!post) {
return (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" />
</View>
);
}
// 投稿の詳細を表示
return (
<ScrollView>
<Image
source={{ uri: post.thumbnail.url }}
style={{ width: "100%", height: 200 }}
/>
<View style={styles.titleWrapper}>
<Text h2>{post.title}</Text>
</View>
<Text>{post.body}</Text>
</ScrollView>
);
};
const styles = StyleSheet.create({
titleWrapper: {
marginBottom: 20,
},
loadingContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
},
});
ここまでの内容でmicroCMSから得た内容がReact Native上のアプリで表示できたと思います。
React Nativeの感想
<div>
の代わりに<View>
を使うなど、いくつかの特有のルールがありますが、慣れればWeb開発の感覚で開発できそうかな、と思えました。
ところでなぜ React Native?
"React Nativeの採用理由とFlutterの比較"
chot Inc.では以前からReactを用いたWebフロントエンド開発を行っていたため、自分以外に開発を任せる際にも、メンバーの経験を活かせると考えてReact Nativeを導入しました。
Flutterも考えましたが、私含め経験のないDart言語を学ぶ必要があるため、その学習コストを考慮するとReact Nativeの方が向いていると判断しました。
他には、Shopifyが自社の大規模なモバイルアプリをReact Nativeに移行した記事(2022/12)を見かけたり
npm trends
を見てもダウロード数はされていそうなので使ってみることにしました。
React Native 製のアプリ一覧なども参考にしました。
さいごに
記事内に誤った内容が含まれているかもしれません。訂正すべき点などございましたら、遠慮なくお知らせいただけますと幸いです。ご意見やご指摘は、貴重なフィードバックとなります。
microCMSのTwitterにてご紹介いただきました
ありがとうございます。
Github
ちょっと株式会社(chot-inc.com)のエンジニアブログです。 フロントエンドエンジニア募集中! カジュアル面接申し込みはこちらから chot-inc.com/recruit/iuj62owig
Discussion