※当サイトではアフィリエイト広告を利用しています。

react

reactで作る todo アプリ 4/4

この記事では、図で示したような ToDo アプリの開発手順について全 4 部構成で詳しく解説しています。React の基礎から応用までを実際のコーディングを通じて学ぶことに焦点を当てています。初心者から中級者向けの内容となっており、ToDo アプリの開発は、実践的な技能の習得とポートフォリオ作成のための具体的な例として最適だと考えています。

todoapp-demo

この第 4 部では、Firebase による認証機能の実装を行います。

想定読者

  • react の初学者から中級者
  • react を触ったことはあるが、なにかポートフォリオ的な物を作りたい人

記事構成

  1. ToDo アプリの概要と開発環境の構築
  2. json-server の導入とレイアウトの定義
  3. ToDo アプリの機能と API リクエストの実装
  4. Firebase による認証機能の実装

Firebase Authentication とは

Firebase Authentication は、Firebase プラットフォームの一部であり、アプリケーションに安全なユーザー認証機能を簡単に追加できるサービスです。このサービスを使用することで、開発者はユーザーのサインインとサインアップのプロセスを簡素化することができます。

具体的な特徴は以下のとおりです

  • 多様な認証方法
    Firebase Authentication は、メールアドレスとパスワードによる認証、Google、Facebook、Twitter、GitHub などのソーシャルメディアアカウントを使用した認証、電話番号を使用した SMS 認証など、多様な認証方法をサポートしています。
  • ユーザー管理
    Firebase コンソールを通じて、登録されたユーザーの管理が容易に行えます。ユーザー情報の確認や、ユーザーの有効・無効化などの操作が可能です。
  • web アプリへの統合
    Firebase SDK を利用することで、Web アプリケーションに簡単に認証機能を統合できます。

他にも様々な特徴がありますので、詳しく確認したい方は公式サイトをご確認ください。

Firebase プロジェクトの作成

ここからは Firebase のアカウントを持っている前提で話を進めます。Firebaseにアクセスします。

1. プロジェクトを追加をクリックする

 

2. プロジェクトに名前をつける

 

3. Google アナリティクスの設定を無効化する

アクセス数などの統計データの取得は必要ないので、アナリティクスの設定を無効化します。

 

4. 続行を押す

 

5. ウェブアプリに Firebase を追加する

左から 3 番目の web をクリックします。

任意の名前でアプリのニックネームをつけて、アプリを登録します。

以下の内容を控えておきます。(コンソール上から再確認可能)

6. 認証方法を設定する

Authentication をクリックして、認証方法を設定します。表示された画面で「始める」を押します。

ログイン方法を設定する

「ログイン方法を設定」をクリックして、ログイン方法を選択します。

今回はメール/パスワードにします。有効にするをチェックして、保存を押します。

Firebase SDK の導入

  1. Firebase SDK をインストールする

    ターミナル上で frontend ディレクトリに移動し、以下のコマンドを実行します。

    npm install firebase​
  2. .env ファイルを作成する
    frontend ディレクトリの直下に.env ファイルを作成します。XXXXXXXXX には Firebase プロジェクトの作成の 5 で控えた値がそれぞれ入ります。

    REACT_APP_FIREBASE_API_KEY=XXXXXXXXX
    REACT_APP_FIREBASE_AUTH_DOMAIN=XXXXXXXXX
    REACT_APP_FIREBASE_PROJECT_ID=XXXXXXXXX
    REACT_APP_FIREBASE_STORAGE_BUCKET=XXXXXXXXX
    REACT_APP_FIREBASE_MESSAGING_SENDER_ID=XXXXXXXXX
    REACT_APP_FIREBASE_APP_ID=XXXXXXXXX​
  3. firebase.ts ファイルを作成する

    src ディレクトリに auth をフォルダを作成し、firebase.ts ファイルを作成します。process.env によって先程作成した.evn の内容を取得しています。

    // firebase.ts
    
    import { initializeApp } from "firebase/app";
    import { getAuth } from "firebase/auth";
    import { getFirestore } from "firebase/firestore";
    import { getStorage } from "firebase/storage";
    
    const firebaseConfig = {
      apiKey: process.env.REACT_APP_FIREBASE_API_KEY,
      authDomain: process.env.REACT_APP_FIREBASE_AUTH_DOMAIN,
      projectId: process.env.REACT_APP_FIREBASE_PROJECT_ID,
      storageBucket: process.env.REACT_APP_FIREBASE_STORAGE_BUCKET,
      messagingSenderId: process.env.REACT_APP_FIREBASE_MESSAGING_SENDER_ID,
      appId: process.env.REACT_APP_FIREBASE_APP_ID,
    };
    
    const app = initializeApp(firebaseConfig);
    export const storage = getStorage(app);
    export const auth = getAuth(app);
    export const db = getFirestore(app);
  4. AuthContext.tsx を作成する

    Firebase 認証の状態を管理するための AuthContext という名前の認証コンテキストを作成します。このコンテキストを使用することで、アプリケーションのどの部分からでも現在のユーザー認証の状態にアクセスできるようになります。

    // AuthContext.tsx
    
    import React, {
      createContext,
      useState,
      useContext,
      useEffect,
    } from "react";
    import { auth } from "./firebase";
    import { User } from "firebase/auth";
    
    // AuthContextの型を定義
    interface AuthContextProps {
      user: User | null;
      isLoading: boolean;
    }
    const AuthContext = createContext<AuthContextProps>({
      user: null,
      isLoading: true,
    });
    
    export const useAuthContext = () => {
      return useContext(AuthContext);
    };
    
    export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
      children,
    }) => {
      const [user, setUser] = useState<User | null>(null);
      const [isLoading, setIsLoading] = useState(true);
    
      useEffect(() => {
        const unsubscribe = auth.onAuthStateChanged((user) => {
          setUser(user);
          setIsLoading(false);
        });
    
        return unsubscribe; // クリーンアップ関数
      }, []);
    
      return (
        <AuthContext.Provider value={{ user, isLoading }}>
          {children}
        </AuthContext.Provider>
      );
    };

認証機能の導入

  1. Loading 処理を追加する

    src ディレクトリ内の components フォルダに Loading.tsx ファイルを作成します。認証が完了するまでローディングを表示するために利用します。

    // Loading.tsx
    import { Box, CircularProgress } from "@mui/material";
    import React from "react";
    
    interface LoadingProps {
      height?: string;
    }
    
    const Loading: React.FC<LoadingProps> = ({ height = "100vh" }) => {
      return (
        <Box
          display="flex"
          justifyContent="center"
          alignItems="center"
          height={height}
        >
          <CircularProgress />
        </Box>
      );
    };
    
    export default Loading;
  2. プライベートルートを作成する

    auth フォルダ内に PrivateRoute.tsx ファイルを作成します。このコンポーネントは、先ほど作成した useAuthContext フックを使用してユーザー情報を取得し、ユーザーが認証されている場合は指定されたページに遷移を許可します。ユーザーが認証されていない場合は、サインインページにリダイレクトさせます。

    // PrivateRoute.tsx
    
    import React from "react";
    import { useAuthContext } from "./AuthContext";
    import Loading from "../components/Loading";
    import { Navigate } from "react-router-dom";
    
    interface PrivateRouteProps {
      element: React.ReactElement;
    }
    
    export const PrivateRoute: React.FC<PrivateRouteProps> = ({ element }) => {
      const { user, isLoading } = useAuthContext();
      if (isLoading) {
        return <Loading />;
      }
    
      if (user) {
        return element;
      }
    
      return <Navigate to="/signin" />;
    };
  3. サインインページを作成する

    auth フォルダ内に Signin.tsx ファイルを作成します。Firebase の Authentication を使用してメールアドレスとパスワードによる認証を実装するため、これらの入力フィールドを含むログインフォームを実装します。

    // Signin.tsx
    import {
      Avatar,
      Box,
      Button,
      Grid,
      Paper,
      Stack,
      Typography,
    } from "@mui/material";
    import { useFormik } from "formik";
    import React, { useLayoutEffect } from "react";
    import * as Yup from "yup";
    
    import LockOutlinedIcon from "@mui/icons-material/LockOutlined";
    import { signInWithEmailAndPassword } from "firebase/auth";
    import { auth } from "./firebase";
    import { Link, useNavigate } from "react-router-dom";
    import { useAuthContext } from "./AuthContext";
    import Loading from "../components/Loading";
    import { FormikTextField } from "../components/FormikTextField";
    
    const Signin: React.FC = () => {
      const { user, isLoading } = useAuthContext();
      const navigate = useNavigate();
      const validationSchema = Yup.object().shape({
        email: Yup.string().required("Email is required"),
        password: Yup.string().required("Password is required"),
      });
    
      const formik = useFormik({
        initialValues: {
          email: "",
          password: "",
        },
        validationSchema,
        onSubmit: async (state) => {
          signInWithEmailAndPassword(auth, state.email, state.password)
            .then((user) => {
              navigate("/");
            })
            .catch((error) => {
              console.error(error);
            });
        },
      });
    
      useLayoutEffect(() => {
        if (user) {
          navigate("/");
        }
      }, [user, navigate]);
    
      if (isLoading) {
        return <Loading />;
      }
    
      return (
        <Grid>
          <Paper
            elevation={3}
            sx={{
              p: 4,
              height: "100%",
              width: "280px",
              m: "20px auto",
            }}
          >
            <Box
              sx={{ width: "100%", display: "flex", justifyContent: "center" }}
            >
              <Avatar>
                <LockOutlinedIcon />
              </Avatar>
            </Box>
            <Typography>Signin</Typography>
            <form noValidate onSubmit={formik.handleSubmit}>
              <Stack spacing={2}>
                <FormikTextField
                  name="email"
                  label="Email *"
                  variant="standard"
                  formik={formik}
                />
                <FormikTextField
                  name="password"
                  label="Password *"
                  variant="standard"
                  type="password"
                  formik={formik}
                />
                <Button fullWidth variant="contained" type="submit">
                  Login
                </Button>
                <Link to={"/signup"}>Create account</Link>
              </Stack>
            </form>
          </Paper>
        </Grid>
      );
    };
    
    export default Signin;
  4. サインアップページを作成する

    auth フォルダ内に Signup.tsx ファイルを作成し、新規ユーザー登録のためのフォームを実装します。このフォームでは、ユーザーがメールアドレスとパスワードを入力してアカウントを作成できるようにします。Firebase Authentication を利用して、入力された情報に基づいて新規ユーザーの登録を行い、成功した場合はログインページにリダイレクトさせます。

    // Signup.tsx
    import {
      Avatar,
      Box,
      Button,
      Grid,
      Paper,
      Stack,
      Typography,
    } from "@mui/material";
    import { useFormik } from "formik";
    import React, { useLayoutEffect } from "react";
    import * as Yup from "yup";
    
    import LockOutlinedIcon from "@mui/icons-material/LockOutlined";
    import { createUserWithEmailAndPassword } from "firebase/auth";
    import { auth } from "./firebase";
    import { Link, useNavigate } from "react-router-dom";
    import { useAuthContext } from "./AuthContext";
    import Loading from "../components/Loading";
    import { FormikTextField } from "../components/FormikTextField";
    
    const Signup: React.FC = () => {
      const { user, isLoading } = useAuthContext();
      const navigate = useNavigate();
      const validationSchema = Yup.object().shape({
        email: Yup.string().required("Email is required"),
        password: Yup.string().required("Password is required"),
      });
    
      const formik = useFormik({
        initialValues: {
          email: "",
          password: "",
        },
        validationSchema,
        onSubmit: async (state) => {
          await createUserWithEmailAndPassword(
            auth,
            state.email,
            state.password
          );
          navigate("/");
        },
      });
    
      useLayoutEffect(() => {
        if (user) {
          navigate("/");
        }
      }, [user, navigate]);
    
      if (isLoading) {
        return <Loading />;
      }
    
      return (
        <Grid>
          <Paper
            elevation={3}
            sx={{
              p: 4,
              height: "100%",
              width: "280px",
              m: "20px auto",
            }}
          >
            <Box
              sx={{
                width: "100%",
                display: "flex",
                justifyContent: "center",
              }}
            >
              <Avatar>
                <LockOutlinedIcon />
              </Avatar>
            </Box>
            <Typography>Signup</Typography>
            <form noValidate onSubmit={formik.handleSubmit}>
              <Stack spacing={2}>
                <FormikTextField
                  name="email"
                  label="Email *"
                  variant="standard"
                  formik={formik}
                />
                <FormikTextField
                  name="password"
                  label="Password *"
                  variant="standard"
                  type="password"
                  formik={formik}
                />
                <Button fullWidth variant="contained" type="submit">
                  Submit
                </Button>
                <Link to={"/signin"}>Or Sign in</Link>
              </Stack>
            </form>
          </Paper>
        </Grid>
      );
    };
    
    export default Signup;
  5. プライベートルートを適用する
    プライベートルートを適用し、認証されたユーザーのみが特定のページにアクセスできるようにするために、App.tsx を修正します。この変更により、認証されていないユーザーが保護されたページにアクセスしようとした場合、ログインページへリダイレクトされます。

    // App.tsx
    
    import "./App.css";
    import { Route, Routes, useLocation } from "react-router-dom";
    import AppLayout from "./layout/AppLayout";
    import { CssBaseline, ThemeProvider } from "@mui/material";
    import { createTheme } from "./theme";
    import TodoList from "./todo/TodoList";
    import { useTodos } from "./todo/hooks/useTodos";
    import { BackgroundLocation } from "./todo/hooks/useModalRoute";
    import TodoEdit from "./todo/components/TodoEdit";
    import Signup from "./auth/Signup";
    import Signin from "./auth/Signin";
    import { PrivateRoute } from "./auth/PrivateRoute";
    
    function App() {
      const theme = createTheme();
      const { todos, setTodos } = useTodos();
      const location = useLocation();
      const background = (location.state as BackgroundLocation)?.background;
    
      return (
        <div className="App">
          <ThemeProvider theme={theme}>
            <CssBaseline />
            <Routes location={background || location}>
              <Route path="/" element={<PrivateRoute element={<AppLayout />} />}>
                <Route
                  index
                  element={
                    <TodoList
                      todos={todos}
                      setTodos={setTodos}
                      filterFunction={(todo) => !todo.done}
                    />
                  }
                />
                <Route
                  path="/important"
                  element={
                    <TodoList
                      todos={todos}
                      setTodos={setTodos}
                      filterFunction={(todo) => todo.important}
                    />
                  }
                />
                <Route
                  path="/all"
                  element={
                    <TodoList
                      todos={todos}
                      setTodos={setTodos}
                      filterFunction={() => true}
                    />
                  }
                />
                <Route
                  path="/complited"
                  element={
                    <TodoList
                      todos={todos}
                      setTodos={setTodos}
                      filterFunction={(todo) => todo.done}
                    />
                  }
                />
                <Route
                  path="/todos/:id"
                  element={
                    <TodoList
                      todos={todos}
                      setTodos={setTodos}
                      filterFunction={(todo) => !todo.done}
                    />
                  }
                />
              </Route>
              <Route path="/signup" element={<Signup />} />
              <Route path="/signin" element={<Signin />} />
            </Routes>
            {background && (
              <Routes>
                <Route
                  path="/todos/:id"
                  element={<TodoEdit todos={todos} setTodos={setTodos} />}
                />
              </Routes>
            )}
          </ThemeProvider>
        </div>
      );
    }
    
    export default App;​
  6. サインアウト機能を追加する

    auth フォルダ内に Signout.tsx ファイルを作成し、サインアウトできる機能を実装します。

    // Signout.tsx
    import React from "react";
    import { signOut } from "firebase/auth";
    import { auth } from "./firebase";
    import {
      List,
      ListItem,
      ListItemButton,
      ListItemIcon,
      ListItemText,
    } from "@mui/material";
    import LogoutIcon from "@mui/icons-material/Logout";
    
    const Signout: React.FC = () => {
      const handleSignout = () => {
        signOut(auth);
      };
      return (
        <List>
          <ListItem key="signout" disablePadding>
            <ListItemButton onClick={handleSignout}>
              <ListItemIcon>
                <LogoutIcon />
              </ListItemIcon>
              <ListItemText primary="Signout" />
            </ListItemButton>
          </ListItem>
        </List>
      );
    };
    
    export default Signout;

     

    作成した機能をサイドバーに配置します。layout フォルダ内の Sidebar.tsx を以下のように修正します。

    // Sidebar.tsx
    import {
      Box,
      Divider,
      List,
      ListItem,
      ListItemButton,
      ListItemIcon,
      ListItemText,
      Toolbar,
    } from "@mui/material";
    import React from "react";
    import StarIcon from "@mui/icons-material/Star";
    import TaskAltIcon from "@mui/icons-material/TaskAlt";
    import AllInclusiveIcon from "@mui/icons-material/AllInclusive";
    import FormatListBulletedIcon from "@mui/icons-material/FormatListBulleted";
    import { useNavigate } from "react-router-dom";
    import Signout from "../auth/Signout";
    
    const Sidebar: React.FC = () => {
      const navigate = useNavigate();
      const listItems = [
        {
          text: "Todos",
          icon: <FormatListBulletedIcon />,
          to: "/",
        },
        {
          text: "Important",
          icon: <StarIcon />,
          to: "/important",
        },
        {
          text: "All",
          icon: <AllInclusiveIcon />,
          to: "/all",
        },
        {
          text: "Complited",
          icon: <TaskAltIcon />,
          to: "complited",
        },
      ];
    
      const handleItemClick = (to: string) => {
        navigate(to);
      };
    
      return (
        <Box
          component="nav"
          sx={{
            width: { sm: "280px" },
            height: "100vh",
            borderRight: 1, // 1ピクセルの右境界線を追加
            borderColor: "divider", // デフォルトの境界線色を使用
            bgcolor: "secondary.main",
            color: "secondary.contrastText",
            display: "flex",
            flexDirection: "column",
            justifyContent: "space-between",
          }}
        >
          <Box>
            <Toolbar variant="regular" sx={{ fontSize: "22px" }}>
              ToDo App
            </Toolbar>
            <Divider />
            <List>
              {listItems.map((item, index) => (
                <ListItem key={item.text} disablePadding>
                  <ListItemButton onClick={() => handleItemClick(item.to)}>
                    <ListItemIcon>{item.icon}</ListItemIcon>
                    <ListItemText primary={item.text} />
                  </ListItemButton>
                </ListItem>
              ))}
            </List>
            <Divider />
          </Box>
          <Box sx={{ pb: 2 }}>
            <Signout />
          </Box>
        </Box>
      );
    };
    
    export default Sidebar;​

次のステップ

もっと体系的に勉強したい方や他にもアプリケーションを作ってみたいという方、バックエンドと組み合わせてフルスタックな実装をしたい方は下記のコースや書籍をおすすめします。

 

まとめ

この記事では、React と TypeScript を使用した ToDo アプリ開発シリーズの第 4 部を紹介しました。第4部ではFirebaseによる認証機能の実装を行いました。認証されている場合のみ、各ページにアクセスでき、認証されていない場合は、サインインページにリダイレクトされるように実装しました。

これでToDoアプリ開発の実装は終了です。CRUD処理をはじめとする基本的な処理の実装スキルが少しでも身につき、コーディングのイメージが湧いていれば幸いです。お疲れ様でした。