Reactのreduxを用いたログイン処理周りの実装【初学者のReact✗Railsアプリ開発第4回】

#やったこと
フロントエンド: React, バックエンド: Rails(APIモード）のアプリ開発における基本的なログイン処理周りの実装
![test200111.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/367226/63864c6f-ff09-dc0a-8d1c-eb1fa6cc55e1.gif)

* 未ログインなら、/loginにリダイレクト
* 確認中は「Loading」を表示させる
* Railsのログイン機能は、devise_auth_tokenを利用: [【Ruby on Rails】devise_token_authでTwitterログイン機能の実装](https://qiita.com/dl10yr/items/a338ca956090237d8ca6)
* ログイン中のユーザーの情報は、reduxを用いて状態管理する。
![スクリーンショット 2020-01-12 15.17.24.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/367226/26cbd1b3-f2a1-25be-70ee-d3b0da10f901.png)


#Rails
まずは、ログイン中のユーザーをjsonとして返すために、userコントローラーを作成します。

```
$ docker-compose run api rails g controller users
```
```ruby:users_controller
module Api
  module V1
    class UsersController < ApplicationController
      before_action :authenticate_api_v1_user!

      def currentuser
        @user = current_api_v1_user
        render json: { status: 'SUCCESS', message: 'Loaded the user', data: @user}
      end

    end
  end
end
```
* current_userを使うと、簡単にログイン中のユーザーを返してくれる。ログインしてなかったら、エラーを返す。ルートのパスが/api/v1/user/なので、current_api_v1_userになる。

```ruby:omniauth_callbacks_controller
          def render_data_or_redirect(message, data, user_data = {})
            ##if Rails.env.production?  コメントアウト！
              if ['inAppBrowser', 'newWindow'].include?(omniauth_window_type)
                render_data(message, user_data.merge(data))


              # 通常、elsif内の処理が実行されるはず。
              elsif auth_origin_url
                redirect_to DeviseTokenAuth::Url.generate(auth_origin_url, data.merge(blank: true))


              else
                fallback_render data[:error] || 'An error occurred'
              end
            else
              # @resource.credentials = auth_hash["credentials"]

              ##render json: @resource, status: :ok コメントアウト！
            end
          end
```
* http://127.0.0.1:3000/api/v1/auth/twitter?auth_origin_url=localhost:8000 にアクセスすると、
「http://localhost:8000/?auth_token=XXX&blank=true&client_id=XXXX&config=&expiry=XXXX&uid=XXX 」にリダイレクトされるように処理が行われている。auth_tokenなどはreact側のAuth.js内でlocalstorageに保存される処理がされる。詳細は下に記載。


#React

###要約
* Routingでログインが必要なコンポーネントは、Authコンポーネントを経由させて出力させる。
* Authコンポネートで、未ログイン・ログイン済みを確認して、未ログインならリダイレクト、ログイン済みなら、パスのコンポーネントを表示させた上で、reduxのglobal stateにログイン中ユーザーの情報をセットする。

###redux
**なんで必要？**
-> state管理（状態管理）を楽にするため。reduxが無いと、親から子、子から親などコンポーネント間のデータの受け渡しが必要であるが、reduxを使うと、一つの情報源に全てのコンポーネントが直接アクセスできる。

**今回はなぜ使った？**
-> ログイン中ユーザーの情報はどのコンポーネントでも使いうるため、"Global State"として、全体で管理したかったから。

###App.js
```javascript:App.js
import React, { Component } from 'react';
import './App.css';
import Home from './containers/Home';
import Term from './containers/Term';
import Info from './containers/Info';
import Auth from './containers/Auth';
import Login from './containers/Login';


import { BrowserRouter, Route, Switch } from 'react-router-dom'

class App extends Component {
  render() {
    return (
      <div className="App">
        <BrowserRouter>
          <Switch>
            <Route path="/login" component={Login} />
            <Route path="/info" component={Info} />
            <Route path="/term" component={Term} />
            <Auth>
              <Route exact path="/" component={Home} />
            </Auth>
          </Switch>
        </BrowserRouter>
      </div >
    );
  }
}

export default App;
```

###Login.js
```javascript:Login.js
import React from 'react';
import PropTypes from 'prop-types';
import { withStyles } from '@material-ui/core/styles';
import Button from '@material-ui/core/Button';


const styles = theme => ({
});

class Login extends React.Component {
  loginTwitter() {
    window.location.href = process.env.REACT_APP_API127_URL + '/api/v1/auth/twitter?auth_origin_url=' + process.env.REACT_APP_BASE_URL;
  }

  render() {
    const { classes } = this.props;
    return (
      <div className={classes.login}>
        <p>未ログイン</p>
        <Button variant="contained" color="secondary" onClick={this.loginTwitter}>
          Twitterで登録・ログイン
        </Button>
      </div>
    )
  }
}

Login.propTypes = {
  classes: PropTypes.object.isRequired,
};

export default withStyles(styles, { withTheme: true })(Login);
```
* ボタンをクリックすると、loginTwitter()が実行される。
* loginTwitter()では、以前、実装したように、http://127.0.0.1:3000/api/v1/auth/twitter?auth_origin_url=localhost:8000 にアクセスし、Rails側で処理され、ログイン処理後、localhost:8000/に戻ってくる。


###Home.js
```javascript:Home.js
import React from 'react';
import PropTypes from 'prop-types';
import { withStyles } from '@material-ui/core/styles';
import Button from '@material-ui/core/Button';

const styles = theme => ({
  home: {
    backgroundColor: "red",
    width: "50%"
  },
});

class Home extends React.Component {
  render() {
    const { classes } = this.props;
    return (
      <div className={classes.home}>
        <p>ログイン済み</p>
      </div>
    )
  }
}

Home.propTypes = {
  classes: PropTypes.object.isRequired,
};

export default withStyles(styles, { withTheme: true })(Home);
```

###Auth.js
```javascript:Auth.js
import React from 'react';
import PropTypes from 'prop-types';

import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';

import * as actions from '../actions';
import _ from 'lodash';
import { Redirect, Route } from 'react-router-dom'
import "normalize.css";

import queryString from 'query-string';
import axios from 'axios';

const styles = theme => ({
});

class Auth extends React.Component {
  constructor(props) {
    super()
    this.state = {
      isLoading: true,
    }
  }

  componentDidMount() {
    let tokens = queryString.parse(_.get(this, "props.location.search"))
    if (!_.isEmpty(tokens.auth_token)) {
      localStorage.setItem('auth_token', tokens.auth_token)
      localStorage.setItem('client_id', tokens.client_id)
      localStorage.setItem('uid', tokens.uid)
      window.location.href = process.env.REACT_APP_BASE_URL
    } else {
      this.setState({
        isLoading: true,
      })

      const auth_token = localStorage.auth_token
      const client_id = localStorage.client_id
      const uid = localStorage.uid
      axios.get(process.env.REACT_APP_API_URL + '/api/v1/user/currentuser', {
        headers: {
          'access-token': auth_token,
          'client': client_id,
          'uid': uid
        }
      })
        .then((response) => {
          this.setState({
            isLoading: false,
            isLoggedin: true,
          });
          this.props.actions.setCurrentUserSuccess(response.data.data)
        })
        .catch(() => {
          this.setState({
            isLoading: false,
            isLoggedin: false,
          });
        });
    }
  }

  render() {
    const { CurrentUserReducer } = this.props;
    const isLoggedin = this.state.isLoggedin;
    const isLoading = this.state.isLoading;
    const { classes } = this.props;

    console.log(isLoading)

    if (isLoading) {
      return (
        <div>loading</div>
      )
    } else {
      if (isLoggedin) {
        return (
          <Route children={this.props.children} />
        )
      } else {
        console.log(isLoading)
        return (
          <Redirect to={'/login'} />
        )
      }
    }
  }
}

Auth.propTypes = {
  classes: PropTypes.object.isRequired,
};

const mapState = (state, ownProps) => ({
  CurrentUserReducer: state.CurrentUserReducer,
});
function mapDispatch(dispatch) {
  return {
    actions: bindActionCreators(actions, dispatch),
  };
}

export default connect(mapState, mapDispatch)(Auth);
```
* このコンポーネントのコードこそが、今回の記事のいちばん重要なポイント。
* componentDidmount()は、render後に実行される。初めのif文では、ログイン処理を行ってrails側から戻ってきているかどうかを判別。
* 先程、説明したように、rails側でログイン処理が終わると、devise_token_authでの認証に必要なtokenがクエリとしてURLに乗せられて、/に戻ってきます。if文ではqueryStringというモジュールで、URLの文字列を分析し、?auth_token="..."が存在しているかどうかを判定し、存在していたら、認証に必要なトークンなどをlocalstorageに保存しています。
* else内では、ログインしているかどうかを判定するために、rails側で先程実装したように、/api/v1/user/currentuserにアクセスして、ログインをしていたら、actionを発行して、reduxを使用して、global stateとして、CurrentUserReducerにログイン中のユーザー情報を保存するようにしています。
* localのstateとしては、isLoggedin（ログインしているかどうか）とisLoading（currentuserを確認中かどうか）を管理していて、これらを使って、Loadingを表示するか、そのままログイン済みユーザーのみ表示させたいコンポーネントへのアクセスを許可するか、/loginにリダイレクトするかを決めています。
* Reactのライフサイクルを理解しきっていないため、タイミングの調節が結構苦労しました。

#redux
###実装の概要
* index.jsでcreateStoreする、Providerで配る。
* reducers/rootReducer.jsで複数のreducerを管理している。
* actions/index.js内で、アクションの内容を記述している。（今回は、setCurrentUserSuccessしか使っていませんが...）
* reducers/CurrentUserReducer.js内でdispatchされたactionに対するstateの変更の記述をしている。

###起こっていること
1. Auth.js内のthis.props.actions.setCurrentUserSuccess(response.data.data)でactions/index.jsに対して、データが渡されて、actionのオブジェクトがcreateされる。
2. createされたactionのオブジェクトはreducers/CurrentUserReducer.jsに渡されて、stateの変更が行われる。

###index.js
```javascript:index.js
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import { MuiThemeProvider } from '@material-ui/core/styles';
import { theme } from './materialui/theme'
import { BrowserRouter as Router } from 'react-router-dom';

import createBrowserHistory from 'history/createBrowserHistory';
import { Provider } from 'react-redux';
import rootReducer from './reducers/rootReducer';
import thunk from 'redux-thunk'
import logger from 'redux-logger'
import { createStore, applyMiddleware, compose } from 'redux';


const history = createBrowserHistory();

const store = createStore(
  rootReducer,
  applyMiddleware(thunk, logger)
);

ReactDOM.render(
  <Provider store={store}>
    <MuiThemeProvider theme={theme} >
      <Router>
        <App />
      </Router>
    </MuiThemeProvider>
  </Provider >
  , document.getElementById('root'));
```

###actions/index.js
```javascript:index.js
import axios from 'axios'

export const setCurrentUser = () => {
  return (dispatch) => {
    const auth_token = localStorage.auth_token
    const client_id = localStorage.client_id
    const uid = localStorage.uid

    return axios.get(process.env.REACT_APP_API_URL + '/api/v1/user/currentuser', {
      headers: {
        'access-token': auth_token,
        'client': client_id,
        'uid': uid
      }
    })
      .then(response => dispatch(setCurrentUserSuccess(response.data.data)))
      .catch(error => dispatch(setCurrentUserFailure(error)))
  };
}

export const setCurrentUserRequest = () => ({
  type: 'SET_CURRENTUSER_REQUEST',
})


export const setCurrentUserSuccess = (json) => ({
  type: 'SET_CURRENTUSER_SUCCESS',
  items: json,
})

export const setCurrentUserFailure = (error) => ({
  type: 'SET_CURRENTUSER_FAILURE',
  items: error,
})
```

###reducers/CurrentUserReducer.js
```javascript:CurrentUserReducers.js
const initialState = {
  isLoggedin: false,
  isLoading: false,
  items: []
};

const CurrentUserReducer = (state = initialState, action) => {
  switch (action.type) {
    case 'SET_CURRENTUSER_REQUEST':
      return {
        ...state,
        isLoggedin: false,
        isLoading: true,
        items: [],
      };
    case 'SET_CURRENTUSER_SUCCESS':
      if (!action.items) {
        return {
          ...state,
          isLoggedin: false,
          isLoading: false,
          items: action.items,
        };
      } else {
        return {
          ...state,
          isLoggedin: true,
          isLoading: false,
          items: action.items,
        };
      }

    case 'SET_CURRENTUSER_FAILURE':
      return {
        ...state,
        isLoggedin: false,
        isLoading: false,
        error: action.error,
      };
    default:
      return state;
  }
};

export default CurrentUserReducer;
```

###reducers/rootReducer.js
```javascript:rootReducer.js
import { combineReducers } from 'redux'
import { routerReducer } from 'react-router-redux'
import CurrentUserReducer from './CurrentUserReducer'

const rootReducer = combineReducers({
  CurrentUserReducer,
  router: routerReducer,
})

export default rootReducer
```