この記事はNTTドコモソリューションズ Advent Calendar 2025の17日目の記事です。
こんにちは、NTTドコモソリューションズの渡邉健太です。
先月から開発エンジニアに転向しました。
何か作ってみたいと思い、スマホアプリ開発を始めたので、学びを共有します。
サマリ
身近な題材「家計簿」でReact Nativeアプリ開発に挑戦!
初心者でも楽しめる学習記録を紹介します。
※サマリはAIが生成しました
はじめに
スマホアプリ開発を学習したいと思い、アプリを一つ作ってみたので、学びをまとめました。
iOS・Android両方に対応できるように、フレームワークは「React Native」を選択しました。
(本記事では、React NativeのOSSライブラリを利用したソースコードを紹介しています。ライブラリに関する詳細は公式リポジトリをご参照ください。)
アプリ開発の学習目的のため、題材は身近なものとし、「家計簿アプリ」にしました。
家計簿アプリの概要はこちら
作りたい画面・機能に対して、どのような実装・ライブラリ活用をしたかまとめる。
目次
-
画面切替
1-1. 機能(例.登録、一覧・編集)の画面切替
1-2. 分類(例. 支出、収入)の画面切替 -
登録機能
2-1. 年月日の選択
2-2. 数値の入力
2-3. カテゴリーの選択
2-4. ストレージ書き込み -
一覧機能
3-1. 収支一覧の参照
3-2. 収支の年月別の集計・可視化 -
編集機能
4-1. 編集対象の選択
4-2. 更新
4-2-1. 更新の画面操作
4-2-2. 更新のストレージ書き込み
4-3. 削除
4-3-1. 削除の画面操作
4-3-2. 削除のストレージ書き込み
詳細
1. 画面切替
1-1. 機能(例.登録、一覧・編集)の画面切替
画面下部の「ボトム・タブ・ナビゲータ」で選択した画面に切り替える。

画面切替の実装は下記の通り
- ボトム・タブ・ナビゲータ
createBottomTabNavigator- 画面に表示する名前:
name="登録" - 画面に表示する要素:
component={・・・} - 画面に表示するアイコン:
tabBarIcon: (・・・)
- 画面に表示する名前:
import { NavigationContainer } from '@react-navigation/native';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
const Tab = createBottomTabNavigator();
export default function App() {
return (
<NavigationContainer>
<Tab.Navigator initialRouteName='登録'>
<Tab.Screen
name="登録"
component={・・・}
options={{
tabBarIcon: ({ color, size }) => (
<Ionicons name="pencil-outline" color={color} size={size} />
)
}}
/>
<Tab.Screen
name='一覧・編集'
component={・・・}
options={{
tabBarIcon: ({ color, size }) => (
<Ionicons name="eye" color={color} size={size} />
)
}}
/>
</Tab.Navigator>
</NavigationContainer>
);
}
1-2. 分類(例. 支出、収入)の画面切替
画面上部の「セグメンテッド・コントロール」を選択すると画面を切り替える。

画面切替の実装は下記の通り
- セグメンテッド・コントロール
SegmentedControl- 表示する選択肢:
values={['支出', '収入']} - 選択時の処理:
onChange={(event) => {・・・}} - 選択中の値は「状態変数」
useStateで管理
- 表示する選択肢:
- 選択に応じて表示文言を出し分け
- 表示:
<Button title = {confirmText}/> - 出し分け:
type === 'expense' ? '支出' : '収入'
- 表示:
import React, { useState } from 'react';
import SegmentedControl from '@react-native-segmented-control/segmented-control';
export default function InputScreen({ route, navigation }) {
const [type, setType] = useState('expense');
const typeText = type === 'expense' ? '支出' : '収入';
const confirmText = `${typeText}を入力する`
return (
<View>
<SegmentedControl
values={['支出', '収入']}
selectedIndex={type === 'expense' ? 0 : 1 }
onChange={(event) => {
const index = event.nativeEvent.selectedSegmentIndex;
setType(index === 0 ? 'expense' : 'income');
}}
/>
<Button title = {confirmText} onPress={handleSave} />
</View>
);
}
2. 登録機能
2-1. 年月日の選択
日付登録の実装は下記の通り
- カレンダーを表示
- デートタイムピッカー
DateTimePicker
- デートタイムピッカー
- 選択した日付を表示
- 状態変数
useStateを使用- 初期値は現在の日付:
new Date() - 選択した日付に更新:
onChange={・・・}
- 初期値は現在の日付:
- 状態変数
- OKを押下するとカレンダーを非表示にする
- 条件付きレンダリング:
{条件 && (表示内容)} - 押下時に表示フラグを切替:
<TouchableOpacity onPress={・・・}>
- 条件付きレンダリング:
import { View, Text, Platform, TouchableOpacity } from 'react-native';
import DateTimePicker from '@react-native-community/datetimepicker';
import { format } from 'date-fns';
export default function InputScreen({ route, navigation }) {
const [date, setDate] = useState(new Date());
const [showPicker, setShowPicker] = useState(false);
const displayDate = format(date, 'yyyy-MM-dd');
return (
<View style={styles.container}>
<TouchableOpacity onPress={() => setShowPicker(true)}>
<Text>{displayDate}</Text>
</TouchableOpacity>
{showPicker && (
<View>
<DateTimePicker
value={date}
mode="date"
display='spinner'
onChange={(event, selectedDate) => {
setDate(selectedDate);
}}
/>
{Platform.OS === 'ios' && (
<TouchableOpacity onPress={() => setShowPicker(false)}>
<Text>OK</Text>
</TouchableOpacity>
)}
</View>
)}
</View>
);
}
2-2. 数値の入力
金額を選択するとテンキーが表示され、入力できる。
数値入力の実装は下記の通り
- OKで確定した金額を表示
- 状態変数
useStateを使用
- 状態変数
- 数値の入力
- テンキーを直接定義
{['1','2', '3', '4', '5', '6', '7', '8', '9', '0'].map((digit) => (・・・)- 1行に収まらない分は改行する
pad: { flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'center' },
- 押した数値を追加:
(digit) => {setAmount((prev) => prev + digit)}
- テンキーを直接定義
- 下一桁の削除
setAmount((prev) => prev.slice(0, -1))
- 数値を確定し、テンキーを非表示にする
<AmountInputPad onConfirm={(value) => {・・・}} />- ただし、
onConfirmは、呼び出し先AmountInputPadに渡すコールバック関数
import React, { useState } from 'react';
import { View, Text, TouchableOpacity } from 'react-native';
import AmountInputPad from '../utils/numberInput';
export default function InputScreen({ route, navigation }) {
const [amount, setAmount] = useState('0');
const [showAmountPad, setShowAmountPad] = useState(false);
return (
<View>
<TouchableOpacity onPress={() => setShowAmountPad(true)}>
<Text>{amount} 円</Text>
</TouchableOpacity>
{showAmountPad && (
<AmountInputPad onConfirm={(value) => {
setAmount(value);
setShowAmountPad(false);
}} />
)}
</View>
);
}
import React, { useState } from 'react';
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
export default function AmountInputPad({ onConfirm }) {
const [amount, setAmount] = useState('');
// 数字ボタン(0〜9)が押されたとき、金額の文字列にその数字を追加する。最大9桁とした
const handlePress = (digit) => {
if (amount.length < 9) {
setAmount((prev) => prev + digit);
}
};
// 削除ボタンが押された時、一番右の一桁を削除する
const handleDelete = () => {
setAmount((prev) => prev.slice(0, -1));
};
// onConfirm は親(呼び出し元)で指定するコールバック関数
return (
<View>
<Text>¥{amount || '0'}</Text>
<View style={styles.pad}>
{['1','2', '3', '4', '5', '6', '7', '8', '9', '0'].map((digit) => (
<TouchableOpacity key={digit} onPress={() => handlePress(digit)}>
<Text>{digit}</Text>
</TouchableOpacity>
))}
<TouchableOpacity onPress={handleDelete}>
<Text>←</Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => onConfirm(Number(amount))}>
<Text>OK</Text>
</TouchableOpacity>
</View>
</View>
);
}
const styles= StyleSheet.create({
pad: { flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'center' },
});
2-3. カテゴリーの選択
カテゴリー登録の実装は下記の通り
- 選択中のカテゴリーを表示
- 状態変数
useStateを使用
- 状態変数
- ピッカー
-
<Picker>を使用- 表示する選択肢:
selectedValue={・・・} - 選択時の処理:
onValueChange={・・・}
- 表示する選択肢:
-
- ピッカーを非表示にする
<TouchableOpacity onPress={・・・}>
import React, { useState } from 'react';
import { View } from 'react-native';
import { Picker } from '@react-native-picker/picker';
export default function InputScreen({ route, navigation }) {
const [category, setCategory] = useState('-');
const [showCategoryPicker, setShowCategoryPicker] = useState(false);
const categories = ・・・;
return (
<TouchableOpacity onPress={() => setShowCategoryPicker(true)}>
<Text>{category}</Text>
</TouchableOpacity>
{showCategoryPicker && (
<View>
<Picker
selectedValue={category}
onValueChange={(itemValue) => setCategory(itemValue)}
>
{categories.map((cat) => (
<Picker.Item key={cat.value} label={cat.label} value={cat.value} />
))}
</Picker>
<TouchableOpacity onPress={() => setShowCategoryPicker(false)}>
<Text>OK</Text>
</TouchableOpacity>
</View>
)}
);
}
2-4. ストレージ書き込み
画面には表示されない内部処理の実装は下記の通り
- 入力したデータを保存
- ID付与:
id: Date.now().toString(), - ストレージ保存関数:
saveExpense(newExpense); - 入力用の変数の初期化関数:
initializeInput();
- ID付与:
- ストレージ保存関数を定義
- 現行情報を取得:
AsyncStorage.getItem(STORAGE_KEY); - 登録情報を追加:
[...currentExpense, transaction]; - 上書き:
AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(newExpense));
- 現行情報を取得:
- 入力用の変数の初期化関数を定義
- 金額を初期化:
setAmount('0'); - カテゴリーを初期化:
setCategory('-'); - 日付を初期化:
setDate(new Date());
- 金額を初期化:
import { saveExpense } from '../utils/storage';
export default function InputScreen({ route, navigation }) {
const [type, setType] = useState('expense');
const [amount, setAmount] = useState('0');
const [category, setCategory] = useState('-');
const [date, setDate] = useState(new Date());
const handleSave = async () => {
const newExpense = {
id: Date.now().toString(),
type,
amount: parseInt(amount),
category,
date,
};
const initializeInput = () => {
setAmount('0');
setCategory('-');
setDate(new Date());
};
await saveExpense(newExpense);
navigation.navigate('一覧・編集');
initializeInput();
};
}
import AsyncStorage from '@react-native-async-storage/async-storage';
const STORAGE_KEY = ・・・;
export const saveExpense = async(transaction) => {
try {
const jsonValue = await AsyncStorage.getItem(STORAGE_KEY);
const currentExpense = jsonValue != null ? JSON.parse(jsonValue) :[];
const newExpense = [...currentExpense, transaction];
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(newExpense));
} catch (e) {
console.error('保存エラー:', e);
}
};
3. 一覧機能
3-1. 収支一覧の参照
登録した値を参照する実装は下記の通り
- 「一覧・編集」配下の、画面名が表示される
- 画面管理は
createNativeStackNavigatorで実施 - 画面に表示する名前:
name="一覧" - 画面に表示する要素:
component={・・・}
- 画面管理は
- 一覧を参照
- 処理のトリガー
- 「トリガー」が更新された際に「処理」する:
useEffect(() => {処理}, [トリガー]); - 「トリガー」として画面表示を
useIsFocusedで確認する
- 「トリガー」が更新された際に「処理」する:
- ストレージから読み込み
- 2と同様に
AsyncStorage.getItem(STORAGE_KEY);で取得
- 2と同様に
- 変数に格納
- 2と同様に
useStateを使用
- 2と同様に
- 変数に格納された値を画面に表示
- リスト形式:
<FlatList>を使用- リストに入れる値:
data={・・・} - 表示処理:
renderItem={({ item }) => {・・・}}
- リストに入れる値:
- リスト形式:
- 処理のトリガー
import { createNativeStackNavigator } from '@react-navigation/native-stack';
const Stack = createNativeStackNavigator();
function ReportStack() {
return (
<Stack.Navigator>
<Stack.Screen name='一覧' component={・・・} />
<Stack.Screen name='編集' component={・・・} />
</Stack.Navigator>
);
}
import React, { useEffect, useState } from 'react';
import { View, Text, FlatList } from 'react-native';
import { useIsFocused } from '@react-navigation/native';
import { loadExpense } from '../utils/storage';
export default function SummaryScreen({ navigation }){
const [transactions, setTransactions] = useState([]);
const [type, setType] = useState('expense');
const isFocused = useIsFocused();
useEffect(() => {
const fetchData = async () => {
const data = await loadExpense();
const filtered = data.filter((item) => item.type === type);
setTransactions(filtered);
};
if (isFocused) {
fetchData();
}
}, [isFocused, type]);
return (
<View>
<FlatList
data={transactions}
keyExtractor={(item) => item.id}
renderItem={({ item }) => {
return (
<View>
<Text>{item.date} - {item.category}: ¥{item.amount}</Text>
</View>
);
}}
/>
</View>
)
}
import AsyncStorage from '@react-native-async-storage/async-storage';
const STORAGE_KEY = ・・・;
export const loadExpense = async () => {
try {
const jsonValue = await AsyncStorage.getItem(STORAGE_KEY);
return jsonValue != null ? JSON.parse(jsonValue) : [];
} catch (e) {
console.error('読み込みエラー:', e);
return [];
}
};
3-2. 収支の年月別の集計・可視化
値を集計・可視化する実装は下記の通り
- 年月別の集計・可視化
- 集計前の値を変数に格納
- 3.1の通り状態変数
useStateを使用
- 3.1の通り状態変数
- 集計処理
-
calculateMonthlyTotals()として、配列の要素の和を取る処理を定義
-
- 集計結果を変数に格納
- 3.1と同様に状態変数
useStateを使用 - 集計結果を格納した変数
monthlyTotalsの例{"2025-09": {"expense": 1500, "income": 0}, "2025-10": {"expense": 1100, "income": 0}, "2025-11": {"expense": 700, "income": 0}}
- 3.1と同様に状態変数
- 変数に格納した値をグラフで表示
- 棒グラフ:
<BarChart> - x軸
labels: Object.keys(monthlyTotals),- 抽出結果は
["2025-09", "2025-10", "2025-11"]
- y軸
data: Object.values(monthlyTotals).map(item => item[type]),- 抽出結果は
typeがexpenseの場合、[1500, 1100, 700]
- 棒グラフ:
- 集計前の値を変数に格納
- y軸は0始まり
- オプションで
fromZero={true}を使用
- オプションで
- グラフに値を表示
- オプションで
showValuesOnTopOfBars={true}を使用
- オプションで
import React, { useEffect, useState } from 'react';
import { View, Text, FlatList, Dimensions } from 'react-native';
import { useIsFocused } from '@react-navigation/native';
import { BarChart } from 'react-native-chart-kit';
import { loadExpense } from '../utils/storage';
import { calculateMonthlyTotals } from '../utils/aggregate';
export default function SummaryScreen({ navigation }){
const [transactions, setTransactions] = useState([]);
const [monthlyTotals, setMonthlyTotals] = useState([]);
const [type, setType] = useState('expense');
const isFocused = useIsFocused();
useEffect(() => {
const fetchData = async () => {
const data = await loadExpense();
const filtered = data.filter((item) => item.type === type);
setTransactions(filtered);
setMonthlyTotals(calculateMonthlyTotals(filtered));
};
if (isFocused) {
fetchData();
}
}, [isFocused, type]);
const screenWidth = Dimensions.get("window").width;
return (
<View>
<BarChart
data={{
labels: Object.keys(monthlyTotals),
datasets: [{
data: Object.values(monthlyTotals).map(item => item[type]),
},],
}}
width={screenWidth - 32}
height={220}
fromZero={true}
showValuesOnTopOfBars={true}
chartConfig={{・・・}}
/>
</View>
)
}
export const calculateMonthlyTotals = (transactions) => {
const totals = {};
transactions.forEach((transaction) => {
const date = new Date(transaction.date);
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
if (!totals[monthKey]) {
totals[monthKey] = { expense:0, income:0};
}
if (transaction.type === 'expense') {
totals[monthKey].expense += transaction.amount;
} else {
totals[monthKey].income += transaction.amount;
}
});
return totals;
};
4. 編集機能
4-1. 編集対象の選択
編集対象を選択時の実装は下記の通り
- 編集対象を選択
- 要素押下時の処理:
<TouchableOpacity onPress={・・・}> - 処理の中身
- 要素の抽出:
transactions.find((e) => e.id === id) - 編集画面へ遷移:
navigation.navigate('編集', 引数);
- 要素の抽出:
- 要素押下時の処理:
import { View, Text, FlatList, TouchableOpacity } from 'react-native';
import React, { useState } from 'react';
export default function SummaryScreen({ navigation }){
const [transactions, setTransactions] = useState([]);
const handleEdit = (id) => {
const target = transactions.find((e) => e.id === id);
navigation.navigate('編集', {transaction: target});
};
return (
<View>
<FlatList
data={transactions}
keyExtractor={(item) => item.id}
renderItem={({ item }) => {
return (
<View>
<TouchableOpacity onPress={() => handleEdit(item.id)}>
<Text>{item.date} - {item.category}: ¥{item.amount}</Text>
</TouchableOpacity>
</View>
);
}}
/>
</View>
)
}
編集時の実装は下記の通り
- 前画面に戻ることができる
- 3に記載の通り、画面遷移は
createNativeStackNavigatorを使用している -
createNativeStackNavigatorは「スタック」構造で画面遷移の履歴を保持しており、1つずつ前画面に戻ることができる
- 3に記載の通り、画面遷移は
- 前画面で選択した項目の値が表示される
- 前画面から引数を受け取る:
function 関数({ route }) {・・・} - 引数から指定のプロパティを取得:
route.params?.プロパティ- 指定のプロパティがある場合:値を取得
- 指定のプロパティがない場合:
undefinedを取得
- プロパティ有無を真偽値で表現:
!!route.params?.プロパティ - 状態変数
useStateに設定:useState(真偽値 ? route.params.プロパティ.子プロパティ)
- 前画面から引数を受け取る:
import React, { useState } from 'react';
export default function InputScreen({ route, navigation }) {
const isEdit = !!route.params?.transaction;
const [amount, setAmount] = useState(isEdit ? String(route.params.transaction.amount) : '0');
const [category, setCategory] = useState(isEdit ? route.params.transaction.category : '-');
const [date, setDate] = useState(isEdit ? new Date(route.params.transaction.date) : new Date());
const [type, setType] = useState(isEdit ? route.params.transaction.type : 'expense');
return (・・・);
}
4-2. 更新
4-2-1. 更新の画面操作
更新の実装は下記の通り
- 上書き
- ボタン
<Button/>の使用- 文言:
title={confirmText} - 押下時の処理:
onPress={・・・}
- 文言:
- ボタン
import { updateExpense } from '../utils/storage';
export default function InputScreen({ route, navigation }) {
const isEdit = !!route.params?.transaction;
const [amount, setAmount] = useState(isEdit ? String(route.params.transaction.amount) : '0');
const [category, setCategory] = useState(isEdit ? route.params.transaction.category : '-');
const [date, setDate] = useState(isEdit ? new Date(route.params.transaction.date) : new Date());
const [type, setType] = useState(isEdit ? route.params.transaction.type : 'expense');
const handleSave = async () => {
const newExpense = {
id: isEdit ? route.params.transaction.id : Date.now().toString(),
type,
amount: parseInt(amount),
category,
date,
};
if (isEdit) {
await updateExpense(newExpense);
navigation.goBack();
}
};
const typeText = type === 'expense' ? '支出' : '収入';
const confirmText = isEdit
? `${typeText}を上書きする`
: `${typeText}を入力する`;
return (
<View>
<Button title = {confirmText} onPress={handleSave} />
</View>
);
}
4-2-2. 更新のストレージ書き込み
画面には表示されない内部処理の実装は下記の通り
- ストレージの現行情報を取得:
AsyncStorage.getItem(STORAGE_KEY); - 指定したIDの要素を更新:
currentExpense.map(item => item.id === updatedItem.id ? updatedItem : item); - 上書き:
AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(newExpense));
import AsyncStorage from '@react-native-async-storage/async-storage';
const STORAGE_KEY = ・・・;
export const updateExpense = async(updatedItem) => {
try {
const jsonValue = await AsyncStorage.getItem(STORAGE_KEY);
const currentExpense = jsonValue != null ? JSON.parse(jsonValue) :[];
const newExpense = currentExpense.map(item =>
item.id === updatedItem.id ? updatedItem : item
);
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(newExpense));
} catch (e) {
console.error('更新エラー:', e);
}
};
4-3. 削除
4-3-1. 削除の画面操作
編集画面から削除を選択する
削除押下時の実装は下記の通り
- 削除する
- ボタン
<Button/>の使用- 文言:
title='削除' - 押下時の処理:
onPress={・・・}
- 文言:
- ボタン
import { View, Text, Button } from 'react-native';
export default function InputScreen({ route, navigation }) {
const isEdit = !!route.params?.transaction;
const handleDelete = () => {・・・}
return (
<View>
{isEdit && (
<Button title='削除' onPress={handleDelete} />
)}
</View>
);
}
確認ダイアログを表示する
- 確認ダイアログ
-
Alert.alertを使用- タイトル:
'確認' - 本文:
'この支出を削除しますか' - キャンセル:
text: 'キャンセル', style: 'cancel', - 実行
- 文言:
text: '削除' - 押下時の処理:
onPress: async() => {・・・}
- 文言:
- タイトル:
-
import { Alert } from 'react-native';
import { deleteExpense } from '../utils/storage';
export default function InputScreen({ route, navigation }) {
const handleDelete = () => {
const newExpense = {
id: isEdit ? route.params.transaction.id : Date.now().toString(),
type,
amount: parseInt(amount),
category,
date,
};
Alert.alert(
'確認', // タイトル
'この支出を削除しますか', // 本文
[
{
text: 'キャンセル',
style: 'cancel',
},
{
text: '削除',
onPress: async() => {
await deleteExpense(newExpense);
navigation.goBack();
}
}
],
);
};
return (・・・);
}
4-3-2. 削除のストレージ書き込み
画面には表示されない内部処理の実装は下記の通り
- ストレージの現行情報を取得
AsyncStorage.getItem(STORAGE_KEY);
- 指定したIDの要素を除く
currentExpense.filter(item => item.id !== deletedItem.id);
- ストレージを上書き
AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(newExpense));
import AsyncStorage from '@react-native-async-storage/async-storage';
const STORAGE_KEY = ・・・;
export const deleteExpense = async (deletedItem) => {
try {
const jsonValue = await AsyncStorage.getItem(STORAGE_KEY);
const currentExpense = jsonValue != null ? JSON.parse(jsonValue) :[];
const newExpense = currentExpense.filter(item => item.id !== deletedItem.id);
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(newExpense));
} catch (e) {
console.error('削除エラー:', e);
}
}
所感
Copilotが隣にいれば
・やりたいことに適したライブラリを見つける
・見つけたライブラリの使い方を確認する
をスムーズにできるため、
初心者でもスマホアプリを開発できることが分かった。
記載されている会社名、製品名、サービス名は各社の商標または登録商標です。










