概要: Reactのアイデンティティ管理では、アプリケーションでユーザーセッションを取り扱う方法が複数あります。このため、戸惑いを感じるかもしれません。このチュートリアルでは、Reactでアイデンティティ管理を行う方法を紹介します。コンテキストを利用して認証の詳細情報に関するグローバルステートを作成し、フックを利用してそれらの詳細情報を更新します。この記事で取り上げている各ファイルは、必要に応じてこちらのレポジトリで参照できます

「Reactでアイデンティティ管理を行う方法を学びましょう。コンテキストを利用して認証の詳細情報に関するグローバルステートを作成し、フックを利用してそれらの詳細情報を更新します。」

前提条件

このチュートリアルは、マシンにnodenpmがインストールされていることを前提としています。また、JavaScriptとReactについてもある程度の知識が必要です。Reactに初めて触れる場合は、最初にこちら記事をお読みください。最後に、Auth0アカウントが必要です(無料で取得できます)。アカウントをお持ちでない場合は、こちらのリンク先で作成できます

Reactでのステート管理

前述のとおり、このチュートリアルでは、ユーザー認証に関する詳細情報をグローバルステートに保存する方法を紹介します。グローバルステートは、ある瞬間におけるアプリケーションのスナップショットと捉えることができます。グローバルステートを保持することは、シングルページアプリケーション(SPA)にとって重要です。グローバルステートをReactで処理するための手法は、この数年間で大きく進化しました。

Reactアプリケーションのステートには、一般にローカルステートとグローバルステートの2種類があります。この2つには大きな違いがあります。ローカルステートは特定のページまたはコンポーネントの情報のみを表すことが多い一方で、グローバルステートはアプリケーション全体のステートを表します。このローカルステートについては、Reactのコアにソリューションがすでに組み込まれていましたが、グローバルステートについては未対応でした。

変化する可能性のあるグローバルステートをすべてのコンポーネントから収集するには、Reduxなどのパッケージをインストールして対処する必要がありました。Reactバージョン16.3のリリースに伴い、Context APIの新たなバージョンが導入され、グローバルステートを保持することが可能になりました。

コンテキストとは

Context APIは以前から存在しましたが、常に実験的な機能と見なされていました。とは言え、ReduxやReact Routerといった一般的なパッケージでは利用されていました。コンテキストを利用すると、様々なコンポーネントのプロパティへのアクセスが容易になり、各レベルで明示的にプロパティを渡す必要がなくなります。高階コンポーネント(HOC)を利用して、コンポーネントツリーの下位にあるコンポーネントからプロパティを取得できます。

現在、このAPIは一般公開されているため、React.createContext()関数を使用して新しいコンテキストを作成できます。この関数によって作成されるオブジェクトには、ProviderConsumerという2つのコンポーネントがあります。下の例は、この概念を示したものです。

import React from 'react';

const Context = React.createContext();

render() {
  return (
    <Context.Provider>
      <Context.Consumer>
        { /* custom components */ }
      </Context.Consumer>
    </Context.Provider>
  );
}

プロパティの受け渡し

Contextに格納されているプロパティを渡すには、ProviderコンポーネントとConsumerコンポーネントの両方を使用する必要があります。Providerコンポーネントには、valueと呼ばれるプロパティを渡します。このプロパティは後でConsumerコンポーネントで利用できます。上のContextの例では、Providerのプロパティは次の方法でConsumerに渡されます。

<Context.Provider value={/* object */}>
  <Context.Consumer>
    { props => /* custom components */ }
  </Context.Consumer>
</Context.Provider>

コンテキストを更新する

ContextProviderには、Consumerに渡す値を設定するためのオブジェクトが渡されますが、この値はProviderのレベルで変化させることができます。このためには、Reactのローカルステートを使用します。具体的には、このProviderに初期ステート値を渡し、setState()メソッドを使用してこのステートを変化させる関数を渡します。この処理の実装は、以下のコード例のようになります。

import React from 'react';
const Context = React.createContext();
export default class App extends React.Component {
  state = {
    value: 'foo'
  }
  updateState(value) {
    this.setState({ value });
  }
  render() {
    return (
      <Context.Provider value={ { value: this.state.value, updateState: this.updateState } }>
        <Context.Consumer>
          { props => /* custom components */ }
        </Context.Consumer>
      </Context.Provider>
    );
  }
}

フックとは

Reactにフックが正式に導入されたため(バージョン16.8)、上のコード例は大幅に簡素化できます。useStateuseReducerといった多数の事前定義フックが導入されているほか、独自(カスタム)のフックを作成することもできます。ユースケースがきわめて多いフックの1つに、ローカルステート管理へのショートカットを提供するuseStateフックがあります。上のコード例に適用してみましょう。

import React from 'react'

const Context = React.createContext();

const initialState = {
  value: 'foo'
}

const [state, updateState] = React.useState(initialState);

const App = () => (
  <Context.Provider value={ { value: state.value, updateState } }>
      <Context.Consumer>
          { props => // … custom components }
      </Context.Consumer>
  </Context.Provider>
);

export default App;

useReducerフック

上のコード一式は、扱っているコンポーネントが小規模で、ステートをローカルでのみ更新すればよい場合は、問題なく機能します。より規模の大きいアプリケーションや、より高度なユースケースの場合は、代わりにuseReducerフックを使用します。

useReducerフックを使用すると、発生するイベントのタイプに関係なく、イベントに基づいて初期オブジェクトを更新できます。useStateフックとの違いは、この初期オブジェクトがReactに組み込まれてローカルステート管理とは無関係であるため、アプリケーション内部でグローバルに適用できることです。

Reduxなどのグローバルステート管理ライブラリを使用した経験がある方は、こうした方式に馴染みがあるかもしれません。使用経験がない方のために、useReducerフックを適用する方法を次のコード例に示します。適用方法はReduxと似ています。

const initialState = {
  value: 'foo'
}

reducer(state, action) {
  switch(action.type) {
    case 'updateValue':
      return {
        …state,
        value: action.payload
      }
    default:
      return state
  }
}

const [state, dispatch] = React.useReducer(reducer, initialState);

上の例で、useReducerフックは定数reducerinitialStateをパラメータとしてとっています。出力は、リデューサから返される値と、リデューサ関数を呼び出すための関数です。この関数はアクションのみをパラメータとしてとり、現在のステートを継承します。この場合、リデューサはアクションのtypeというフィールドを探し、タイプがupdateValueと等しければ、そのアクションのpayloadの値でステートを更新します。タイプが異なる場合、リデューサはステートの現在の値を返します。initialStateのvalueフィールドを更新するこの関数は、以下の方法で呼び出すことができます。

{
  () => dispatch({ type: "updateValue", payload: "new value" });
}

ディスパッチ関数のパラメータとして使用されるアクションオブジェクトには、typeフィールドとpayloadフィールドがあります。リデューサはこれらのフィールドを使用してinitialStatevalueフィールドを更新します。

フックを使用してコンテキストを更新する

useReducerフックを使用して、コンテキストのProviderに渡す値を設定し、この値を変更することもできます。その場合は、ディスパッチ関数も渡します。この処理を行う方法を実践するため、関数イベントの管理に使用できるアプリケーションという形で、新しいプロジェクトを作成しましょう。

新しいReactプロジェクトを作成する

最初に、Create React Appを使用して新しいReactプロジェクトを作成します。ほとんどのReactアプリケーションに適した構成がこのアプリによって提供されます。Create React Appをあまり使用したことがない場合は、こちらのドキュメントをご覧ください。概要と使用方法が記載されています。

ターミナルで以下のコマンドを実行すると、新しいアプリケーションを作成できます。最後の部分new-projectは別の名前に置き換えてかまいません。これは、プロジェクトの名前を定義する設定値です。

npx create-react-app new-project

上記を実行後、new-projectディレクトリに移動して、任意のコードエディタでプロジェクトを開くことができます。次のセクションでは、srcディレクトリにあるファイルに変更を加えます。

コンテキストのプロバイダとコンシューマを追加する

src/App.jsファイルを開いて、コードを以下の内容で置き換えます。

// src/App.js
import React from 'react';

const MeetupContext = React.createContext();

const initialState = {
  title: 'Auth0 Online Meetup',
  date: Date()
};

const App = () => (
  <MeetupContext.Provider value={initialState}>
    <MeetupContext.Consumer>
      {props => (
        <div>
          <h1>{props.title}</h1>
          <span>{props.date}</span>
        </div>
      )}
    </MeetupContext.Consumer>
  </MeetupContext.Provider>
);

export default App;

上のコードブロックは、MeetupContextという形でContext APIを実装したものです。このコンテキストには、initialStateを受け取るProviderと、この情報を表示するConsumerが含まれています。

今回のチュートリアルではテストとスタイル設定を取り上げないため、上の作業の後は、src/App.test.jssrc/App.csssrc/Logo.svgの各ファイルを削除してかまいません。

ここで、アプリ(npm start)を実行した後にブラウザでアクセスすると、このアプリケーションの最初のバージョンが表示されます。このバージョンでは、タイトルと現在の日時が表示されます。

作成したReactアプリケーションの最初のバージョンの実行

次に、この架空のミートアップへの参加者を何名か設定して、この情報を拡張します。このため、新しいフィールドをinitialStateオブジェクトに追加する必要があります。このフィールドをattendeesと命名して、無作為の人名が保持されている配列を割り当てます。

// src/App.js

// ... import React, MeetupContext ...

const initialState = {
    title: 'Auth0 Online Meetup',
    date: Date(),
    attendees: ['Bob', 'Jessy', 'Christina', 'Adam']
};

// ... const App, export App ...

次に、この参加者リストがアプリケーションで使用されるようにします。

// src/App.js

// ... import React, MeetupContext, and initialState ...

const App = () => (
  <MeetupContext.Provider value={initialState}>
    <MeetupContext.Consumer>
      {props => (
        <div>
          <h1>{props.title}</h1>
          <span>{props.date}</span>
          <div>
            <h2>{`Attendees (${props.attendees.length})`}</h2>
            {props.attendees.map(attendant => (
              <li>{attendant}</li>
            ))}
          </div>
        </div>
      )}
    </MeetupContext.Consumer>
  </MeetupContext.Provider>
);

export default App;

これで、ミートアップの情報だけでなく参加者のリストも表示されるようになります。以下のセクションでは、このミートアップに参加登録するための機能を追加していきます。

ネストしたコンテキスト

このミートアップに実際に参加登録できるようにするには、ユーザー用の新しいContextを追加する必要があります。Contextの複数のProvidersConsumersをネストすると、propsにグローバルにアクセスできるようになります。これを実際の挙動で確認するには、src/App.jsファイルを以下のように更新します。

// src/App.js

// ... import React ...

const MeetupContext = React.createContext();
const UserContext = React.createContext();

// ... initialState, and App ...

ユーザー用のContextには初期値も必要であるため、MeetupContextの値を保持している現在のinitialState定数を拡張します。このためには、ミートアップに関するフィールドを、ネストされた新しいオブジェクトmeetupに移動します。これにより、nameフィールドのみを使用して、ユーザーの新しいプロパティをこのオブジェクトに追加できます。

// src/App.js

// ... import React and context objects definintion ... 

const initialState = {
  meetup: {
    title: 'Auth0 Online Meetup',
    date: Date(),
    attendees: ['Bob', 'Jessy', 'Christina', 'Adam']
  },
  user: {
    name: 'Roy'
  }
};

// ... const App ...

次に、新しく作成したUserContextをアプリケーションに追加して、このコンテキストでinitialStateuserフィールドを使用します。MeetupContextinitialStatemeetupフィールドを値として使用することを指定する必要もあります。MeetupContextUserContext内に配置しています。このコンテキストがユーザーの他のコンポーネント(プロフィールのページなど)でも今後必要になる可能性があるためです。

なお、ユーザーのコンシューマはMeetupContextのすぐ上に配置できます。propsとしてではなく変数userとしてプロバイダからの値を返す必要があります。そうしないと、定数propsの重複宣言になります。明確にするために、ミートアップのコンシューマからの戻り値の名前も変更することをおすすめします。

最終的に、App定数は以下のようになります。

// src/App.js

// ... import React, context objects definition, and initialState

const App = () => (
  <UserContext.Provider value={initialState.user}>
    <UserContext.Consumer>
      {user => (
        <MeetupContext.Provider value={initialState.meetup}>
          <MeetupContext.Consumer>
            {meetup => (
              <div>
                <h1>{meetup.title}</h1>
                <span>{meetup.date}</span>
                <div>
                  <h2>{`Attendees (${meetup.attendees.length})`}</h2>
                  {meetup.attendees.map(attendant => (
                    <li>{attendant}</li>
                  ))}
                </div>
              </div>
            )}
          </MeetupContext.Consumer>
        </MeetupContext.Provider>
      )}
    </UserContext.Consumer>
  </UserContext.Provider>
);

render()関数でユーザーとミートアップのコンテキストが両方が返されると、次のステップで、このユーザーがこのミートアップに参加登録できるようになります。

useReducerフックを実装する

ユーザーがこのミートアップに参加登録すると、ユーザーの名前が参加者のリストに追加されるようにしたいと考えています。そのため、MeetupContext.Providerの初期値を変化させる必要があります。、前述のとおり、この処理はuseReducerフックを使用して行うことができます。

まず、MeetupContextProviderが返され、ユーザーのContextとすべての子の両方をプロパティとしてとる新しいコンポーネントを作成します。このコンポーネントでは、useReducerフックを追加し、このフックから返されるdispatch関数を使用してMeetupContextの値を拡張できます。

// src/App.js

// ... import React, contexts, and initialState ...

const MeetupContextProvider = ({ user, ...props }) => {
  const [state, dispatch] = React.useReducer(reducer, initialState.meetup);
  return (
    <MeetupContext.Provider
      value={ {
        ...state,
        handleSubscribe: () =>
          dispatch({ type: 'subscribeUser', payload: user.name }),
        handleUnSubscribe: () =>
          dispatch({ type: 'unSubscribeUser', payload: user.name })
      } }
    >
      {props.children}
    </MeetupContext.Provider>
  );
};

// ... const App ...

コード例に示すように、このuseReducerフックはreducerもパラメータとしてとります。このパラメータはMeetupContextProviderコンポーネントの上に直接追加できます。このreducerはステートと受け取ったアクションの両方をパラメータとしてとります。タイプがsubscribeUserのアクションを受け取ると、そのアクションのpayloadフィールドを参加者の配列に追加します。

// src/App.js

// ... import React, contexts, and initialState ...

const reducer = (state, action) => {
  switch (action.type) {
    case 'subscribeUser':
      return {
        ...state,
        attendees: [...state.attendees, action.payload],
        subscribe: true
      };
    default:
      return state;
  }
};

// ... MeetupContextProvider and App definition ...

同様に、reducer関数を拡張して、ミートアップへのユーザーの参加登録を解除できます。

// src/App.js

// ... import React, contexts, and initialState ...

const reducer = (state, action) => {
  switch (action.type) {
    case 'subscribeUser':
      return {
        ...state,
        attendees: [...state.attendees, action.payload],
        subscribed: true
      };
    case 'unSubscribeUser':
      return {
        ...state,
        attendees: state.attendees.filter(
          attendee => attendee !== action.payload
        ),
        subscribed: false
      };
    default:
      return state;
  }
};

// ... import React, contexts, and initialState ...

次に、このアクションをミートアップのConsumer内で使用できるようになったので、この関数を呼び出すボタンをアプリケーションに追加する必要があります。ユーザーのContextMeetupContextProviderに送信する必要もあります。これは、ユーザーを参加者のリストに追加するのに必要です。

結果として、Appコンポーネントは以下のようになります。

// src/App.js

// ... import React, context objects, etc ...

const App = () => (
  <UserContext.Provider value={initialState.user}>
    <UserContext.Consumer>
      {user => (
        <MeetupContextProvider user={user}>
          <MeetupContext.Consumer>
            {meetup => (
              <div>
                <h1>{meetup.title}</h1>
                <span>{meetup.date}</span>
                <div>
                  <h2>{`Attendees (${meetup.attendees.length})`}</h2>
                  {meetup.attendees.map(attendant => (
                    <li>{attendant}</li>
                  ))}
                  <p>
                    {!meetup.subscribed ? (
                      <button onClick={meetup.handleSubscribe}>
                        Subscribe
                      </button>
                    ) : (
                      <button onClick={meetup.handleUnSubscribe}>
                        Unsubscribe
                      </button>
                    )}
                  </p>
                </div>
              </div>
            )}
          </MeetupContext.Consumer>
        </MeetupContextProvider>
      )}
    </UserContext.Consumer>
  </UserContext.Provider>
);

export default App;

このアプリケーションをブラウザで確認してみると、「Subscribe」ボタンをクリックすることで参加者のリストを更新できることがわかります。このボタンをクリックするたびにContextが更新され、Consumerが新たにレンダリングされます。

認証されていないユーザーがミートアップに参加登録することは望ましくないため、次のセクションでは、このアプリケーションに認証を追加します。

アプリケーションに認証を追加する

認証にはAuth0を利用することから、ダッシュボードにAuth0アプリケーションを作成する必要があります。したがって、「Applications」セクションに移動し、「Create Application」ボタンをクリックします。ボタンをクリックした後、フォームに以下のように入力します。

  • Application Name:React + Hooks
  • Application Type:Single Page Web Applications

次に、「Create」ボタンをクリックした後、「Settings」タブに移動します。このタブで、http://localhost:3000/?callbackを「Allowed Callback URLs」_ _ フィールドに追加し、「Save Changes」ボタンをクリックする必要があります。その後、このページは開いたままにしておきます。後ほど、このページのいくつかの値をコピーする必要があります。

Auth0を使用して安全な方法で認証をアプリケーションに問追加するには、2つのnpmパッケージをインストールすることが必要です。1つはdotenvで、、ローカル環境変数をアプリケーションに追加するために使用されます。もう1つはauth0-jsで、Auth0が提供している公式のクライアントサイドJavaScript SDKです。これらのパッケージをインストールするには、以下のコードをターミナルで実行します(必要に応じて、Ctrl+Cキーを押して現在のプロセスを終了してください)。

npm install dotenv auth0-js

この2つのパッケージをインストールすると、.envファイルをプロジェクトのルートに作成して、アプリケーションキーをローカル環境に保存できます。したがって、このファイルを作成し、以下の内容を追加します。

# ./.env
REACT_APP_AUTH0_DOMAIN=[DOMAIN]
REACT_APP_AUTH0_CLIENT_ID=[CLIENT ID]

[DOMAIN][CLIENT ID]は、お使いの環境固有のAuth0プロパティで置き換える必要があります。このため、ブラウザに戻って、開いたままにしたページのDomainプロパティとClient IDプロパティをコピーして、この2つのプロパティで.envファイル内のプレースホルダを置き換えます。

次に、認証プロセスを処理するロジックが含まれているファイルを作成する必要があります。このためには、新しいファイルをAuth.jsという名前でsrcディレクトリに作成し、以下のコードブロックをこのファイルに追加します。

// src/Auth.js

import auth0 from 'auth0-js';

export default class Auth {

  constructor() {
    this.auth0 = new auth0.WebAuth({
      domain: process.env.REACT_APP_AUTH0_DOMAIN,
      audience: `https://${process.env.REACT_APP_AUTH0_DOMAIN}/userinfo`,
      clientID: process.env.REACT_APP_AUTH0_CLIENT_ID,
      redirectUri: 'http://localhost:3000/?callback',
      responseType: 'id_token',
      scope: 'openid profile'
    });

    this.handleAuthentication = this.handleAuthentication.bind(this);
    this.signIn = this.signIn.bind(this);
  }

  signIn() {
    this.auth0.authorize();
  }

  getProfile() {
    return this.profile;
  }

  handleAuthentication() {
    return new Promise((resolve, reject) => {
      this.auth0.parseHash((err, authResult) => {
        if (err) return reject(err);
        if (!authResult || !authResult.idToken) {
          return reject(err);
        }

        this.idToken = authResult.idToken;
        this.profile = authResult.idTokenPayload;
        // set the time that the id token will expire at
        this.expiresAt = authResult.idTokenPayload.exp * 1000;
        resolve();
      });
    });
  }
}

Authクラスには、認証を処理し、アプリケーションに返されるトークンを保存するためのすべてのメソッドが定義されています。

ここで、src/App.jsファイルを開いて新しいクラスを使用します。

// src/App.js
// ... import React ...

import Auth from './Auth';

const auth = new Auth();

// ... everything else ...

次に、authオブジェクトを使用するためにUserContextというオブジェクトを作成する必要があります。この新しいオブジェクトの振る舞いは、MeetupContextと似ています。

// src/App.js

// ... import React, context object, auth, etc ...

const UserContextProvider = props => {
  const [state, dispatch] = React.useReducer(reducer, initialState.user);
  auth.handleAuthentication().then(() => {
    dispatch({
      type: 'loginUser',
      payload: {
        authenticated: true,
        user: auth.getProfile()
      }
    });
  });
  return (
    <UserContext.Provider
      value={ {
        ...state,
        handleLogin: auth.signIn
      } }
    >
      {props.children}
    </UserContext.Provider>
  );
};

// ... MeetupContextProvider and App ...

上のコードブロックでは、ユーザーのコンテキストのProviderを作成しています。このプロバイダもuseReducerフックを使用します。このプロバイダの内部では、Auth0クライアントの2つの関数を使用して、認証プロセスを初期化し、Auth0からトークンが返されたことを検証します。

認証を初期化するには、ユーザーのコンテキストで使用できるhandleLogin関数を呼び出します。この処理が正常に完了すると、ユーザーをリデューサで検証済みとして設定するアクションをhandleAuthenticationがディスパッチします。この処理のために、別のcaseステートメントをリデューサに追加します。

// src/App.js
const reducer = (state, action) => {
  switch (action.type) {

    // ... leave subscribeUser and unSubscribeUser untouched ...

    case 'loginUser':
      return {
        ...state,
        isAuthenticated: action.payload.authenticated,
        name: action.payload.user.name,
      };
    default:
      return state;
  }
};

これで、ユーザーがログインしてミートアップに参加登録できるよう、Appコンポーネントに変更を加えることが可能になります。

// src/App.js

// ... everything else ...

const App = () => (
  <UserContextProvider>
    <UserContext.Consumer>
      {user => (
        <MeetupContextProvider user={user}>
          <MeetupContext.Consumer>
            {meetup => (
              <div>
                <h1>{meetup.title}</h1>
                <span>{meetup.date}</span>
                <div>
                  <h2>{`Attendees (${meetup.attendees.length})`}</h2>
                  {meetup.attendees.map(attendant => (
                    <li key={attendant}>{attendant}</li>
                  ))}
                  <p>
                    {user.isAuthenticated ? (
                      !meetup.subscribed ? (
                        <button onClick={meetup.handleSubscribe}>
                          Subscribe
                        </button>
                      ) : (
                        <button onClick={meetup.handleUnSubscribe}>
                          Unsubscribe
                        </button>
                      )
                    ) : (
                      <button onClick={user.handleLogin}>Login</button>
                    )}
                  </p>
                </div>
              </div>
            )}
          </MeetupContext.Consumer>
        </MeetupContextProvider>
      )}
    </UserContext.Consumer>
  </UserContextProvider>
);

export default App;

プロジェクトを再起動して(npm start)ブラウザで開くと、アプリケーションにサインインできるようになります。「Subscribe」ボタンをクリックすると、参加者のリストに、「Roy」の代わりに自分の名前またはメールアドレスが表示されることを確認できます。

Auth0を利用してReactアプリケーションで認証を処理する

「React、フック、Context APIを使ってユーザーIDを簡単に処理する方法がわかった。」

まとめ

この記事では、広く利用されている2つのReact APIで認証を処理する方法を学びました。つまり、フックとContext APIです。まずReactアプリケーションをゼロから作成した後、これらのAPIのそれぞれがどのような形で機能するのかを個別に学びました。最後に、それらすべてを統合する方法、Auth0と併用してアイデンティティ管理を簡単に行う方法を学びました。

下のコメントボックスで、今回のアプローチについてご意見をぜひお聞かせください。