1
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ツイートアプリを作成しながら学習 1-2【Django + React】

Last updated at Posted at 2025-01-29

目次

こんな感じで進めていきます。

1.プロフィール画像の管理

2.ツイート投稿機能の実装

3.フロントエンドをセットアップ

プロフィール画像の管理

プロフィール画像をどう管理するか、迷いました。

  • カスタムUserモデルに直接フィールド追加
    すでに users/models.py の Userクラスは AbstractUser を継承している。ここに profile_imageやbio(自己紹介)などを足すだけでもOK。

  • Profileを別モデルにする

結論
以下を考え、startapp profielsでProfileを別モデルにしました。

Userテーブルを最小限にしておける

  • カスタムUserモデルをいじり過ぎると、将来「外部認証(Googleログインや社内SSO)を使う」「ユーザーデータを別サービスと同期する」といった要件が出たとき面倒になりそう
  • たとえば「ユーザーのIDや認証情報は他システムに合わせたいけど、プロフィール画像や自己紹介はこっちのDBにだけ置く」といったとき、Userテーブルに追加要素が多いと分割が難しくなりそう

企業や大規模プロダクトで拡張しやすい

  • プロフィール情報を大幅に拡張したくなるケースが多いきがする。たとえば「経歴」「SNSリンク」「複数の画像管理」「誕生日」「ステータス」「設定情報」など
  • Userテーブルにどんどんフィールドを増やすと、「ユーザー認証」に関係ないカラムが大量に並ぶ状態になる

将来、Userを別サービスと連携・交換するときのリスクが減る

  • もし大きなサービスや社内システムと連携するとき、「Userは社内ディレクトリサービスに任せて、プロフィール部分だけ自前で管理する」みたいなやり方になることもありそう。
  • そういう場面でUserテーブルをフル改造していると、移行が大変

モジュールの責務を分けやすい

  • チームによっては「認証まわりを扱うチーム」と「ユーザー詳細やSNS的プロフィールを扱うチーム」が別れたりする
  • Userテーブルを「認証用モジュール」として分離し、Profileテーブルを「拡張モジュール」として運用すると、責務分割しやすい

:computer: startapp で "profiles" アプリを新規作成

backend/ python manage.py startapp profiles

:computer: settings.py でアプリを認識させる

# backend/settings.py

INSTALLED_APPS = [
    # Django標準アプリ
    "django.contrib.admin",
    "django.contrib.auth",
    ...

    # サードパーティ
    "rest_framework",
    "corsheaders",
    "rest_framework_simplejwt",

    # 自作アプリ
    "users",        # カスタムUser
    "profiles",     # 今回追加
]

:computer: Profileモデルを作る

# backend/profiles/models.py

import os
from django.db import models
from django.conf import settings
from django.contrib.auth import get_user_model

def upload_profile_image_path(instance, filename):
    """
    プロフィール画像を user_<user_id>/profile.jpg の形で保存する
    ファイル名は必ず 'profile.jpg' に固定
    """
    return f"profile_images/user_{instance.user.id}/profile.jpg"

User = get_user_model()
# カスタムUser (users.User) を動的に取得

class Profile(models.Model):
    user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile')
    profile_image = models.ImageField(upload_to=upload_profile_image_path, default='default_profile.png')
    bio = models.TextField(blank=True)

    def __str__(self):
        return self.user.username

:pencil2: 解説

  • OneToOneField(User, ...) でユーザーと1対1の関係
  • upload_profile_image_path で user_{instance.user.id}/profile.jpg を返すことで、"profile_images/user_5/profile.jpg" のように必ず同じ名前に固定
  • ユーザーIDが5なら "user_5/profile.jpg" に上書きされる
  • これにより複数ファイルが溜まらない&常に同じパスでアクセスできる
  • default='default_profile.png' は、まだカスタム画像がない場合に使うデフォルト画像(プロジェクトのmedia/default_profile.pngに置いておくと便利)

:computer: Pillowをインストール&マイグレーション

backend/ pip install Pillow
backend/ python manage.py makemigrations
backend/ python manage.py migrate

:book: もしpip知らない方はこちらに超簡単にまとめています。

:computer: signalsの作成

# profiles/signals.py

import os
from django.db.models.signals import post_save, pre_save
from django.dispatch import receiver
from django.conf import settings
from django.contrib.auth import get_user_model
from .models import Profile

User = get_user_model()

@receiver(post_save, sender=User)
def create_profile(sender, instance, created, **kwargs):
    """
    ユーザー作成時に自動でProfileを作る
    """
    if created:
        Profile.objects.create(user=instance)
        print(f"Profile created for user: {instance.username}")

@receiver(post_save, sender=User)
def save_profile(sender, instance, **kwargs):
    """
    ユーザー更新時にもProfileをsaveする(念のため)
    """
    if hasattr(instance, 'profile'):
        instance.profile.save()

@receiver(pre_save, sender=Profile)
def delete_old_profile_image(sender, instance, **kwargs):
    """
    Profileが更新される前に、古い画像ファイルを削除する
    """
    if not instance.pk:
        # 新規作成の場合は古いファイルはまだない
        return

    try:
        old_profile = Profile.objects.get(pk=instance.pk)
    except Profile.DoesNotExist:
        return

    old_image = old_profile.profile_image
    new_image = instance.profile_image

    # もし古いファイルが存在して、かつ新しいファイルと違い、
    # なおかつ old_image がデフォルト画像でなければ削除する
    if (
        old_image 
        and old_image != new_image 
        and old_image.name != "default_profile.png"
    ):
        old_path = os.path.join(settings.MEDIA_ROOT, old_image.name)
        if os.path.exists(old_path):
            os.remove(old_path)
            print(f"Deleted old profile image: {old_path}")

:pencil2: 解説

  • post_save で「Userが作られたらProfileも自動作成」
  • pre_save で「Profileが上書き保存されるとき、前の画像ファイルを削除」
  • instance はこれから更新されるProfile、old_profile は更新前のProfile
  • 古いファイル名と新しいファイル名が違う場合だけ削除
  • default_profile.png はそもそも共通のデフォルトだから消さない

:book: もしSignal知らない方はこちら

:computer: signalsを読み込むためのapps.py設定

Djangoは、 signals.pyをどこかでインポートしないと実行してくれません。
profiles/apps.py で ready() でインポートします。

# profiles/apps.py

from django.apps import AppConfig

class ProfilesConfig(AppConfig):
    default_auto_field = 'django.db.models.BigAutoField'
    name = 'profiles'

    def ready(self):
        import profiles.signals
        # この一行で signals.py が読み込まれ、デコレータ@receiverが有効になる

:computer: シリアライザ用意

# profiles/serializers.py

from rest_framework import serializers
from .models import Profile

class ProfileSerializer(serializers.ModelSerializer):
    class Meta:
        model = Profile
        fields = ["id", "profile_image", "bio"]
        read_only_fields = ["id"]

:pencil2: 解説

  • これで profile_image と bio を編集できるシンプルなシリアライザ
  • 画像をmultipart/form-dataで送ればアップロードできる

:computer: ビューを用意 (Profile更新API)

# profiles/views.py

from rest_framework import generics, permissions
from .models import Profile
from .serializers import ProfileSerializer

class MyProfileView(generics.RetrieveUpdateAPIView):
    permission_classes = [permissions.IsAuthenticated]
    serializer_class = ProfileSerializer

    def get_object(self):
        # ログイン中のユーザーの Profile を返す
        return self.request.user.profile

:pencil2: 解説

  • RetrieveUpdateAPIView → GETで取得、PATCH/PUTで更新を自動実装してくれる
  • get_object() で「自分の Profile を取得するように」カスタマイズ

:computer: URLを設定

# profiles/urls.py

from django.urls import path
from .views import MyProfileView

urlpatterns = [
    path("profile/me/", MyProfileView.as_view(), name="my_profile"),
]

# backend/urls.py

from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path("admin/", admin.site.urls),
    path("api/", include("profiles.urls")),
    # その他いろいろ ...
]

:computer: 動作確認

  • python manage.py runserver 0.0.0.0:8000
  • ユーザー登録 → 自動的にProfile生成されたはず
  • GET api/profile/me/ → {"id": 1, "profile_image": "default_profile.png", "bio": ""} とかが返る
  • もし別の画像にアップロードし直したら、古い画像を削除( pre_save )してくれるはず

:computer: Git commit


git add .
git commit -m "feat: create 'profiles' app with OneToOne Profile model 
- add signals to auto-create profile on user creation
git push

ツイート投稿機能の実装

:computer: startapp tweetsでツイートアプリ(tweets)を作る

backend/ python manage.py startapp tweets

:computer: INSTALLED_APPS に "tweets" を追加:

backend/backend/settings.py

INSTALLED_APPS = [
    ...,
    "tweets",
]

:computer: Tweetモデル設計

# tweets/models.py

from django.db import models
from django.contrib.auth import get_user_model

# Create your models here.

User = get_user_model()

class Tweet(models.Model):
    user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="tweets")
    content = models.CharField(max_length=280)
    created_at = models.DateTimeField(auto_now_add=True)

    def __str__(self):
        return f"{self.user.username}: {self.content[:30]}"

:pencil2: 解説

  • User = get_user_model()でカスタムUserを参照
  • CharField(max_length=280) → 文字数制限
  • auto_now_add=True → モデルが作られたタイミングで自動に日時を入れてくれる

:computer: マイグレーション


backend/ python manage.py makemigrations
backend/ python manage.py migrate

:computer: シリアライザでJSON化

# tweets/serializers.py

from rest_framework import serializers
from .models import Tweet

class TweetSerializer(serializers.ModelSerializer):
    # user情報も返したい場合、NestedSerializerかStringRelatedFieldなど使う
    username = serializers.ReadOnlyField(source='user.username')

    class Meta:
        model = Tweet
        fields = ["id", "username", "content", "created_at"]
        read_only_fields = ["id", "created_at", "username"]

:pencil2: 解説

  • username = serializers.ReadOnlyField(source='user.username')で
  • userフィールドそのものを返す代わりに、ユーザー名だけを追加

:computer: Viewの設定

# backend/tweets/views.py

from rest_framework import viewsets, permissions
from.models import Tweet
from.serializers import TweetSerializer

# Create your views here.
class TweetViewSet(viewsets.ReadOnlyModelViewSet):
    queryset = Tweet.objects.all().order_by("-created_at")
    serializer_class = TweetSerializer
    permission_classes = [permissions.IsAuthenticatedOrReadOnly]

    def perform_create(self,serializer):
        # 新規作成時に "user" を自動的にセット
        serializer.save(user=self.request.user)

:pencil2: 解説

  • ModelViewSet → 一覧(GET), 詳細(GET), 作成(POST), 更新(PUT/PATCH), 削除(DELETE)が一通りまとまってる
  • permission_classes で「未認証は読み込みだけ可、投稿は認証必須」を設定
  • perform_create()で user = self.request.user をセットし、誰が投稿したか自動的に保存

:computer: URL設定

# backend/tweets/urls.py

from rest_framework.routers import DefaultRouter
from.views import TweetViewSet

router = DefaultRouter()
router.register(r'tweets', TweetViewSet, basename='tweets')

urlpatterns = router.urls

:pencil2: 解説

  • RouterでViewSet を登録すると、/tweets/, /tweets// が自動生成される
    :book: もしRouter知らない方はこちら

:computer: 最後にプロジェクト全体URLを設定

# backend/backend/urls.py

from django.contrib import admin
from django.urls import path, include
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView

urlpatterns = [
    path("admin/", admin.site.urls),
    path("api/", include("users.urls")),
    path("api/", include("profiles.urls")),
    path("api/", include(("tweets.urls"))),
    path("api/token/", TokenObtainPairView.as_view(), name="token_obtain_pair"),
    path("api/token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
]

:computer: コミット

フロントエンド(React)をセットアップ

:computer: Reactアプリの新規作成

ルートプロジェクト配下にfrontendというフォルダを用意し、
そこでcreate-react-appを使います。


tweetapp_ver1/
  ├─ backend/    ← Djangoサーバ
  ├─ frontend/   ← Reactフロント
  ├─ .devcontainer/
  ├─ ...


tweetapp_ver1/ mkdir forntend
tweetapp_ver1/ cd frontend
frintend/ npx create-react-app . 
frintend/ npm start

うまくいかない場合、あまり推奨ではないですがこれ試してみる。


tweetapp_ver1/ mkdir forntend
tweetapp_ver1/ cd frontend
frintend/ npx create-react-app . --legacy-peer-deps
frintend/ npm install react@18 react-dom@18
frintend/ npm install web-vitals
frintend/ npm start

:rocket:
ここまで来たら

# backend/backend/settings.py

CORS_ALLOW_ALL_ORIGINS = False
CORS_ALLOWED_ORIGINS = [
    "http://localhost:3000", ←ここに行って確認
    "http://localhost:3001",
]

↓このようなのがでたら成功です!

スクリーンショット 2025-01-27 174727.png

:computer: Tailwind CSSの導入

「デザインにこだわりたい」とき、Tailwindは非常に便利。
(一応手順を書いておきます)


frontend/ npm install -D tailwindcss postcss autoprefixer
frontend/ npx tailwindcss init -p

:pencil2: 解説

  • -D は開発依存(devDependencies)としてインストールするオプション
  • -p フラグをつけるとpostcss.config.jsも生成してくれる
  • これで**tailwind.config.js**postcss.config.jsファイルができる

:computer: tailwind.config.js を編集

/* frontend/tailwind.config.js */

/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    "./src/**/*.{js,jsx,ts,tsx}",  // Reactプロジェクトのsrc内の全ファイルをTailwind適用対象に
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}

:computer: CSSにTailwindのスタイルを読み込む

src/index.cssの先頭に以下3行を追加:

// frontend/src/index.css

@tailwind base;
@tailwind components;
@tailwind utilities;

:computer: コンポーネント設計

ページ単位の構造

  • App.js: 全体のルーティング制御
  • pages/: 各ページ(Home, Login, Register, Profile, TweetList, など)
  • components/: 複数ページで使いまわすUI部品(ヘッダ、フッタ、フォームなど)
frontend/
  ├─ src/
  │   ├─ pages/
  │   │   ├─ Home.jsx
  │   │   ├─ Login.jsx
  │   │   ├─ Register.jsx
  │   │   ├─ TweetList.jsx
  │   │   ├─ Profile.jsx
  │   │   └─ ...
  │   ├─ components/
  │   │   ├─ Header.jsx
  │   │   ├─ Footer.jsx
  │   │   └─ ...
  │   ├─ App.js
  │   ├─ index.js
  │   └─ index.css
  └─ package.json

:computer: React Router セットアップ

frontend/ npm install react-router-dom

App.js例:

// frontend/src/App.js
import { BrowserRouter as Router, Routes, Route } from "react-router-dom";
import Home from "./pages/Home";
import Login from "./pages/Login";
import Register from "./pages/Register";
import Profile from "./pages/Profile";
import TweetList from "./pages/TweetList";

function App() {
  return (
    <Router>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/login" element={<Login />} />
        <Route path="/register" element={<Register />} />
        <Route path="/profile" element={<Profile />} />
        <Route path="/tweets" element={<TweetList />} />
      </Routes>
    </Router>
  );
}

export default App;

:computer: AuthContext.jsx の実装 (API.getを使用)

ここから進むにこちら軽く理解されるといいと思います。

概要

AuthContext.jsxは、Reactアプリ全体でユーザーの認証状態やユーザー情報を管理し、どのコンポーネントからでもアクセスできるようにするためのコンテキストです。これにより、ログイン状態の管理、ユーザー情報の取得、ログイン・ログアウト機能の提供がスムーズに行えます。

APIモジュールの設定

  • バックエンドのURL構成
    backend/urls.pyで、APIエンドポイントは全て/api/配下に配置されています。

  • ユーザー登録: POST /api/register/

  • プロフィール取得・更新: GET/PATCH /api/profile/me/

  • JWT取得: POST /api/token/

  • JWTリフレッシュ: POST /api/token/refresh/

  • ツイート関連: GET/POST /api/tweets/など

:computer: APIモジュールの設定 (api.js)

まず、axiosをカスタマイズしたAPIモジュールを作成します。これにより、ベースURLやヘッダーの設定を一元管理できます。


// frontend/src/api/index.js

import axios from 'axios';

// APIベースURLの設定
const API = axios.create({
    baseURL: 'http://localhost:8000/api/',
});

// リクエストインターセプターでAuthorizationヘッダーを自動設定
API.interceptors.request.use(
    (config) => {
        const token = localStorage.getItem('access_token');
        if (token) {
            config.headers['Authorization'] = `Bearer ${token}`;
        }
        return config;
    },
    (error) => {
        return Promise.reject(error);
    }
);

//レスポンスインターセプターでトークンリフレッシュを自動化(オプション)
API.interceptors.request.use(
    (response) => response,
    async (error) => {
        const originalRequest = error.config;
        if (
            error.request.data.status === 401 &&
            !originalRequest._retry &&
            localStorage.getItem('refresh_token')
        ) {
            originalRequest._retry = true;
            const refreshToken = localStorage.getItem('refresh_token');
            try {
                const res = await axios.post('http://localhost:8000/api/token/refresh/', {refresh: refreshToken});
                const newAccessToken = res.data.access;
                localStorage.setItem('access_token', newAccessToken);
                API.defaults.headers['Authorization'] = `Bearer ${newAccessToken}`;
                originalRequest.headers['Authorization'] = `Bearer ${newAccessToken}`;
                return API(originalRequest);
            } catch (err) {
                console.error('Refresh token expired', err)
                // 必要な場合はログアウト処理ここで
            }
        }
        return Promise.reject(error);
    } 
);

export default API;

:pencil2: 解説

  • ベースURL設定
    axios.createでベースURLを設定し、全てのリクエストがhttp://localhost:8000/api/に向かうようにします。必要に応じて環境変数を使って本番環境と開発環境でURLを切り替えることも可能です。

  • リクエストインターセプター
    すべてのリクエストに対して、localStorageからaccess_tokenを取得し、存在すればAuthorizationヘッダーに付与します。

  • レスポンスインターセプター
    401 Unauthorizedエラーが返ってきた場合、リフレッシュトークンを使ってアクセストークンを更新し、再試行します。これはオプションですが、ユーザー体験を向上させるために役立ちます。

:computer: AuthContext.jsxの実装

次に、AuthContext.jsxを実装します。これにより、アプリ全体で認証状態やユーザー情報を共有できます。

// frontnd/src/contexts/AuthContext.jsx

import { createContext, useState, useEffect, useCallback } from "react";
import API from "../api";
import { useNavigate } from 'react-router-dom';

export const AuthContext = createContext();

export function AuthProvider({ children }) {
    const navigate =useNavigate();
    const [user, setUser] = useState(null);
    const [loading, setLoading] = useState(true);

    // ログアウト関数を useCallback でラップ 
    const logout = useCallback(() => {
        localStorage.removeItem('access_token');
        localStorage.removeItem('refresh_token');
        setUser(null);
        navigate('/login');
    }, [navigate]);


    //アプリ起動時にトークンからユーザー情報を取得
    useEffect(() => {
        const fetchUser = async () => {
            const token = localStorage.getItem('access_token');
            if (token) {
                try {
                    //プロフィール取得
                    const res = await API.get('profile/me/');
                    setUser(res.data);
                } catch (err) {
                    console.error('Failed to fetch user', err);
                    // トークンが無効な場合はログアウト
                    logout();
                }
            }
            setLoading(false);
        };
        fetchUser();
    }, [logout]);

   
    const login = async (username, password) => {
        try {
          const response = await API.post("token/", { username, password });
      
          if (response.status === 200) {
            const { access, refresh } = response.data;
            localStorage.setItem("access_token", access);
            localStorage.setItem("refresh_token", refresh);
            
            console.log("Login successful!", response.data);
            return response.data;
          } else {
            console.error("Login failed with status", response.status);
            throw new Error(`Login failed with status ${response.status}`);
          }
        } catch (err) {
          if (err.response) {
            // サーバーがエラーレスポンスを返した場合
            console.error("Login failed", err.response.status, err.response.data);
            throw new Error(`Login failed with status ${err.response.status}: ${err.response.data.detail || "Unknown error"}`);
          } else if (err.request) {
            // サーバーが応答しない場合
            console.error("Login failed, no response:", err.message || err);
            throw new Error("Login failed: No response from server");
          } else {
            // 予期しないエラー
            console.error("Login failed, internal error", err.message || err);
            throw new Error(`Login failed, internal error: ${err.message}`);
          }
        }
      };    const register = async (username, email, password) => {
        try {
            const res = await API.post('register/', { username, email, password});
            console.log('Registration successfull:', res.data)
            // 登録後自動的にログイン
            await login(username, password);
        } catch (err) {
            console.error('Registration failed', err);
            throw err;
        }
    };

    return (
        <AuthContext.Provider value={{ user, loading, login, register, logout }}>
            {children}
        </AuthContext.Provider>
    );

}

:pencil2: 解説

  • コンテキストの作成
    createContextでAuthContextを作成し、AuthProviderでラップすることで、アプリ全体で認証状態を共有します。

  • 状態管理

    • user: 現在ログインしているユーザーの情報を保持します。
    • loading: アプリ起動時の初期ロード状態を管理します。
  • useEffect
    アプリ起動時にaccess_tokenが存在すれば、/profile/me/エンドポイントにリクエストを送り、ユーザー情報を取得します。トークンが無効な場合はログアウトします。

  • login関数
    ユーザー名とパスワードを使って/token/エンドポイントからアクセストークンとリフレッシュトークンを取得し、localStorageに保存します。その後、ユーザー情報を取得してuserステートを更新し、ホームページにリダイレクトします。

  • register関数
    /register/エンドポイントにユーザー情報を送信し、新規ユーザーを作成します。作成後、ログインさせるためにlogin関数を呼び出します。

  • logout関数

    • useCallback: logout関数をメモ化し、依存配列内のnavigateが変わらない限り再生成しません。これにより、コンポーネントの再レンダリング時に不要な関数再生成を防ぎます。
    • localStorageからアクセストークンとリフレッシュトークンを削除します。
    • user状態をnullに設定し、ユーザー情報をクリアします。
    • /loginページにリダイレクトします。

:computer: AuthProvider の設定

AuthProviderをアプリ全体に適用するために、index.jsまたはApp.jsでラップします。

// frontend/src/index.js

import React from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import { AuthProvider } from './contexts/AuthContext';
import { BrowserRouter } from 'react-router-dom';

const container = document.getElementById('root'); // ルート要素を取得

if (container) {
  const root = createRoot(container);
  root.render(
    <React.StrictMode>
      <BrowserRouter>
        <AuthProvider>
          <App />
        </AuthProvider>
      </BrowserRouter>
    </React.StrictMode>,
  );
} else {
  console.error('Could not find root element to mount to!');
}

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

:pencil2: 解説

PrivateRouteは、AuthContextからuserとloadingを取得し、ユーザーが認証済みであれば子コンポーネントを表示し、そうでなければログインページにリダイレクトします。

React Routerの保護されたルート設定

ログインが必要なページを保護するために、PrivateRouteコンポーネントを作成します。

// frontend/src/components/PrivateRoute.jsx

import React, { useContext } from 'react';
import { AuthContext } from '../contexts/AuthContext';
import { Navigate } from 'react-router-dom';

function PrivateRoute({ children }) {
    const { user, loading } = useContext(AuthContext);

    if (loading) return <div>Loading...</div>;

    return user ? children : <Navigate to="/login" />;
}

export default PrivateRoute;

:pencil2: 解説

PrivateRouteは、AuthContextからuserとloadingを取得し、ユーザーが認証済みであれば子コンポーネントを表示し、そうでなければログインページにリダイレクトします。

使用例

// frontend/src/App.js

import { Routes, Route } from "react-router-dom";
import Home from "./pages/Home";
import Login from "./pages/Login";
import Register from "./pages/Register";
import Profile from "./pages/Profile";
import TweetList from "./pages/TweetList";
import PrivateRoute from "./components/PrivateRoute";
import Navbar from "./components/Navbar";

function App() {
  return (
    <>
        <Navbar />
        <Routes>
          <Route path="/" element={<PrivateRoute><Home /></PrivateRoute>} />
          <Route path="/login" element={<Login />} />
          <Route path="/register" element={<Register />} />
          <Route path="/profile" element={<PrivateRoute><Profile /></PrivateRoute>} />
          <Route path="/tweets" element={<PrivateRoute><TweetList /></PrivateRoute>} />
        </Routes>
    </>
  );
}

export default App;

:computer: フロントエンドのユーザー体験を向上させるためにローディング状態の管理

ユーザーの認証状態やデータ取得中にローディングインジケーターを表示すると、ユーザー体験が向上します。


// frontend/src/components/Loading.jsx
import React from 'react';

function Loading() {
  return (
    <div className="flex justify-center items-center h-screen">
      <div className="loader ease-linear rounded-full border-8 border-t-8 border-gray-200 h-32 w-32"></div>
    </div>
  );
}

export default Loading;

/* frontend/index.css */

.loader {
  border-top-color: #3498db;
  animation: spin 1s infinite linear;
}

@keyframes spin {
  to { transform: rotate(360deg); }
}

:computer: ログインを作成・エラーハンドリングとフィードバック

ログイン失敗やAPIエラー時にユーザーにフィードバックを提供することで、アプリの使いやすさが向上します。

// frontend/src/pages/Login.jsx

import React, { useContext, useState } from 'react';
import { AuthContext } from '../contexts/AuthContext';

function Login() {
    const { login } = useContext(AuthContext);
    const [username, setUsername] = useState("");
    const [password, setPassword] = useState("");
    const [error, setError] = useState("");

    const handleSubmit = async (e) => {
        e.preventDefault();
        try {
            await login(username, password);
            // navigate("/") などは AuthContext の login 関数内で実行 
        } catch (err) {
            console.error("Login Error:", error);
            setError(error?.message || "An unknown error occurred"); 
        };
    };

    return (
        <form onSubmit={handleSubmit} className="max-w-sm mx-auto bg-white p-4 shadow">
            <h2 className="text-xl mb-4">Login</h2>
            {error && <p className="text-red-500 mb-2">{error}</p>}
            <div>
                <label>Username:</label>
                <input
                    className='border p-1 w-full'
                    value={username}
                    onChange={(e) => setUsername(e.target.value)}
                    required
                />
            </div>
            <div className='mt-2'>
                <label>Password:</label>
                <input
                    className='border p-1 w-full'
                    type="password"
                    value={password}
                    onChange={(e) => setPassword(e.target.value)}
                    required
                />
            </div>
            <button type='submit' className='mt-4 bg-blue-500 text-white px-4 py-1 rounded w-full'>
                Login
            </button>
        </form>
    );
}

export default Login;

ナビゲーションバーの追加

認証状態に応じて表示内容を変えるナビゲーションバーを作成すると、ユーザーがアプリ内を簡単に移動できます。

// frontend/src/components/Navbar.jsx

import React, { useContext } from 'react';
import { Link } from 'react-router-dom';
import { AuthContext } from '../contexts/AuthContext';

function Navbar() {
    const { user, logout } = useContext(AuthContext);

    return (
        <nav className='bg-blue-600 p-4 text-white flex justify-between'>
            <div>
                <Link to="/" className='mr-4'>Home</Link>
                {user && <Link to="/tweets" className='mr-4'>Tweets</Link>}
            </div>
            <div>
                { user ? (
                    <>
                        <Link to="/profile" className='mr-4'>{user.username}</Link>
                        <button onClick={logout} className='bg-red-500 px-3 py-1 rounded'>Logout</button>
                    </>
                ) : (
                    <>
                        <Link to="/login" className='mr-4'>Login</Link>
                        <Link to="/register" className='bg-green-500 px-3 py-1 rounded'>Register</Link>
                    </>
                )}
            </div>
        </nav>
    );
}

export default Navbar;

:computer: App.lsで使用

// frontend/src/App.js

import { Routes, Route } from "react-router-dom";
import Home from "./pages/Home";
import Login from "./pages/Login";
import Register from "./pages/Register";
import Profile from "./pages/Profile";
import TweetList from "./pages/TweetList";
import PrivateRoute from "./components/PrivateRoute";
import Navbar from "./components/Navbar";

function App() {
  return (
    <>
        <Navbar />
        <Routes>
          <Route path="/" element={<PrivateRoute><Home /></PrivateRoute>} />
          <Route path="/login" element={<Login />} />
          <Route path="/register" element={<Register />} />
          <Route path="/profile" element={<PrivateRoute><Profile /></PrivateRoute>} />
          <Route path="/tweets" element={<PrivateRoute><TweetList /></PrivateRoute>} />
        </Routes>
    </>
  );
}

export default App;

:pencil2: 解説

  • Navbarコンポーネントをアプリのトップに配置し、常に表示されるようにします。
  • 認証状態に応じて、Login, RegisterリンクとProfile, Logoutボタンの表示を切り替えます。

:computer: Git commit

今日はここまで

1
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?