React入門者が最初に作るべき簡単アプリ5選

React初心者向けに厳選した学習効果の高い簡単アプリを5つ紹介。基本概念を身につけながら実践的なスキルを習得できる段階的な学習プランを提供します。

Learning Next 運営
73 分で読めます

React入門者が最初に作るべき簡単アプリ5選

みなさん、React学習を始めたけれど、何から作ればいいかわからないと悩んでいませんか?

チュートリアルは理解できたけど、実際に自分でアプリを作ろうとすると手が止まってしまう。 そんな経験をしたことがあるのではないでしょうか?

React学習において、理論の理解と実践の間には大きなギャップがあります。 でも心配いりません!

今回は、React入門者が確実にスキルアップできる5つの簡単なアプリを紹介します。 学習効果を考慮して厳選した順番で、着実にReactスキルを身につけていきましょう!

なぜ実践プロジェクトが重要なのか

座学だけでは身につかないReactのスキルを、実践を通じて習得しましょう。

実践学習の効果

実践プロジェクトがなぜ重要なのか、理由を説明します。

実践学習には以下のような効果があります。

  • 概念の具体化: 抽象的な概念を実際のコードで体験できる
  • 問題解決能力: エラーやバグに対する対処能力が向上する
  • 応用力の育成: 基本知識を組み合わせて新しい機能を実装できる
  • ポートフォリオ作成: 就職活動や転職時のアピール材料になる

簡単に言うと、「知っている」から「できる」への変化を促すのが実践プロジェクトです。

アプリ選定の基準

今回紹介する5つのアプリは、以下の基準で選定しました。

選定基準をご紹介します。

  1. 段階的な難易度: 基本から応用へと順番に学習できる
  2. 重要概念の網羅: Reactの主要機能を幅広くカバーしている
  3. 実用性: 実際に使える機能を持つアプリケーション
  4. 拡張性: 後から機能を追加しやすい構造になっている

大丈夫です! 一つずつ確実に作っていけば、きっとReactの力が身につきますよ。

アプリ1: カウンターアプリ(所要時間: 30分〜1時間)

最初に作るべきは、Reactの基本中の基本であるカウンターアプリです。

学習目標

このアプリで習得できる概念をご紹介します。

  • useState: 状態管理の基本を理解する
  • イベントハンドリング: ボタンクリックの処理を学ぶ
  • JSX: Reactの記法に慣れる
  • コンポーネント: 基本的なコンポーネント構造を理解する

基本実装

まず、基本的なカウンターアプリを作ってみましょう。

import React, { useState } from 'react';
import './Counter.css';

function Counter() {
  const [count, setCount] = useState(0);

  const increment = () => {
    setCount(count + 1);
  };

  const decrement = () => {
    setCount(count - 1);
  };

  const reset = () => {
    setCount(0);
  };

  return (
    <div className="counter-container">
      <h1>カウンターアプリ</h1>
      
      <div className="count-display">
        <span className="count-number">{count}</span>
      </div>
      
      <div className="button-group">
        <button 
          className="btn btn-increment" 
          onClick={increment}
        >
          +1
        </button>
        
        <button 
          className="btn btn-decrement" 
          onClick={decrement}
        >
          -1
        </button>
        
        <button 
          className="btn btn-reset" 
          onClick={reset}
        >
          リセット
        </button>
      </div>
      
      <div className="info">
        <p>現在のカウント: {count}</p>
        <p>状態: {count > 0 ? 'プラス' : count < 0 ? 'マイナス' : 'ゼロ'}</p>
      </div>
    </div>
  );
}

export default Counter;

このコードを見ると、useStateフックを使ってカウントの状態を管理しています。 setCount関数を使って状態を更新すると、自動的にUIが再描画される仕組みです。

ボタンクリックのイベントハンドラー(incrementdecrementreset)も実装しています。

CSS例

見た目を整えるCSSも用意しました。

/* Counter.css */
.counter-container {
  max-width: 400px;
  margin: 50px auto;
  padding: 30px;
  text-align: center;
  border: 2px solid #e0e0e0;
  border-radius: 10px;
  background-color: #f9f9f9;
}

.count-display {
  margin: 30px 0;
  padding: 20px;
  background-color: white;
  border-radius: 8px;
  box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}

.count-number {
  font-size: 48px;
  font-weight: bold;
  color: #333;
}

.button-group {
  display: flex;
  gap: 10px;
  justify-content: center;
  margin: 20px 0;
}

.btn {
  padding: 12px 20px;
  font-size: 16px;
  border: none;
  border-radius: 6px;
  cursor: pointer;
  transition: all 0.3s ease;
}

.btn-increment {
  background-color: #4CAF50;
  color: white;
}

.btn-decrement {
  background-color: #f44336;
  color: white;
}

.btn-reset {
  background-color: #2196F3;
  color: white;
}

.btn:hover {
  transform: translateY(-2px);
  box-shadow: 0 4px 8px rgba(0,0,0,0.2);
}

.info {
  margin-top: 20px;
  padding: 15px;
  background-color: #e8f4f8;
  border-radius: 6px;
}

このCSSを使うことで、見た目も美しいカウンターアプリが完成します。

発展課題

基本実装ができたら、以下の機能を追加してみましょう。

function AdvancedCounter() {
  const [count, setCount] = useState(0);
  const [step, setStep] = useState(1);
  const [history, setHistory] = useState([0]);

  const updateCount = (newCount) => {
    setCount(newCount);
    setHistory(prev => [...prev, newCount]);
  };

  const incrementByStep = () => {
    updateCount(count + step);
  };

  const decrementByStep = () => {
    updateCount(count - step);
  };

  const undo = () => {
    if (history.length > 1) {
      const newHistory = history.slice(0, -1);
      setHistory(newHistory);
      setCount(newHistory[newHistory.length - 1]);
    }
  };

  return (
    <div className="counter-container">
      <h1>高機能カウンター</h1>
      
      <div className="count-display">
        <span className="count-number">{count}</span>
      </div>
      
      <div className="step-control">
        <label>
          増減値: 
          <input 
            type="number" 
            value={step} 
            onChange={(e) => setStep(parseInt(e.target.value) || 1)}
            min="1"
          />
        </label>
      </div>
      
      <div className="button-group">
        <button onClick={incrementByStep}>+{step}</button>
        <button onClick={decrementByStep}>-{step}</button>
        <button onClick={() => updateCount(0)}>リセット</button>
        <button onClick={undo} disabled={history.length <= 1}>
          元に戻す
        </button>
      </div>
      
      <div className="stats">
        <p>最大値: {Math.max(...history)}</p>
        <p>最小値: {Math.min(...history)}</p>
        <p>操作回数: {history.length - 1}</p>
      </div>
    </div>
  );
}

この発展版では、増減値の設定、操作履歴の記録、元に戻す機能、統計情報の表示を追加しています。

複数の状態を管理する練習にもなりますね。

アプリ2: TODOリスト(所要時間: 2〜3時間)

2番目に取り組むべきは、定番のTODOリストアプリです。

学習目標

このアプリで習得できる概念をご紹介します。

  • 配列のstate管理: 複数のデータを扱う方法を学ぶ
  • リスト表示: mapを使ったデータ表示を理解する
  • CRUD操作: 作成、読み取り、更新、削除の基本を学ぶ
  • フォーム処理: 入力データの処理方法を身につける

基本実装

まず、基本的なTODOリストを作ってみましょう。

import React, { useState } from 'react';
import './TodoApp.css';

function TodoApp() {
  const [todos, setTodos] = useState([]);
  const [inputValue, setInputValue] = useState('');
  const [filter, setFilter] = useState('all'); // all, active, completed

  // TODOを追加
  const addTodo = () => {
    if (inputValue.trim() !== '') {
      const newTodo = {
        id: Date.now(),
        text: inputValue.trim(),
        completed: false,
        createdAt: new Date().toLocaleString()
      };
      setTodos([...todos, newTodo]);
      setInputValue('');
    }
  };

  // TODOの完了状態を切り替え
  const toggleTodo = (id) => {
    setTodos(todos.map(todo =>
      todo.id === id 
        ? { ...todo, completed: !todo.completed }
        : todo
    ));
  };

  // TODOを削除
  const deleteTodo = (id) => {
    setTodos(todos.filter(todo => todo.id !== id));
  };

  // TODOのテキストを編集
  const editTodo = (id, newText) => {
    setTodos(todos.map(todo =>
      todo.id === id 
        ? { ...todo, text: newText }
        : todo
    ));
  };

  // フィルタリング
  const filteredTodos = todos.filter(todo => {
    if (filter === 'active') return !todo.completed;
    if (filter === 'completed') return todo.completed;
    return true; // 'all'
  });

  // Enter キーで追加
  const handleKeyPress = (e) => {
    if (e.key === 'Enter') {
      addTodo();
    }
  };

  // 完了済みTODOを一括削除
  const clearCompleted = () => {
    setTodos(todos.filter(todo => !todo.completed));
  };

  const completedCount = todos.filter(todo => todo.completed).length;
  const activeCount = todos.length - completedCount;

  return (
    <div className="todo-app">
      <h1>TODOリスト</h1>
      
      {/* 入力エリア */}
      <div className="input-section">
        <input
          type="text"
          value={inputValue}
          onChange={(e) => setInputValue(e.target.value)}
          onKeyPress={handleKeyPress}
          placeholder="新しいタスクを入力..."
          className="todo-input"
        />
        <button onClick={addTodo} className="add-button">
          追加
        </button>
      </div>

      {/* フィルタボタン */}
      <div className="filter-section">
        <button 
          className={filter === 'all' ? 'active' : ''}
          onClick={() => setFilter('all')}
        >
          すべて ({todos.length})
        </button>
        <button 
          className={filter === 'active' ? 'active' : ''}
          onClick={() => setFilter('active')}
        >
          未完了 ({activeCount})
        </button>
        <button 
          className={filter === 'completed' ? 'active' : ''}
          onClick={() => setFilter('completed')}
        >
          完了済み ({completedCount})
        </button>
      </div>

      {/* TODOリスト */}
      <div className="todo-list">
        {filteredTodos.length === 0 ? (
          <p className="empty-message">
            {filter === 'all' ? 'タスクがありません' : 
             filter === 'active' ? '未完了のタスクがありません' : 
             '完了済みのタスクがありません'}
          </p>
        ) : (
          filteredTodos.map(todo => (
            <TodoItem
              key={todo.id}
              todo={todo}
              onToggle={toggleTodo}
              onDelete={deleteTodo}
              onEdit={editTodo}
            />
          ))
        )}
      </div>

      {/* フッター */}
      {todos.length > 0 && (
        <div className="footer">
          <span>{activeCount} 個のタスクが残っています</span>
          {completedCount > 0 && (
            <button onClick={clearCompleted} className="clear-button">
              完了済みを削除
            </button>
          )}
        </div>
      )}
    </div>
  );
}

このコードでは、複数の状態を管理しています。 todos配列でタスクのリストを管理し、inputValueで入力値を管理しています。

配列の操作(追加、削除、更新)を学ぶことができます。

次に、個別のTODOアイテムのコンポーネントを見てみましょう。

// 個別のTODOアイテムコンポーネント
function TodoItem({ todo, onToggle, onDelete, onEdit }) {
  const [isEditing, setIsEditing] = useState(false);
  const [editText, setEditText] = useState(todo.text);

  const handleEdit = () => {
    if (editText.trim() !== '') {
      onEdit(todo.id, editText.trim());
      setIsEditing(false);
    }
  };

  const handleCancel = () => {
    setEditText(todo.text);
    setIsEditing(false);
  };

  const handleKeyPress = (e) => {
    if (e.key === 'Enter') {
      handleEdit();
    } else if (e.key === 'Escape') {
      handleCancel();
    }
  };

  return (
    <div className={`todo-item ${todo.completed ? 'completed' : ''}`}>
      <input
        type="checkbox"
        checked={todo.completed}
        onChange={() => onToggle(todo.id)}
        className="todo-checkbox"
      />
      
      {isEditing ? (
        <div className="edit-mode">
          <input
            type="text"
            value={editText}
            onChange={(e) => setEditText(e.target.value)}
            onKeyPress={handleKeyPress}
            onBlur={handleEdit}
            autoFocus
            className="edit-input"
          />
        </div>
      ) : (
        <div className="view-mode">
          <span 
            className="todo-text"
            onDoubleClick={() => setIsEditing(true)}
          >
            {todo.text}
          </span>
          <span className="todo-date">{todo.createdAt}</span>
        </div>
      )}
      
      <div className="todo-actions">
        {!isEditing && (
          <>
            <button 
              onClick={() => setIsEditing(true)}
              className="edit-button"
            >
              編集
            </button>
            <button 
              onClick={() => onDelete(todo.id)}
              className="delete-button"
            >
              削除
            </button>
          </>
        )}
      </div>
    </div>
  );
}

export default TodoApp;

このTodoItemコンポーネントでは、編集モードの切り替えや、インライン編集機能を実装しています。

コンポーネントの分割と props の受け渡しを学ぶことができます。

発展課題

基本機能ができたら、以下の機能を追加してみましょう。

追加できる機能をご紹介します。

  • 優先度設定: 高・中・低の優先度を設定できる
  • 期限設定: タスクの期限日時を設定できる
  • カテゴリ分け: 仕事、プライベートなどのカテゴリで分類
  • 検索機能: タスクのテキスト検索が可能
  • ローカルストレージ: データの永続化を実装

これらの機能を追加することで、より実用的なTODOアプリになります。

アプリ3: 天気アプリ(所要時間: 3〜4時間)

3番目は、外部APIを使った天気情報アプリです。

学習目標

このアプリで習得できる概念をご紹介します。

  • API呼び出し: fetchを使った外部データ取得を学ぶ
  • useEffect: 副作用の処理を理解する
  • 非同期処理: async/awaitとPromiseを使いこなす
  • エラーハンドリング: API エラーの処理方法を身につける
  • ローディング状態: ユーザーフィードバックを実装する

基本実装

まず、天気情報を取得するアプリを作ってみましょう。

import React, { useState, useEffect } from 'react';
import './WeatherApp.css';

function WeatherApp() {
  const [weather, setWeather] = useState(null);
  const [city, setCity] = useState('Tokyo');
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  const [favorites, setFavorites] = useState(['Tokyo', 'Osaka', 'Kyoto']);

  // OpenWeatherMap API キー(実際の開発では環境変数を使用)
  const API_KEY = 'YOUR_API_KEY';
  const API_URL = 'https://api.openweathermap.org/data/2.5/weather';

  // 天気データを取得
  const fetchWeather = async (cityName) => {
    setLoading(true);
    setError(null);

    try {
      const response = await fetch(
        `${API_URL}?q=${cityName}&appid=${API_KEY}&units=metric&lang=ja`
      );

      if (!response.ok) {
        throw new Error('都市が見つかりません');
      }

      const data = await response.json();
      setWeather(data);
    } catch (err) {
      setError(err.message);
      setWeather(null);
    } finally {
      setLoading(false);
    }
  };

  // 初回ロード時に東京の天気を取得
  useEffect(() => {
    fetchWeather(city);
  }, []);

  // 検索実行
  const handleSearch = (e) => {
    e.preventDefault();
    if (city.trim()) {
      fetchWeather(city.trim());
    }
  };

  // お気に入り都市追加
  const addToFavorites = () => {
    if (weather && !favorites.includes(weather.name)) {
      setFavorites([...favorites, weather.name]);
    }
  };

  // お気に入り都市削除
  const removeFromFavorites = (cityName) => {
    setFavorites(favorites.filter(fav => fav !== cityName));
  };

  // 温度に応じた背景色を取得
  const getBackgroundClass = () => {
    if (!weather) return 'default';
    const temp = weather.main.temp;
    if (temp < 0) return 'very-cold';
    if (temp < 10) return 'cold';
    if (temp < 20) return 'cool';
    if (temp < 30) return 'warm';
    return 'hot';
  };

  return (
    <div className={`weather-app ${getBackgroundClass()}`}>
      <div className="container">
        <h1>天気アプリ</h1>

        {/* 検索フォーム */}
        <form onSubmit={handleSearch} className="search-form">
          <input
            type="text"
            value={city}
            onChange={(e) => setCity(e.target.value)}
            placeholder="都市名を入力..."
            className="city-input"
          />
          <button type="submit" disabled={loading} className="search-button">
            {loading ? '検索中...' : '検索'}
          </button>
        </form>

        {/* お気に入り都市 */}
        <div className="favorites">
          <h3>お気に入り都市</h3>
          <div className="favorite-cities">
            {favorites.map(favCity => (
              <div key={favCity} className="favorite-item">
                <button
                  onClick={() => {
                    setCity(favCity);
                    fetchWeather(favCity);
                  }}
                  className="favorite-button"
                >
                  {favCity}
                </button>
                <button
                  onClick={() => removeFromFavorites(favCity)}
                  className="remove-favorite"
                >
                  ×
                </button>
              </div>
            ))}
          </div>
        </div>

        {/* ローディング表示 */}
        {loading && (
          <div className="loading">
            <div className="spinner"></div>
            <p>天気情報を取得中...</p>
          </div>
        )}

        {/* エラー表示 */}
        {error && (
          <div className="error">
            <p>❌ {error}</p>
            <p>都市名を確認して再度お試しください。</p>
          </div>
        )}

        {/* 天気情報表示 */}
        {weather && !loading && (
          <div className="weather-info">
            <div className="weather-header">
              <h2>{weather.name}, {weather.sys.country}</h2>
              <button onClick={addToFavorites} className="add-favorite">
                ⭐ お気に入りに追加
              </button>
            </div>

            <div className="weather-main">
              <div className="temperature">
                <span className="temp-value">{Math.round(weather.main.temp)}</span>
                <span className="temp-unit">°C</span>
              </div>
              
              <div className="weather-description">
                <img
                  src={`https://openweathermap.org/img/w/${weather.weather[0].icon}.png`}
                  alt={weather.weather[0].description}
                  className="weather-icon"
                />
                <p>{weather.weather[0].description}</p>
              </div>
            </div>

            <div className="weather-details">
              <div className="detail-item">
                <span className="label">体感温度</span>
                <span className="value">{Math.round(weather.main.feels_like)}°C</span>
              </div>
              <div className="detail-item">
                <span className="label">湿度</span>
                <span className="value">{weather.main.humidity}%</span>
              </div>
              <div className="detail-item">
                <span className="label">気圧</span>
                <span className="value">{weather.main.pressure} hPa</span>
              </div>
              <div className="detail-item">
                <span className="label">風速</span>
                <span className="value">{weather.wind.speed} m/s</span>
              </div>
              <div className="detail-item">
                <span className="label">視界</span>
                <span className="value">{(weather.visibility / 1000).toFixed(1)} km</span>
              </div>
            </div>

            <div className="weather-footer">
              <p>最後の更新: {new Date().toLocaleString()}</p>
            </div>
          </div>
        )}
      </div>
    </div>
  );
}

export default WeatherApp;

このコードでは、useEffectを使って初回ロード時にデータを取得しています。 async/awaitを使った非同期処理で、API からデータを取得しています。

エラーハンドリングやローディング状態の管理も実装しています。

モックデータ版(API キーなしでテスト可能)

実際のAPIキーがない場合のモックデータ版もご用意しました。

function WeatherAppMock() {
  const [weather, setWeather] = useState(null);
  const [city, setCity] = useState('');
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  // モックデータ
  const mockWeatherData = {
    'Tokyo': {
      name: 'Tokyo',
      sys: { country: 'JP' },
      main: { temp: 23, feels_like: 25, humidity: 65, pressure: 1013 },
      weather: [{ description: '晴れ', icon: '01d' }],
      wind: { speed: 3.2 },
      visibility: 10000
    },
    'Osaka': {
      name: 'Osaka',
      sys: { country: 'JP' },
      main: { temp: 21, feels_like: 22, humidity: 70, pressure: 1015 },
      weather: [{ description: '曇り', icon: '02d' }],
      wind: { speed: 2.1 },
      visibility: 8000
    }
  };

  const fetchWeather = async (cityName) => {
    setLoading(true);
    setError(null);

    // API呼び出しの模擬
    await new Promise(resolve => setTimeout(resolve, 1000));

    const data = mockWeatherData[cityName];
    if (data) {
      setWeather(data);
    } else {
      setError('都市が見つかりません');
      setWeather(null);
    }

    setLoading(false);
  };

  // 残りの実装は同じ...
}

このモック版を使えば、APIキーなしでも動作テストができます。

実際のAPIの動作を理解した後で、本物のAPIキーを使ってみましょう。

アプリ4: 簡易計算機(所要時間: 2〜3時間)

4番目は、状態管理が複雑な計算機アプリです。

学習目標

このアプリで習得できる概念をご紹介します。

  • 複雑な状態管理: 複数の状態の協調処理を学ぶ
  • 条件分岐: 複雑なロジックの実装方法を理解する
  • キーボードイベント: ユーザビリティの向上を図る
  • 数値計算: JavaScript の計算処理を身につける

基本実装

まず、基本的な計算機を作ってみましょう。

import React, { useState, useEffect } from 'react';
import './Calculator.css';

function Calculator() {
  const [display, setDisplay] = useState('0');
  const [previousValue, setPreviousValue] = useState(null);
  const [operation, setOperation] = useState(null);
  const [waitingForNewValue, setWaitingForNewValue] = useState(false);
  const [history, setHistory] = useState([]);

  // 数字入力
  const inputNumber = (num) => {
    if (waitingForNewValue) {
      setDisplay(String(num));
      setWaitingForNewValue(false);
    } else {
      setDisplay(display === '0' ? String(num) : display + num);
    }
  };

  // 小数点入力
  const inputDecimal = () => {
    if (waitingForNewValue) {
      setDisplay('0.');
      setWaitingForNewValue(false);
    } else if (display.indexOf('.') === -1) {
      setDisplay(display + '.');
    }
  };

  // クリア
  const clear = () => {
    setDisplay('0');
    setPreviousValue(null);
    setOperation(null);
    setWaitingForNewValue(false);
  };

  // 演算子入力
  const performOperation = (nextOperation) => {
    const inputValue = parseFloat(display);

    if (previousValue === null) {
      setPreviousValue(inputValue);
    } else if (operation) {
      const currentValue = previousValue || 0;
      const newValue = calculate(currentValue, inputValue, operation);

      setDisplay(String(newValue));
      setPreviousValue(newValue);

      // 履歴に追加
      const calculation = `${currentValue} ${operation} ${inputValue} = ${newValue}`;
      setHistory(prev => [calculation, ...prev.slice(0, 9)]); // 最新10件を保持
    }

    setWaitingForNewValue(true);
    setOperation(nextOperation);
  };

  // 計算実行
  const calculate = (firstValue, secondValue, operation) => {
    switch (operation) {
      case '+':
        return firstValue + secondValue;
      case '-':
        return firstValue - secondValue;
      case '×':
        return firstValue * secondValue;
      case '÷':
        return secondValue !== 0 ? firstValue / secondValue : 0;
      case '=':
        return secondValue;
      default:
        return secondValue;
    }
  };

  // イコール処理
  const handleEqual = () => {
    if (operation && previousValue !== null) {
      performOperation('=');
      setOperation(null);
      setPreviousValue(null);
      setWaitingForNewValue(true);
    }
  };

  // バックスペース
  const handleBackspace = () => {
    if (display.length > 1) {
      setDisplay(display.slice(0, -1));
    } else {
      setDisplay('0');
    }
  };

  // プラスマイナス切り替え
  const toggleSign = () => {
    if (display !== '0') {
      setDisplay(display.charAt(0) === '-' ? display.slice(1) : '-' + display);
    }
  };

  // パーセント計算
  const handlePercent = () => {
    const value = parseFloat(display);
    setDisplay(String(value / 100));
  };

  return (
    <div className="calculator">
      <div className="calculator-container">
        <div className="display-section">
          <div className="display">
            {display}
          </div>
          <div className="operation-display">
            {previousValue !== null && operation && (
              <span>{previousValue} {operation}</span>
            )}
          </div>
        </div>

        <div className="buttons">
          {/* 1行目 */}
          <button onClick={clear} className="btn btn-clear">C</button>
          <button onClick={toggleSign} className="btn btn-operation">±</button>
          <button onClick={handlePercent} className="btn btn-operation">%</button>
          <button onClick={() => performOperation('÷')} className="btn btn-operation">÷</button>

          {/* 2行目 */}
          <button onClick={() => inputNumber(7)} className="btn btn-number">7</button>
          <button onClick={() => inputNumber(8)} className="btn btn-number">8</button>
          <button onClick={() => inputNumber(9)} className="btn btn-number">9</button>
          <button onClick={() => performOperation('×')} className="btn btn-operation">×</button>

          {/* 3行目 */}
          <button onClick={() => inputNumber(4)} className="btn btn-number">4</button>
          <button onClick={() => inputNumber(5)} className="btn btn-number">5</button>
          <button onClick={() => inputNumber(6)} className="btn btn-number">6</button>
          <button onClick={() => performOperation('-')} className="btn btn-operation">-</button>

          {/* 4行目 */}
          <button onClick={() => inputNumber(1)} className="btn btn-number">1</button>
          <button onClick={() => inputNumber(2)} className="btn btn-number">2</button>
          <button onClick={() => inputNumber(3)} className="btn btn-number">3</button>
          <button onClick={() => performOperation('+')} className="btn btn-operation">+</button>

          {/* 5行目 */}
          <button onClick={() => inputNumber(0)} className="btn btn-number btn-zero">0</button>
          <button onClick={inputDecimal} className="btn btn-number">.</button>
          <button onClick={handleEqual} className="btn btn-equals">=</button>
        </div>
      </div>

      {/* 履歴表示 */}
      {history.length > 0 && (
        <div className="history">
          <h3>計算履歴</h3>
          <div className="history-list">
            {history.map((item, index) => (
              <div key={index} className="history-item">
                {item}
              </div>
            ))}
          </div>
          <button 
            onClick={() => setHistory([])} 
            className="clear-history"
          >
            履歴をクリア
          </button>
        </div>
      )}
    </div>
  );
}

export default Calculator;

このコードでは、複数の状態を同時に管理しています。 表示値、前の値、演算子、待機状態など、複雑な状態の関係を理解できます。

calculate関数でswitch文を使った条件分岐も学べます。

さらに、キーボードイベントの処理も追加してみましょう。

// キーボードイベント
useEffect(() => {
  const handleKeyPress = (event) => {
    const { key } = event;
    
    if ('0123456789'.includes(key)) {
      inputNumber(parseInt(key));
    } else if (key === '.') {
      inputDecimal();
    } else if (key === '+') {
      performOperation('+');
    } else if (key === '-') {
      performOperation('-');
    } else if (key === '*') {
      performOperation('×');
    } else if (key === '/') {
      event.preventDefault();
      performOperation('÷');
    } else if (key === 'Enter' || key === '=') {
      handleEqual();
    } else if (key === 'Escape' || key === 'c' || key === 'C') {
      clear();
    } else if (key === 'Backspace') {
      handleBackspace();
    }
  };

  window.addEventListener('keydown', handleKeyPress);
  return () => window.removeEventListener('keydown', handleKeyPress);
}, [display, operation, previousValue, waitingForNewValue]);

このコードを追加すると、キーボードでも計算機を操作できるようになります。

ユーザビリティの向上を図ることができます。

発展課題

基本機能ができたら、以下の機能を追加してみましょう。

追加できる機能をご紹介します。

  • 科学計算機能: sin, cos, tan, log, sqrt などの関数を追加
  • メモリ機能: M+, M-, MR, MC の実装
  • 履歴の詳細: 計算過程の保存と再利用機能
  • テーマ切り替え: ダーク/ライトモードの切り替え

これらの機能を実装することで、より高度な計算機アプリになります。

アプリ5: 画像ギャラリー(所要時間: 4〜5時間)

5番目は、画像を扱う本格的なギャラリーアプリです。

学習目標

このアプリで習得できる概念をご紹介します。

  • ファイル操作: 画像アップロードとプレビューを学ぶ
  • レスポンシブデザイン: 画面サイズに応じたレイアウト
  • モーダル表示: 画像の拡大表示機能
  • フィルタリング: カテゴリやタグでの絞り込み機能
  • ローカルストレージ: データの永続化を実装

基本実装

まず、画像ギャラリーの基本機能を作ってみましょう。

import React, { useState, useEffect } from 'react';
import './ImageGallery.css';

function ImageGallery() {
  const [images, setImages] = useState([]);
  const [selectedImage, setSelectedImage] = useState(null);
  const [category, setCategory] = useState('all');
  const [searchTerm, setSearchTerm] = useState('');
  const [viewMode, setViewMode] = useState('grid'); // grid or list

  // ローカルストレージからデータを読み込み
  useEffect(() => {
    const savedImages = localStorage.getItem('galleryImages');
    if (savedImages) {
      setImages(JSON.parse(savedImages));
    } else {
      // サンプル画像データ
      const sampleImages = [
        {
          id: 1,
          url: 'https://picsum.photos/400/300?random=1',
          title: '美しい風景',
          category: 'nature',
          tags: ['風景', '自然', '山'],
          uploadDate: new Date().toISOString()
        },
        {
          id: 2,
          url: 'https://picsum.photos/400/300?random=2',
          title: '都市の夜景',
          category: 'city',
          tags: ['夜景', '都市', 'ライト'],
          uploadDate: new Date().toISOString()
        }
      ];
      setImages(sampleImages);
    }
  }, []);

  // ローカルストレージに保存
  useEffect(() => {
    localStorage.setItem('galleryImages', JSON.stringify(images));
  }, [images]);

  // 画像アップロード
  const handleImageUpload = (event) => {
    const files = Array.from(event.target.files);
    
    files.forEach(file => {
      if (file.type.startsWith('image/')) {
        const reader = new FileReader();
        reader.onload = (e) => {
          const newImage = {
            id: Date.now() + Math.random(),
            url: e.target.result,
            title: file.name.split('.')[0],
            category: 'uncategorized',
            tags: [],
            uploadDate: new Date().toISOString(),
            file: file
          };
          setImages(prev => [newImage, ...prev]);
        };
        reader.readAsDataURL(file);
      }
    });
  };

  // 画像削除
  const deleteImage = (id) => {
    setImages(images.filter(img => img.id !== id));
    if (selectedImage && selectedImage.id === id) {
      setSelectedImage(null);
    }
  };

  // 画像情報編集
  const editImage = (id, updates) => {
    setImages(images.map(img => 
      img.id === id ? { ...img, ...updates } : img
    ));
  };

  // フィルタリング
  const filteredImages = images.filter(image => {
    const matchesCategory = category === 'all' || image.category === category;
    const matchesSearch = image.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
                         image.tags.some(tag => tag.toLowerCase().includes(searchTerm.toLowerCase()));
    return matchesCategory && matchesSearch;
  });

  // カテゴリ一覧取得
  const categories = ['all', ...new Set(images.map(img => img.category))];

  return (
    <div className="image-gallery">
      <header className="gallery-header">
        <h1>画像ギャラリー</h1>
        
        {/* アップロードボタン */}
        <div className="upload-section">
          <input
            type="file"
            id="image-upload"
            multiple
            accept="image/*"
            onChange={handleImageUpload}
            style={{ display: 'none' }}
          />
          <label htmlFor="image-upload" className="upload-button">
            📷 画像をアップロード
          </label>
        </div>
      </header>

      {/* フィルターとコントロール */}
      <div className="controls">
        <div className="filters">
          <select 
            value={category} 
            onChange={(e) => setCategory(e.target.value)}
            className="category-filter"
          >
            {categories.map(cat => (
              <option key={cat} value={cat}>
                {cat === 'all' ? 'すべて' : cat}
              </option>
            ))}
          </select>

          <input
            type="text"
            value={searchTerm}
            onChange={(e) => setSearchTerm(e.target.value)}
            placeholder="タイトルやタグで検索..."
            className="search-input"
          />
        </div>

        <div className="view-controls">
          <button 
            onClick={() => setViewMode('grid')}
            className={viewMode === 'grid' ? 'active' : ''}
          >
            📷 グリッド
          </button>
          <button 
            onClick={() => setViewMode('list')}
            className={viewMode === 'list' ? 'active' : ''}
          >
            📋 リスト
          </button>
        </div>
      </div>

      {/* 画像表示 */}
      <div className={`image-container ${viewMode}`}>
        {filteredImages.length === 0 ? (
          <div className="empty-gallery">
            <p>画像がありません</p>
            <p>画像をアップロードして始めましょう!</p>
          </div>
        ) : (
          filteredImages.map(image => (
            <ImageCard
              key={image.id}
              image={image}
              onSelect={setSelectedImage}
              onDelete={deleteImage}
              onEdit={editImage}
              viewMode={viewMode}
            />
          ))
        )}
      </div>

      {/* モーダル表示 */}
      {selectedImage && (
        <ImageModal
          image={selectedImage}
          onClose={() => setSelectedImage(null)}
          onEdit={editImage}
          onDelete={deleteImage}
        />
      )}

      {/* 統計情報 */}
      <div className="gallery-stats">
        <p>総画像数: {images.length} | 表示中: {filteredImages.length}</p>
      </div>
    </div>
  );
}

このコードでは、FileReaderを使って画像ファイルを読み込んでいます。 localStorageを使ってデータを永続化しています。

フィルタリング機能や検索機能も実装しています。

次に、個別の画像カードとモーダルのコンポーネントを見てみましょう。

// 個別の画像カードコンポーネント
function ImageCard({ image, onSelect, onDelete, onEdit, viewMode }) {
  const [isEditing, setIsEditing] = useState(false);
  const [editData, setEditData] = useState({
    title: image.title,
    category: image.category,
    tags: image.tags.join(', ')
  });

  const handleSave = () => {
    onEdit(image.id, {
      title: editData.title,
      category: editData.category,
      tags: editData.tags.split(',').map(tag => tag.trim()).filter(tag => tag)
    });
    setIsEditing(false);
  };

  if (viewMode === 'list') {
    return (
      <div className="image-item-list">
        <img 
          src={image.url} 
          alt={image.title}
          onClick={() => onSelect(image)}
          className="list-thumbnail"
        />
        <div className="list-info">
          <h3>{image.title}</h3>
          <p>カテゴリ: {image.category}</p>
          <p>タグ: {image.tags.join(', ')}</p>
          <p>アップロード日: {new Date(image.uploadDate).toLocaleDateString()}</p>
        </div>
        <div className="list-actions">
          <button onClick={() => setIsEditing(true)}>編集</button>
          <button onClick={() => onDelete(image.id)}>削除</button>
        </div>
      </div>
    );
  }

  return (
    <div className="image-item">
      {isEditing ? (
        <div className="edit-form">
          <input
            value={editData.title}
            onChange={(e) => setEditData({...editData, title: e.target.value})}
            placeholder="タイトル"
          />
          <input
            value={editData.category}
            onChange={(e) => setEditData({...editData, category: e.target.value})}
            placeholder="カテゴリ"
          />
          <input
            value={editData.tags}
            onChange={(e) => setEditData({...editData, tags: e.target.value})}
            placeholder="タグ(カンマ区切り)"
          />
          <div className="edit-buttons">
            <button onClick={handleSave}>保存</button>
            <button onClick={() => setIsEditing(false)}>キャンセル</button>
          </div>
        </div>
      ) : (
        <>
          <img 
            src={image.url} 
            alt={image.title}
            onClick={() => onSelect(image)}
            className="gallery-image"
          />
          <div className="image-overlay">
            <h3>{image.title}</h3>
            <div className="image-actions">
              <button onClick={() => setIsEditing(true)}>編集</button>
              <button onClick={() => onDelete(image.id)}>削除</button>
            </div>
          </div>
        </>
      )}
    </div>
  );
}

// モーダルコンポーネント
function ImageModal({ image, onClose, onEdit, onDelete }) {
  const [isEditing, setIsEditing] = useState(false);
  const [editData, setEditData] = useState({
    title: image.title,
    category: image.category,
    tags: image.tags.join(', ')
  });

  const handleSave = () => {
    onEdit(image.id, {
      title: editData.title,
      category: editData.category,
      tags: editData.tags.split(',').map(tag => tag.trim()).filter(tag => tag)
    });
    setIsEditing(false);
  };

  const handleDelete = () => {
    if (window.confirm('この画像を削除しますか?')) {
      onDelete(image.id);
      onClose();
    }
  };

  return (
    <div className="modal-overlay" onClick={onClose}>
      <div className="modal-content" onClick={(e) => e.stopPropagation()}>
        <button className="modal-close" onClick={onClose}>×</button>
        
        <img src={image.url} alt={image.title} className="modal-image" />
        
        <div className="modal-info">
          {isEditing ? (
            <div className="modal-edit-form">
              <input
                value={editData.title}
                onChange={(e) => setEditData({...editData, title: e.target.value})}
                placeholder="タイトル"
              />
              <input
                value={editData.category}
                onChange={(e) => setEditData({...editData, category: e.target.value})}
                placeholder="カテゴリ"
              />
              <input
                value={editData.tags}
                onChange={(e) => setEditData({...editData, tags: e.target.value})}
                placeholder="タグ(カンマ区切り)"
              />
              <div className="modal-edit-buttons">
                <button onClick={handleSave}>保存</button>
                <button onClick={() => setIsEditing(false)}>キャンセル</button>
              </div>
            </div>
          ) : (
            <div className="modal-details">
              <h2>{image.title}</h2>
              <p><strong>カテゴリ:</strong> {image.category}</p>
              <p><strong>タグ:</strong> {image.tags.join(', ')}</p>
              <p><strong>アップロード日:</strong> {new Date(image.uploadDate).toLocaleDateString()}</p>
              
              <div className="modal-actions">
                <button onClick={() => setIsEditing(true)}>編集</button>
                <button onClick={handleDelete} className="delete-btn">削除</button>
              </div>
            </div>
          )}
        </div>
      </div>
    </div>
  );
}

export default ImageGallery;

このコードでは、コンポーネントの分割と props の効率的な渡し方を学べます。

モーダルの実装方法や、イベントの伝播制御(stopPropagation)も理解できます。

発展課題

基本機能ができたら、以下の機能を追加してみましょう。

追加できる機能をご紹介します。

  • ドラッグ&ドロップ: 画像のドラッグ&ドロップアップロード
  • 画像加工: フィルター、回転、リサイズ機能
  • スライドショー: 自動再生機能付きスライドショー
  • ソート機能: 日付、名前、サイズでのソート
  • エクスポート: 選択した画像のダウンロード機能

これらの機能を実装することで、プロレベルの画像ギャラリーアプリになります。

学習の進め方とコツ

効果的にスキルアップするための学習戦略を紹介します。

段階的学習のロードマップ

おすすめの学習スケジュールをご紹介します。

週1: カウンターアプリ
  ↓ useStateとイベント処理の基本を習得
週2-3: TODOリスト
  ↓ 配列管理とCRUD操作をマスター
週4-5: 天気アプリ
  ↓ API呼び出しと非同期処理を理解
週6-7: 計算機
  ↓ 複雑な状態管理と条件分岐を学習
週8-10: 画像ギャラリー
  ↓ ファイル操作とモーダル表示を実装

このスケジュールで進めれば、無理なくスキルアップできます。

効果的な学習方法

学習を成功させるためのコツをご紹介します。

1. 完璧を求めない

最初は動くコードを作ることが大切です。

// 最初は動けばOK
function SimpleCounter() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <p>{count}</p>
      <button onClick={() => setCount(count + 1)}>+</button>
    </div>
  );
}

基本が理解できたら、後から改善していきましょう。

// 後から改善していく
function ImprovedCounter() {
  const [count, setCount] = useState(0);
  
  const increment = useCallback(() => {
    setCount(prev => prev + 1);
  }, []);
  
  return (
    <div className="counter">
      <p className="count-display">{count}</p>
      <button onClick={increment} className="increment-btn">
        Increment
      </button>
    </div>
  );
}

段階的に改善することで、理解が深まります。

2. コードを書いて実験する

疑問に思ったことは、実際にコードを書いて確認しましょう。

// 疑問に思ったことは実際に試してみる
function Experiment() {
  const [state, setState] = useState('initial');
  
  console.log('Component rendered with state:', state);
  
  const handleClick = () => {
    console.log('Before setState:', state);
    setState('updated');
    console.log('After setState:', state); // まだ更新されていない
  };
  
  return <button onClick={handleClick}>Test State Update</button>;
}

実際に動かしてみることで、理解が深まります。

3. エラーを恐れない

エラーから学ぶ姿勢が成長につながります。

// エラーから学ぶ姿勢が重要
function LearningFromErrors() {
  const [items, setItems] = useState([]);
  
  // このコードはエラーになる(なぜ?)
  const addItem = () => {
    items.push('new item'); // 直接変更はNG
    setItems(items);
  };
  
  // 正しい方法
  const addItemCorrectly = () => {
    setItems(prev => [...prev, 'new item']);
  };
  
  return (
    <div>
      <button onClick={addItem}>Wrong Way</button>
      <button onClick={addItemCorrectly}>Right Way</button>
    </div>
  );
}

エラーが出たときは、原因を調べて理解することが重要です。

スキル向上のための継続的な取り組み

長期的にスキルを伸ばすための方法をご紹介します。

1. 日々のコーディング習慣

継続的な学習のポイントをご紹介します。

  • 毎日30分でもコードを書く習慣をつける
  • 小さな機能でも積極的に実装してみる
  • 学んだことをメモやブログに記録する

2. コミュニティとの交流

他の開発者との交流も重要です。

  • GitHubでコードを公開して、フィードバックをもらう
  • 技術ブログやSNSで学習内容を共有する
  • オンラインコミュニティに参加して質問や議論をする

3. 段階的な挑戦

スキルアップのステップをご紹介します。

  • 基本機能 → 発展機能 → 独自機能の順で実装する
  • 他の人のコードを読んで、学習の参考にする
  • より高度なライブラリやツールに挑戦する

継続的な学習と実践により、確実にスキルが向上します。

まとめ

React入門者が作るべき5つの簡単アプリについて、詳細な実装例とともに解説しました。

学習効果の高いアプリ選択

今回紹介したアプリの学習効果をまとめます。

  • カウンターアプリ: useState の基本理解ができる
  • TODOリスト: 配列操作とCRUD操作を習得できる
  • 天気アプリ: API呼び出しと非同期処理を学べる
  • 計算機: 複雑な状態管理とロジックを理解できる
  • 画像ギャラリー: ファイル操作とUI設計を身につけられる

重要な学習ポイント

成功のためのポイントをまとめます。

  1. 段階的なスキルアップ: 簡単なものから複雑なものへ進む
  2. 実践重視: 手を動かして学習する
  3. エラーから学習: 失敗を恐れずチャレンジする
  4. 継続的な改善: 機能追加とコード改善を繰り返す

次のステップ

これらのアプリが完成したら、以下に挑戦してみてください。

さらなるスキルアップのための課題をご紹介します。

  • React Router: ページ遷移のあるアプリを作る
  • 状態管理ライブラリ: Redux や Zustand を活用する
  • TypeScript: 型安全なReact開発を身につける
  • テスト: Jest を使ったテストコード作成を学ぶ
  • デプロイ: Netlify や Vercel での公開を体験する

React学習は継続が最も重要です。 毎日少しずつでもコードを書き続けることで、確実にスキルが向上します。

最初は完璧を目指さず、動くものを作ることから始めてください。 徐々に品質を高めていく姿勢を大切にしましょう。

あなたのReact学習の成功を心から応援しています! ぜひ、今日から実践を始めてみてください。

関連記事