オウルです。
さて、最近工作にハマってる4歳の娘が作った、これは一体誰しょう?

娘の日課は「ノージーのひらめき工房」を3~4回観る⇔工作なので、我が家は娘の工作品で溢れかえっています。こんなに無邪気に工作するのも、いつまでだろう。これ残したいなぁと思ってると、ひらめきー!です。
最近、React勉強してるし、勉強がてらにWebアプリを作ってみるという、別に全然ひらめきではないですが、思った次第です。Googleフォトといった素晴らしいサービスはありますが、僕の家族が使うWebアプリを目指してみようと思います、まぁいつ完成するかは分からないですが。
ということで、ASP.NET CoreのReactプロジェクトテンプレートをベースにWebアプリを作成していきます。何はともあれ開発環境の準備からです。
目標
- ASP.NET CoreとReactの開発環境の準備
- ログインから他ページ遷移の実装
開発環境
ローカル開発環境は次になります。
OS | WSL Ubuntu 21.04 |
Editor | VSCode |
言語と主なライブラリ
フロントエンド
React | 17.0.2 |
React Router | 6.0.0-beta.2 |
Typescript | 4.1.5 |
Tailwind CSS |
バックエンド
ASP.NET Core | 5.0 |
node.jsとnpmはインストール済みであることとします。
開発環境の準備
ASP.NET CoreのReactプロジェクトテンプレートを使ってプロジェクトを作成します。
Reactプロジェクトテンプレート
.NET Core CLIでReactプロジェクトテンプレートを作成します。
1 2 3 4 5 6 7 8 9 |
// ソリューションファイルの作成 dotnet new sln -o MyProject // ディレクトリの作成と移動 mkdir -p src/app cd src/app // Reactプロジェクトを作成 dotnet new react -o my-app // ソリューションにプロジェクトを追加 dotnet sln add MyProject.sln src/app/my-app/my-app.csproj |
React Redux Appに置き換え
上記で作成されたClientAppは標準のCRA Reactアプリです。これをRedux + TS テンプレートのReact Redux Appに置き換えます。
1 2 3 4 5 6 7 |
// ClientAppを削除 rm -r ClientApp // React Redux Appを作成 npx create-react-app client-app --template redux-typescript // ソリューションファイルがある場所に移動してVS Codeを起動 cd ***** code . |
ASP.NET Coreプロジェクトを変更
ClientApp⇒client-appに変更したので、関係するところを変更していきます。同じClientAppで作成した場合は、ここを変更すればいいんだなと思いながら眺めてください。
プロジェクトファイル
まずは、プロジェクトファイルです。
1 2 3 4 5 6 7 8 9 10 11 |
<PropertyGroup> <TargetFramework>net5.0</TargetFramework> <TypeScriptCompileBlocked>true</TypeScriptCompileBlocked> <TypeScriptToolsVersion>Latest</TypeScriptToolsVersion> <IsPackable>false</IsPackable> <!-- ここ! --> <SpaRoot>client-app/</SpaRoot> <DefaultItemExcludes>$(DefaultItemExcludes);$(SpaRoot)node_modules\**</DefaultItemExcludes> <!-- ここ! --> <RootNamespace>client-app</RootNamespace> </PropertyGroup> |
Startup.cs
続いて、Startup.csです。
1 2 3 4 |
services.AddSpaStaticFiles(configuration => { configuration.RootPath = "client-app/build"; }); |
1 2 3 4 5 6 7 8 9 |
app.UseSpa(spa => { spa.Options.SourcePath = Path.Join($"{env.ContentRootPath}/src/app/my-app", "client-app"); if (env.IsDevelopment()) { spa.UseReactDevelopmentServer(npmScript: "start"); } }); |
生成されたパスのUrlを小文字にする
Startup.csを編集中なので、ついでに生成されたパスのURLが小文字になるように構成します。
1 2 3 4 |
services.Configure<RouteOptions>(options => { options.LowercaseUrls = true; }); |
ここでは省略しますが、Loggerを追加しておきましょう。NLogを使う場合は、Getting started with ASP.NET Core 5を参考にしてください。また、エディターにVSCodeをお使いの場合は、プロジェクトファイルに、nlog.configをコピーするように設定してください。
Prettier
PrettierをVS Codeに導入します。Prettierは、Javascriptでよく使われるコード整形ツールです。
これで、動くはずです。VS Codeからデバッグして、次のページがブラウザに表示されればOKです。

ログインから他ページに遷移
開発環境の準備が整ったので、ログインページから他ページの遷移を実装してみます。今回のサンプルに限り、認証ロジックなしのトークン(固定文字列)を返すだけのAPIにして、結果のトークンをセッションストレージに格納します。”限り”というのも、例えばJWS形式のJWTにした場合、トークンの保存先問題があります。Please Stop Using Local Storageにあるようにセキュリティリスクを考えなければならないです。なので、ASP.NET Core Identityを使用しない認証Cookieを使った方法を検討中です。こちらは検証次第、また紹介できればと思います。
JWS形式のJWTって何?という方はこちらをご参考に。
Identityを使用しない認証Cookieって何?という方はこちらをご参考に。
フォームライブラリとCSSフレームワーク
シンプルなフォームバリデーションが実現できるReact Hook FormとCSSフレームワークのTailwind CSSを導入します。なぜ、Tailwind CSSなのか?Bootstrap以外で流行っているフレームワークをただ触りたかったからという理由です。
各インストールは手順通りに進めれば問題ないはずです。ただ、僕はTailwind CSSのtailwind.config.jsを生成するときに”lum[i] = (chan <= 0.039_28) ? chan / 12.92 : ((chan + 0.055) / 1.055) ** 2.4;
`”というエラーが出て嵌りましたが。。。色々試しましたが、結局Nodeのバージョンがv10で古く、v14までアップデートして再度インストールすると上手くいきました。
ログイン
ログインページを作っていきます。作るファイルは以下の3つです。
- Login.tsx
- loginAPI.ts
- loginSlice.ts
loginAPI.ts
Redux ToolkitのcreateAsyncThunkを使ってASP.NET Core APIにアクセスする非同期関数を実装します。因みにRedux Toolkitは以下のような声から生まれたようです。
- “Configuring a Redux store is too complicated”
- “I have to add a lot of packages to get Redux to do anything useful”
- “Redux requires too much boilerplate code”
要は、めんどくさくて使いにくいってことですね。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 |
import { createAsyncThunk } from "@reduxjs/toolkit"; export type User = { email: string; password: string; }; export interface UserCredential { email: string; token: string | null; } /** * thunk action creatorを生成 */ export const loginAsync = createAsyncThunk( "login/fetchLogin", async (user: User) => { const url = new URL( `${window.location.protocol}//${document.domain}:${window.location.port}/user/auth` ); const res = await fetch(url.href, { method: "post", headers: { accept: "application/json", "Content-Type": "application/json", }, body: JSON.stringify(user), credentials: "same-origin", }); if (res.ok) { return (await res.json()) as UserCredential; } throw new Error("fail login"); } ); /** * セッションストアに保存されたトークンを取得 * @returns トークン */ export const getToken = () => { const item = sessionStorage.getItem("token"); if (item) { const credential: UserCredential = JSON.parse(item); return credential.token; } return null; }; |
loginAsyncのpayloadCreator(createAsyncThunkの第2引数)に、ASP.NET Core APIのログインAPIにアクセスする非同期ロジックを含めています。
loginSlice.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
import { createSlice } from "@reduxjs/toolkit"; import { RootState } from "../../app/store"; import { loginAsync } from "./loginAPI"; type LoginState = { status: "idle" | "loading" | "failed" } const initialState: LoginState = { status: "idle" } export const loginSlice = createSlice({ name: "login/login", initialState: initialState, reducers: { }, extraReducers: (builder) => { builder .addCase(loginAsync.pending, (state) => { state.status = "loading" }) .addCase(loginAsync.fulfilled, (state, action) => { state.status = "idle" const credential = action.payload; sessionStorage.setItem("token", JSON.stringify(credential)) }) .addCase(loginAsync.rejected, (state) => { state.status = "failed" }) }, }) export const getStatus = (state: RootState) => state.login.status export default loginSlice.reducer |
createSliceのreducersパラメーターには何も追加していません。loginAsyncは、公式の言葉を使うならば「reference “external” actions」になるので、extraReducersパラメーターに追加します。これは、以下の公式引用にあるように推奨とあります。今回は単純な非同期リクエストのライフサイクル(pending、fulfilled、rejected)を表すステータスを変更するReducerだけですけど。
We recommend using this API as it has better TypeScript support (and thus, IDE autocomplete even for JavaScript users), as it will correctly infer the action type in the reducer based on the provided action creator. It’s particularly useful for working with actions produced by createAction and createAsyncThunk.
Login.tsx
ログインコンポーネントを作ります。メールアドレスとパスワードのシンプルなフォームです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 |
import React, { useCallback } from "react" import { useNavigate } from 'react-router-dom'; import { useAppSelector, useAppDispatch } from "../../app/hooks" import { getStatus } from "./loginSlice" import { loginAsync, getToken } from "./loginAPI" import ReactLoading from "react-loading"; import { useForm } from "react-hook-form" import "../../index.css"; type FormData = { email: string, password: string } export const Login = () => { const navigate = useNavigate() const status = useAppSelector(getStatus) const dispatch = useAppDispatch() const { register, handleSubmit, formState: { errors } } = useForm<FormData>() const fn = useCallback(async (data) => { await dispatch(loginAsync(data)) if(getToken()) { navigate("/dashboard") } }, [dispatch, navigate]) const onSubmit = handleSubmit(fn) return( <div className="flex justify-center"> <div className="w-full max-w-xs"> <form onSubmit={onSubmit} className="bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4"> <div className="mb-4"> <label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="username">メールアドレス</label> <input className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline mb-2" { ...register("email", { required: true }) } /> <p className="text-red-500 text-xs">{ errors.email && "メールアドレスを入力してください" }</p> </div> <div className="mb-6"> <label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="password">パスワード</label> <input className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline mb-2" type="password" { ...register("password", { required: true }) } /> <p className="text-red-500 text-xs">{ errors.password && "パスワードを入力してください" }</p> </div> { status === "loading" && <ReactLoading type="bubbles" color="#357edd" /> } { status === "failed" && <p className="text-red-500 text-xs">ログインに失敗しました</p> } <div className="flex items-center justify-between"> <input className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline" type="submit" value="ログイン" /> </div> { status === "failed" && <p className="text-red-500 text-xs mt-2">ログインに失敗しました</p> } </form> <p className="text-center text-gray-500 text-xs"> ©2021 Owl Corp. All rights reserved. </p> </div> </div> ) } |
React Hook Formを使うと、簡単にバリデーションできました。ただやはり実装してみると色々思うところがでてきて、特にログインボタンをクリックした結果が、エラーだった場合の表示制御がいまいち。この辺りは、実装を進めながら模索ってとこですね。
後、β版ですがReact Router v6の機能になるナビゲーション関数を使用しています。ここでは、React RouterのuseNavigateフックを使って遷移する機能を追加しています。
本当は、ページ遷移まで行きたかったですが、少し長くなってきたのでページ遷移(React Router)とAPI側(ただ単に文字列を返すだけですが)は次回ということにします。何分、Reactの勉強歴が浅いので、Twitterなどでここはこうした方がいいとかアドバイス等いただけると幸いです。