🔑

React × LaravelでReact Queryの練習がてら、ログイン機能を作ってみた

2021/03/21に公開

React の状態管理ライブラリと言うと Redux や Recoil がメジャーなイメージでしょうか。
少し前から React Query なるものが便利らしいぞと見聞きしたので使ってみたのを、せっかくなので記事にしてみました。

※2023/11/25 各種リンクの URL を tanstack.com をベースに更新しました。

React Query とは?

公式
https://tanstack.com/query/v3

ものすごくざっくりいうと、React アプリで使用するデータの取得、更新。そのデータ管理まで一気にやってくれるライブラリといった感じです。

データの取得や更新の方法はこちらで指定する必要がありますが、実体のデータを Promise で返す関数なら何でも使えます。
なので、fetch でも ky でも axios でも OK。

キャッシュ機能が統合されており、取得したデータは指定したクエリキーと紐づけて管理されます。
このキャッシュされたデータは、どのコンポーネントからもアクセス可能です。
キャッシュの有効時間が過ぎると、バックグラウンドでデータの再取得が自動で行われるようになっています。
(その分、API リクエスト等が増えるということでもあるので、導入するアプリに応じてオプションで調整した方がいいです)

それと個人的に特にいいなと思った部分として、データ取得、更新の状態を返してくれます。
データ取得中なのか、成功したのか、エラーになったのかといったあたり。
この状態を使って、読み込み中やエラーの時の UI に切り替えるということも、楽にできるようになっています。

使用例

React Query を使えるようにするには、まず最初にQueryClientを作成。
アプリをQueryClientProviderで囲み、そこに作成したクライアントを渡すようにします。
こうすることで、この配下のコンポーネントで React Query の機能が使えるようになります。

React Query が提供するフックはいろいろあるのですが、基本となるものとしては以下の2つです。

  • データ取得:useQuery
  • データ更新:useMutation

useQuery

https://tanstack.com/query/v3/docs/react/reference/useQuery

公式の例
https://codesandbox.io/s/github/tanstack/query/tree/v3/examples/simple

/* eslint-disable jsx-a11y/anchor-is-valid */
import React from "react";
import ReactDOM from "react-dom";
import { QueryClient, QueryClientProvider, useQuery } from "react-query";
import { ReactQueryDevtools } from "react-query/devtools";

const queryClient = new QueryClient();

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Example />
    </QueryClientProvider>
  );
}

function Example() {
  const { isLoading, error, data, isFetching } = useQuery("repoData", () =>
    fetch(
      "https://api.github.com/repos/tannerlinsley/react-query"
    ).then((res) => res.json())
  );

  if (isLoading) return "Loading...";

  if (error) return "An error has occurred: " + error.message;

  return (
    <div>
      <h1>{data.name}</h1>
      <p>{data.description}</p>
      <strong>👀 {data.subscribers_count}</strong>{" "}
      <strong>{data.stargazers_count}</strong>{" "}
      <strong>🍴 {data.forks_count}</strong>
      <div>{isFetching ? "Updating..." : ""}</div>
      <ReactQueryDevtools initialIsOpen />
    </div>
  );
}

const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);

useQueryに対して、以下を渡しています

  • 第1引数:クエリキー
  • 第2引数:データ取得の関数

クエリキーには配列の指定もできます。データ取得の関数により取得されたデータは、このクエリキーと紐づけて管理されます。
また、第3引数にオプションのオブジェクトを渡して、成功時や失敗時の処理を書いたりとカスタマイズすることも出来たりします。

useMutation

https://tanstack.com/query/v3/docs/react/reference/useMutation

公式の例(useQueryuseMutation) ※2023/07/09時点では TypeScript になっていました
https://codesandbox.io/p/sandbox/github/tanstack/query/tree/v3/examples/optimistic-updates-typescript

import React from 'react'
import axios from 'axios'

import {
  useQuery,
  useQueryClient,
  useMutation,
  QueryClient,
  QueryClientProvider,
} from 'react-query'
import { ReactQueryDevtools } from 'react-query/devtools'

const queryClient = new QueryClient()

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Example />
    </QueryClientProvider>
  )
}

function Example() {
  const queryClient = useQueryClient()
  const [text, setText] = React.useState('')

  const { status, data, error, isFetching } = useQuery('todos', async () => {
    const res = await axios.get('/api/data')
    return res.data
  })

  const addTodoMutation = useMutation(
    text => axios.post('/api/data', { text }),
    {
      // Optimistically update the cache value on mutate, but store
      // the old value and return it so that it's accessible in case of
      // an error
      onMutate: async text => {
        setText('')
        await queryClient.cancelQueries('todos')

        const previousValue = queryClient.getQueryData('todos')

        queryClient.setQueryData('todos', old => ({
          ...old,
          items: [...old.items, text],
        }))

        return previousValue
      },
      // On failure, roll back to the previous value
      onError: (err, variables, previousValue) =>
        queryClient.setQueryData('todos', previousValue),
      // After success or failure, refetch the todos query
      onSuccess: () => {
        queryClient.invalidateQueries('todos')
      },
    }
  )

  return (
    <div>
      <p>
        In this example, new items can be created using a mutation. The new item
        will be optimistically added to the list in hopes that the server
        accepts the item. If it does, the list is refetched with the true items
        from the list. Every now and then, the mutation may fail though. When
        that happens, the previous list of items is restored and the list is
        again refetched from the server.
      </p>
      <form
        onSubmit={e => {
          e.preventDefault()
          addTodoMutation.mutate(text)
        }}
      >
        <input
          type="text"
          onChange={event => setText(event.target.value)}
          value={text}
        />
        <button>{addTodoMutation.isLoading ? 'Creating...' : 'Create'}</button>
      </form>
      <br />
      {status === 'loading' ? (
        'Loading...'
      ) : status === 'error' ? (
        error.message
      ) : (
        <>
          <div>Updated At: {new Date(data.ts).toLocaleTimeString()}</div>
          <ul>
            {data.items.map(datum => (
              <li key={datum}>{datum}</li>
            ))}
          </ul>
          <div>{isFetching ? 'Updating in background...' : ' '}</div>
        </>
      )}
      <ReactQueryDevtools initialIsOpen />
    </div>
  )
}

useMutationに対して、以下を渡しています。

  • 第1引数:データ更新の関数
  • 第2引数:オプションのオブジェクト

この第1引数に指定した関数は、useMutationが呼ばれただけでは実行されません。
useMutationの返り値の中に、この関数を実行するトリガー関数(mutate)が含まれており、それを使うことで初めて実行されるようになっています。
上記の例だとaddTodoMutation.mutate(text)の部分です。
トリガー関数に渡した引数が、そのままデータ更新の関数に渡されます。

上記の例で使われているオプションをさらっと説明すると、こんな感じです。

  • onMutate:データ更新の関数が実行される前に、先に実行される処理(前処理を書く)
  • onError:エラー発生時に実行される処理
  • onSuccess:成功時に実行される処理

成功時、エラー時ともに実行される onSettled というものもあります。

また、onSuccess、onError、onSettled に関しては、useMutationだけでなく、トリガー関数のオプション引数としても渡すことができます。
どちらにも同名のオプションを渡している場合は、useMutationに渡した方が先に実行されます。


ちなみにReactQueryDevtoolsは開発支援の DevTools です。
キャッシュの状態などがわかるので、いれておくとよいです。

詳細については公式ドキュメントをご確認ください。

今回作ってみたもの

勉強用の個人開発でログイン画面を作りました。
React(Laravel Mix)× Laravel による SPA × API 構成です。

ログイン画面でログインする流れ

メールアドレスとパスワードでログインのスタンダードなタイプ。
後々、ソーシャルログインとゲストログインにしたいということもあり、今回メール認証は特にやっていません。

また、API の認証方式については Cookie を使ったステートフルなものになります。

実装にあたっていろんな文献を参考にさせていただいたのですが、以下の2つは特にお世話になりました。
※連載記事
https://www.hypertextcandy.com/vue-laravel-tutorial-introduction
※書籍
https://github.com/oukayuka/Riakuto-StartingReact-ja3.1

それと各種ライブラリの公式ドキュメントも。

前提

今回使用した各種バージョンは以下のとおりです。

基本部分

  • Node.js:14.2.0
  • TypeScript:4.1.3
  • React:16.14.0
  • PHP:7.4.14
  • Laravel:6.20.9

ライブラリ

  • Material UI
    • core:4.11.3
    • lab:4.0.0-alpha.57
  • axios:0.21.1
  • react-router-dom:5.2.0
  • react-query:3.12.1

以下のセットアップはすでにすんでいるものとして進めます。

  • マイグレーション実行
  • Laravel UI で React の導入
  • TypeScript のセットアップ
  • ライブラリインストール

ディレクトリ構成

Laravel 側は特に変わったことをしてないので、React 側だけ記載します。

Laravel プロジェクトの resources/ts 配下

├ components
│   ├ molecules
│   │   └ LoginAlert.tsx
│   ├ organisms
│   │   └ Header.tsx
│   ├ pages
│   │   ├ Loding.tsx
│   │   ├ Login.tsx
│   │   └ Memo.tsx
├ constants
│   └ statusCode.ts
├ containers
│   ├ organisms
│   │   └ Header.tsx
│   ├ pages
│   │   ├ Login.tsx
│   │   └ Memo.tsx
├ hooks
│   ├ auth
│   │   ├ index.ts
│   │   ├ useLogin.ts
│   │   └ useLogout.ts
│   ├ user
│   │   ├ index.ts
│   │   ├ useCurrentUser.ts
│   │   └ useGetUserQuery.ts
├ models
│   └ User.ts
├ app.tsx
└ bootstrap.js

以降、今回の実装のコードを記載していますが、だいぶ長くなりました...🙄
GitHub で見たいという方は、こちらのリポジトリにタグをつけてあります。
https://github.com/h-yoshikawa44/ooui-memo/releases/tag/post%2Flaravel-react-query-auth

※2021/09/13追記 関数コンポーネントの型定義に FC ばっかり使ってますが、FC から children がなくなるまでは VFC を使った方がいいです...。

API(Laravel)側

※今回、ユーザ新規登録の部分は記載していません。
Laravel に元々備わっているものを使って API を作るなり、tinker でユーザを作っておくなりでご対応ください。

ルーティング

画面の振り分けは React 側で行うので、Laravel 側では全てのリクエストを受けるようにします。

routes/web.php
Route::get('/{any?}', fn() => view('index'))->where('any', '.+');

※2021/05/19 追記
全受けにすると、API ルートで where 制約をかけた際に予期しない動作を引き起こすので api プレフィックスは除外しておいた方がいいです。
(制約外のパスパラメータでアクセスすると404のはずなのに、このルートに来て200になってしまうので)

routes/web.php
Route::get('/{any?}', fn() => view('index'))->where('any', '(?!api).+');

API ルートで使用するミドルウェアグループの変更

今回は Cookie を使った認証にしていきます。
下記のとおり、それに必要なミドルウェアは web のミドルウェアグループに含まれています。

app/Http/Kernel.php
protected $middlewareGroups = [
    'web' => [
        \App\Http\Middleware\EncryptCookies::class,
        \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
        \Illuminate\Session\Middleware\StartSession::class,
        // \Illuminate\Session\Middleware\AuthenticateSession::class,
        \Illuminate\View\Middleware\ShareErrorsFromSession::class,
        \App\Http\Middleware\VerifyCsrfToken::class,
        \Illuminate\Routing\Middleware\SubstituteBindings::class,
    ],

    'api' => [
        'throttle:60,1',
        \Illuminate\Routing\Middleware\SubstituteBindings::class,
    ],
];

それを API でも使うように思い切って変えました。

app/Providers/RouteServiceProvider.php
    protected function mapApiRoutes()
    {
        // Webミドルウェアグループの機能を使いたいのでwebへ
        Route::prefix('api')
             ->middleware('web')
             ->namespace($this->namespace)
             ->group(base_path('routes/api.php'));
    }

なんか抵抗があるという場合は、ステートフルな API 用のミドルウェアグループを新たに作って、それを割り当てるのも手です。

CSRF 対策について

この web のミドルウェアグループには CSRF 対策の機能を持つ、\App\Http\Middleware\VerifyCsrfToken::classも含まれています。
なので、リクエストの際には CSRF トークンを送らないとはじかれます。
https://readouble.com/laravel/6.x/ja/csrf.html

今回の場合、SPA 側からのリクエストには axios を使用するので、リクエストヘッダにX-XSRF-TOKENをつけて送る必要があります。
ただ、このあたりの設定に関して特にやることはありません。

\App\Http\Middleware\VerifyCsrfToken::classで以下の設定があり、レスポンスヘッダのSet-CookieXSRF-TOKENを設定してくれています。

/**
 * Indicates whether the XSRF-TOKEN cookie should be set on the response.
 *
 * @var bool
 */
protected $addHttpCookie = true;

そして、axios は Cookie にXSRF-TOKENがあると、自動でX-XSRF-TOKENにセットして送ってくれるようになっているためです。

ログイン API

Laravel が元々備えている機能を拡張して使用。

LoginControllerで使用されているAuthenticatesUsersトレイトに認証に関するメソッドが定義されています。
そこにログイン API のレスポンスカスタマイズ用のメソッドとしてauthenticatedがあります。
これを使用して、ログイン時はログインしたユーザ情報を返すように。

app/Http/Controllers/Auth/LoginController.php
/**
 * ログインAPI レスポンスカスタマイズ用メソッド
 *
 * @param Illuminate\Http\Request $request
 * @param \App\User $user
 * @return \App\User
 */
protected function authenticated(Request $request, $user)
{
    return $user;
}

API のルートに追加

routes/api.php
Route::post('/login', 'Auth\LoginController@login')->name('login');

ログアウト API

ログイン API と同様に作成。

こちらはloggedOutメソッドがレスポンスカスタマイズ用のメソッドになります。

※2021/04/13 追記
レスポンスはresponse(null, 204)で204を返した方がいいやもしれません。

※2021/04/27 追記
セッション再生成処理は、大元のログアウト処理の中ですでに行われているので不要です。

app/Http/Controllers/Auth/LoginController.php
/**
 * ログアウトAPI レスポンスカスタマイズ用メソッド
 *
 * @param Illuminate\Http\Request $request
 * @return \Illuminate\Http\JsonResponse
 */
protected function loggedOut(Request $request)
{
    // セッションを再生成する
    $request->session()->regenerate();

    return response()->json();
}

API のルートに追加

routes/api.php
Route::post('/logout', 'Auth\LoginController@logout')->name('logout');

ログインユーザ取得 API

新しくコントローラーを作って定義します。
この API は認証をかけたかったので、auth ミドルウェアを使用。

app/Http/Controllers/UserController.php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;

class UserController extends Controller
{
    /**
     * Create a new controller instance.
     *
     * @return void
     */
    public function __construct()
    {
        $this->middleware('auth');
    }

    /**
     * 現在ログインしているユーザ情報取得
     *
     * @return \App\User|null
     */
    public function show()
    {
        return Auth::user();
    }
}

API のルートに追加

routes/api.php
Route::get('/users/me', 'UserController@show')->name('user');

User モデルのレスポンスのプロパティを変更

app/User.php
/**
 * The attributes that should be visible for arrays.
 *
 * @var array
 */
protected $visible = [
    'name',
];

元々は$hiddenで書いてあるのですが、今回の場合は React 側で一旦 name しか使わないので、$visibleで name のみ返すようにしています。

ログイン済みの時に、非ログイン時にしかアクセスできない機能にアクセスした時のリダイレクト設定

元々はRouteServiceProvider::HOMEへリダイレクトするようになっています。
ただ、それだと HTML が返ってきてしまい SPA 的には不都合なので、ログインユーザ取得 API に変えておきます。

app/Http/Middleware/RedirectIfAuthenticated.php
/**
 * Handle an incoming request.
 *
 * @param  \Illuminate\Http\Request  $request
 * @param  \Closure  $next
 * @param  string|null  $guard
 * @return mixed
 */
public function handle($request, Closure $next, $guard = null)
{
    if (Auth::guard($guard)->check()) {
        return redirect()->route('user');
    }

    return $next($request);
}

SPA(React)側

bootstrap.jsは特に変更していないので割愛。

アプリ初期化 + ルーティング

1ファイルの中でやってるので長いですが、こんな感じです。

resources/ts/app.tsx
import React, { FC } from 'react';
import ReactDOM from 'react-dom';
import {
  BrowserRouter as Router,
  Switch,
  Route,
  Redirect,
} from 'react-router-dom';
import { QueryClient, QueryClientProvider, useQueryClient } from 'react-query';
import { ReactQueryDevtools } from 'react-query/devtools';
import CssBaseline from '@material-ui/core/CssBaseline';

import Login from './containers/pages/Login';
import Memo from './containers/pages/Memo';
import Loding from './components/pages/Loding';
import { useGetUserQuery, useCurrentUser } from './hooks/user';

/**
 * First we will load all of this project's JavaScript dependencies which
 * includes React and other helpers. It's a great starting point while
 * building robust, powerful web applications using React + Laravel.
 */
require('./bootstrap');

/**
 * Next, we will create a fresh React component instance and attach it to
 * the page. Then, you may begin adding components to this application
 * or customize the JavaScript scaffolding to fit your unique needs.
 */
// require('./components/Example');

const client = new QueryClient();

type Props = {
  exact?: boolean;
  path: string;
  children: React.ReactNode;
};

const UnAuthRoute: FC<Props> = ({ exact = false, path, children }) => {
  const user = useCurrentUser();
  return (
    <Route
      exact={exact}
      path={path}
      render={() => (user ? <Redirect to={{ pathname: '/' }} /> : children)}
    />
  );
};

const AuthRoute: FC<Props> = ({ exact = false, path, children }) => {
  const user = useCurrentUser();
  return (
    <Route
      exact={exact}
      path={path}
      render={({ location }) =>
        user ? (
          children
        ) : (
          <Redirect to={{ pathname: '/login', state: { from: location } }} />
        )
      }
    />
  );
};

const App: FC = () => {
  const queryClient = useQueryClient();
  const { isLoading } = useGetUserQuery({
    retry: 0,
    initialData: undefined,
    onError: () => {
      queryClient.setQueryData('user', null);
    },
  });

  if (isLoading) {
    return <Loding />;
  }

  return (
    <Switch>
      <UnAuthRoute exact path="/login">
        <Login />
      </UnAuthRoute>
      <AuthRoute exact path="/">
        <Memo />
      </AuthRoute>
    </Switch>
  );
};

if (document.getElementById('app')) {
  ReactDOM.render(
    <Router>
      <QueryClientProvider client={client}>
        <CssBaseline />
        <App />
        {process.env.NODE_ENV === 'development' && (
          <ReactQueryDevtools initialIsOpen={false} />
        )}
      </QueryClientProvider>
    </Router>,
    document.getElementById('app')
  );
}

React Query のセットアップ

冒頭に書いた通り、まずQueryClientを作成。
アプリをQueryClientProviderで囲み、そこに作成したクライアントを渡します。

const client = new QueryClient();
if (document.getElementById('app')) {
  ReactDOM.render(
    <Router>
      <QueryClientProvider client={client}>
        <CssBaseline />
        <App />
        {process.env.NODE_ENV === 'development' && (
          <ReactQueryDevtools initialIsOpen={false} />
        )}
      </QueryClientProvider>
    </Router>,
    document.getElementById('app')
  );
}

ログインユーザ情報の保持

まず最初にログインユーザを取得する処理を入れることで、永続化っぽいことをしています。
このuseGetUserQueryフックはuseQueryをラップしたカスタムフックで、ログインユーザ情報が取得できた時は user キーの中へセットするようにしてあります(後述)

逆にログインユーザが取得できなかった場合はnullをセット。
なお、最初の1回だけでいいので、リトライ回数は0に。

isLoadingを取得して、取得中の時は簡単なローディング画面を表示するようにしています。

const App: FC = () => {
  const queryClient = useQueryClient();
  const { isLoading } = useGetUserQuery({
    retry: 0,
    initialData: undefined,
    onError: () => {
      queryClient.setQueryData('user', null);
    },
  });

  if (isLoading) {
    return <Loding />;
  }

  return (
    <Switch>
      <UnAuthRoute exact path="/login">
        <Login />
      </UnAuthRoute>
      <AuthRoute exact path="/">
        <Memo />
      </AuthRoute>
    </Switch>
  );
};

認証ルートと非認証ルート

React Router が持っているRouteコンポーネントをラップしたコンポーネントをそれぞれ作成。
この実装は React Router 公式の例を参考にしました。
https://v5.reactrouter.com/web/example/auth-workflow

useCurrentUserフックは、キャッシュからログインユーザ情報を取得するカスタムフックです(後述)
ログインユーザ情報の有無でリダイレクトするようにしています。

認証ルートの方でリダイレクト時に location を state にセットしているのは、フレンドリーフォワーディングのためです。

type Props = {
  exact?: boolean;
  path: string;
  children: React.ReactNode;
};

const UnAuthRoute: FC<Props> = ({ exact = false, path, children }) => {
  const user = useCurrentUser();
  return (
    <Route
      exact={exact}
      path={path}
      render={() => (user ? <Redirect to={{ pathname: '/' }} /> : children)}
    />
  );
};

const AuthRoute: FC<Props> = ({ exact = false, path, children }) => {
  const user = useCurrentUser();
  return (
    <Route
      exact={exact}
      path={path}
      render={({ location }) =>
        user ? (
          children
        ) : (
          <Redirect to={{ pathname: '/login', state: { from: location } }} />
        )
      }
    />
  );
};

ローディング画面

Presentational Component

画面中央にスピナーを出すだけのシンプルな画面です。

resources/ts/components/pages/Loding.tsx
import React, { FC } from 'react';
import Box from '@material-ui/core/Box';
import CircularProgress from '@material-ui/core/CircularProgress';
import Container from '@material-ui/core/Container';

const Loding: FC = () => (
  <Container maxWidth="xs">
    <Box
      width={1}
      height="100vh"
      display="flex"
      alignItems="center"
      justifyContent="center"
    >
      <CircularProgress color="inherit" />
    </Box>
  </Container>
);

export default Loding;

ヘッダー

Container Component

useLogoutフックはuseMutationをラップしたカスタムフックです(後述)
ログアウト処理を行う関数を実行するトリガー関数を受け取り、ログアウトボタン押下時の関数の中で実行しています。

ログアウト処理が成功した場合はログイン画面へリダイレクトするようにしています。

resources/ts/containers/organisms/Header.tsx
import React, { FC, useCallback } from 'react';
import { useHistory } from 'react-router-dom';
import Header from '../../components/organisms/Header';
import { useLogout } from '../../hooks/auth';
import { useCurrentUser } from '../../hooks/user';

const EnhancedHeader: FC = () => {
  const user = useCurrentUser();

  const history = useHistory();
  const { mutate } = useLogout();

  const handleLogout = useCallback(() => {
    mutate(undefined, {
      onSuccess: () => {
        history.push('/login');
      },
    });
  }, [history, mutate]);

  return <Header userName={user?.name} handleLogout={handleLogout} />;
};

export default EnhancedHeader;

Presentational Component

ヘッダーにユーザ名表示とログアウトボタンを置くというと、メニューにすることが多いと思われますが、今回は横に並べて配置にしています。

resources/ts/components/organisms/Header.tsx
import React, { FC } from 'react';
import AppBar from '@material-ui/core/AppBar';
import Button from '@material-ui/core/Button';
import Toolbar from '@material-ui/core/Toolbar';
import Typography from '@material-ui/core/Typography';
import useTheme from '@material-ui/core/styles/useTheme';

type Props = {
  userName?: string;
  handleLogout: VoidFunction;
};

const Header: FC<Props> = ({ userName, handleLogout }) => {
  const theme = useTheme();
  return (
    <>
      <AppBar
        position="sticky"
        style={{
          color: theme.palette.text.primary,
          backgroundColor: 'white',
        }}
      >
        <Toolbar>
          <Typography
            component="h1"
            variant="h6"
            style={{ flexGrow: 1 }}
            align="center"
          >
            OOUI-MEMO
          </Typography>
          {userName && (
            <>
              <Typography>{userName}</Typography>
              <Button type="button" onClick={handleLogout}>
                ログアウト
              </Button>
            </>
          )}
        </Toolbar>
      </AppBar>
    </>
  );
};

export default Header;

ログインアラート表示

定数

Laravel から返されるステータスコードを定数で定義しています。
とりあえずこの2つだけ。

resources/ts/constants/statusCode.ts
// バリデーションエラー
export const UNPROCESSABLE_ENTITY = 422;

// サーバエラー
export const INTERNAL_SERVER_ERROR = 500;

Presentational Component

ログイン画面において、ログイン失敗時に表示するアラートのコンポーネント。

resources/ts/components/molecules/LoginAlert.tsx
import React, { FC } from 'react';
import Alert from '@material-ui/lab/Alert';
import AlertTitle from '@material-ui/lab/AlertTitle';
import {
  UNPROCESSABLE_ENTITY,
  INTERNAL_SERVER_ERROR,
} from '../../constants/statusCode';

type Props = {
  statusCode: number;
};

const LoginAlert: FC<Props> = ({ statusCode }) => (
  <>
    {statusCode === UNPROCESSABLE_ENTITY && (
      <Alert severity="error">
        <AlertTitle>認証失敗</AlertTitle>
        入力した情報に誤りがないかご確認ください。
      </Alert>
    )}
    {statusCode === INTERNAL_SERVER_ERROR && (
      <Alert severity="error">
        <AlertTitle>サーバエラー</AlertTitle>
        予期しないエラーが発生しました。恐れ入りますが時間をおいて再度お試しください。
      </Alert>
    )}
  </>
);

export default LoginAlert;

ちなみにこういうやつです。
ログイン画面でのアラート表示

ログイン画面

Container Component

useLoginフックはuseMutationをラップしたカスタムフックです(後述)
ログイン処理を行う関数を実行するトリガー関数を受け取り、ログインボタン押下時の関数の中で実行しています。

フレンドリーフォワーディングにするため、location.state にセットされたものがあれば、ログイン時にその URL へリダイレクトするようにしています。

※2021/05/31 追記
from の型を string にしてしまっていますが、正しくはhistoryLocationですね...。

resources/ts/containers/pages/Login.tsx
import React, { FC, useState, useCallback } from 'react';
import { useHistory, useLocation } from 'react-router-dom';
import Login from '../../components/pages/Login';
import { useLogin } from '../../hooks/auth';

const EnhancedLogin: FC = () => {
  const history = useHistory();
  const location = useLocation();
  const { from } = (location.state as { from: string }) || {
    from: { pathname: '/' },
  };

  const { error, isLoading, mutate } = useLogin();
  const statusCode = error?.response?.status;

  const [email, setEmail] = useState('');
  const [password, serPassword] = useState('');

  const handleChangeEmail = useCallback(
    (ev: React.ChangeEvent<HTMLInputElement>) => {
      setEmail(ev.target.value);
    },
    []
  );

  const handleChangePassword = useCallback(
    (ev: React.ChangeEvent<HTMLInputElement>) => {
      serPassword(ev.target.value);
    },
    []
  );

  const handleLogin = useCallback(
    (ev: React.FormEvent<HTMLFormElement>) => {
      ev.preventDefault();
      if (!email || !password) {
        return;
      }
      mutate(
        { email, password },
        {
          onSuccess: () => {
            history.replace(from);
          },
        }
      );
    },
    [email, password, history, from, mutate]
  );

  return (
    <Login
      email={email}
      password={password}
      handleChangeEmail={handleChangeEmail}
      handleChangePassword={handleChangePassword}
      statusCode={statusCode}
      isLoading={isLoading}
      handleLogin={handleLogin}
    />
  );
};

export default EnhancedLogin;

Presentational Component

ログイン実行中に再度ボタンを押されないようにするため、isLoadingを使って、実行中はBackdropを表示するようにしています。
(暗転してスピナークルクル表示の部分)

resources/ts/components/pages/Login.tsx
import React, { FC } from 'react';
import Backdrop from '@material-ui/core/Backdrop';
import Box from '@material-ui/core/Box';
import Button from '@material-ui/core/Button';
import CircularProgress from '@material-ui/core/CircularProgress';
import Container from '@material-ui/core/Container';
import Card from '@material-ui/core/Card';
import CardHeader from '@material-ui/core/CardHeader';
import CardContent from '@material-ui/core/CardContent';
import TextField from '@material-ui/core/TextField';
import useTheme from '@material-ui/core/styles/useTheme';
import Header from '../../containers/organisms/Header';
import LoginAlert from '../molecules/LoginAlert';

type Props = {
  email: string;
  password: string;
  handleChangeEmail: (ev: React.ChangeEvent<HTMLInputElement>) => void;
  handleChangePassword: (ev: React.ChangeEvent<HTMLInputElement>) => void;
  statusCode?: number;
  isLoading: boolean;
  handleLogin: (ev: React.FormEvent<HTMLFormElement>) => void;
};

const Login: FC<Props> = ({
  email,
  password,
  handleChangeEmail,
  handleChangePassword,
  statusCode,
  isLoading,
  handleLogin,
}) => {
  const theme = useTheme();
  return (
    <>
      <Header />
      <Container maxWidth="xs">
        <Card style={{ margin: `${theme.spacing(6)}px 0` }}>
          <CardHeader title="login" style={{ textAlign: 'center' }} />
          <CardContent>
            <form onSubmit={handleLogin}>
              <Box
                p={2}
                display="flex"
                flexDirection="column"
                alignItems="center"
              >
                {statusCode && <LoginAlert statusCode={statusCode} />}
                <TextField
                  label="メールアドレス"
                  variant="outlined"
                  fullWidth
                  value={email}
                  margin="normal"
                  required
                  autoComplete="email"
                  autoFocus
                  onChange={handleChangeEmail}
                />
                <TextField
                  type="password"
                  label="パスワード"
                  variant="outlined"
                  fullWidth
                  value={password}
                  margin="normal"
                  required
                  autoComplete="current-password"
                  onChange={handleChangePassword}
                />
                <Box my={2}>
                  <Button type="submit" color="primary" variant="contained">
                    ログイン
                  </Button>
                </Box>
              </Box>
            </form>
          </CardContent>
        </Card>
      </Container>
      <Backdrop style={{ zIndex: theme.zIndex.drawer + 1 }} open={isLoading}>
        <CircularProgress color="inherit" />
      </Backdrop>
    </>
  );
};

export default Login;

メモ(アプリホーム)画面

Container Component

まだ未実装なので特に処理はないです。

resources/ts/containers/pages/Memo.tsx
import React, { FC } from 'react';
import Memo from '../../components/pages/Memo';

const EnhancedMemo: FC = () => <Memo />;

export default EnhancedMemo;

Presentational Component

アプリのホーム画面になるわけですが、まだ未実装なので、とりあえず Memo とだけ表示するようにしています。

resources/ts/components/pages/Memo.tsx
import React, { FC } from 'react';
import Box from '@material-ui/core/Box';
import Container from '@material-ui/core/Container';
import Header from '../../containers/organisms/Header';

const Memo: FC = () => (
  <>
    <Header />
    <Container>
      <Box m={4}>Memo</Box>
    </Container>
  </>
);

export default Memo;

User モデル定義

name だけのシンプルな型定義です。

resources/ts/models/User.ts
export type User = {
  name: string;
};

認証に関するカスタムフック

useLogin

useMutationをラップした、ログイン処理を行うためのカスタムフック。
成功時は、返却されたログインユーザ情報を user キーにセット。

resources/ts/hooks/auth/useLogin.ts
import { useQueryClient, UseMutationResult, useMutation } from 'react-query';
import axios, { AxiosError } from 'axios';
import { User } from '../../models/User';

type FormData = {
  email: string;
  password: string;
};

const login = async (formData: FormData): Promise<User> => {
  const { data } = await axios.post('/api/login', formData);
  return data;
};

const useLogin = (): UseMutationResult<
  User,
  AxiosError,
  FormData,
  undefined
> => {
  const queryClient = useQueryClient();

  return useMutation(login, {
    onSuccess: (data) => {
      queryClient.setQueryData('user', data);
    },
  });
};

export default useLogin;

useLogout

useMutationをラップした、ログアウト処理を行うためのカスタムフック。
成功時は、user キーのキャッシュをリセット。


※2021/04/13 追記
ログアウト API のレスポンスで何も返さない場合は、logout 関数でも何も返さず void 型にした方がいいかもです。

resources/ts/hooks/auth/useLogout.ts
import { useQueryClient, UseMutationResult, useMutation } from 'react-query';
import axios, { AxiosError } from 'axios';

const logout = async (): Promise<[]> => {
  const { data } = await axios.post('/api/logout');
  return data;
};

const useLogout = (): UseMutationResult<[], AxiosError, void, undefined> => {
  const queryClient = useQueryClient();

  return useMutation(logout, {
    onSuccess: () => {
      queryClient.resetQueries('user');
    },
  });
};

export default useLogout;

名前付きエクスポート

使いやすいように、再度エクスポートしてます。

resources/ts/hooks/auth/index.ts
export { default as useLogin } from './useLogin';
export { default as useLogout } from './useLogout';

ユーザに関するカスタムフック

useGetUserQuery

useQueryをラップした、ログインユーザ情報取得処理を行うカスタムフック。


※2021/04/13 追記
useGetUserQuery の返り値の型は UseQueryResult ですね...(中身的には一緒だったりするんですが)

resources/ts/hooks/user/useGetUserQuery.ts
import { QueryObserverResult, useQuery, UseQueryOptions } from 'react-query';
import axios, { AxiosError } from 'axios';
import { User } from '../../models/User';

const getLoginUser = async (): Promise<User> => {
  const { data } = await axios.get('/api/users/me');
  return data;
};

const useGetUserQuery = <TData = User>(
  options?: UseQueryOptions<User, AxiosError, TData>
): QueryObserverResult<TData, AxiosError> =>
  useQuery('user', getLoginUser, options);

export default useGetUserQuery;

useCurrentUser

キャッシュからログインユーザ情報を取得するカスタムフック。
いろんなところで使用するのでカスタムフック化してます。

resources/ts/hooks/user/useCurrentUser.ts
import { useQueryClient } from 'react-query';
import { User } from '../../models/User';

// ログイン:User  非ログイン時:null  デフォルト:undefined
const useCurrentUser = (): User | null | undefined => {
  const queryClient = useQueryClient();
  return queryClient.getQueryData('user');
};

export default useCurrentUser;

名前付きエクスポート

使いやすいように、再度エクスポートしてます。
改行してるのは、なんとなく API リクエストとそれ以外とでわかりやすくしたかったので。

resources/ts/hooks/auth/index.ts
export { default as useGetUserQuery } from './useGetUserQuery';

export { default as useCurrentUser } from './useCurrentUser';

ものすごく長くなってしまいましたが、今回はこんな感じで実装してみました。

特に React 側に関しては、なるべくきれいに書けるようになりたいという思いもあり、りあクト!のコードを参考にしながら、コード分割をしていきました。
試行錯誤しながらやっていったので、時間もかかってコミット数も無駄に多いです🙄

TypeScript に関しては、まだ使い始めたばかりなので慣れてないのですが、型定義がきれいにハマった時の入力補完が便利でやばいですね(笑)
引き続き向き合っていきます。

課題として

  • React.Suspense を使って宣言的に書く
  • CSRF トークンの期限が切れたときの対応をいれる
  • React QUery のオプションの調整

とか、まだできそうなことはあるので、そのへんはおいおいやっていこうかとー。

この実装が何かの参考になれば幸いです。

参考リンクまとめ

株式会社ゆめみ

Discussion