Reactを用いたクイズアプリ開発


Mitch Gavan氏による記事 Create a quiz with Reactの日本語訳です。

著者から許可をいただき、記事の日本語訳を公開しています。


react-quiz.jpg

本記事では、Reactを用いてクイズアプリを作成します。Facebookによる Create React App を使うため、ビルドに関する環境設定を行う必要がありません。Reactのプロジェクトを作成するには多くの手順が必要ですが、 Create React App を使うと、Webpack、Babel、ESLint等の最新のワークフロー構成でプロジェクトを作成することができるため、Reactを用いた開発をすぐに始めることができます。本チュートリアルを完了後のクイズアプリは こちら から確認できます。

初期設定

バージョン4以上のNode.jsがインストールされていることを確認してください。任意のディレクトリーで次のようにコマンドを打ち込み、Reactを用いたアプリケーションの雛形を作成します。

$ npx create-react-app react-quiz
$ cd react-quiz

ここでは react-quiz と名前をつけましたが、プロジェクト名は何でも構いません。プロジェクトを作成すると、 react-quiz という名前で新しくフォルダが作成されます。フォルダ内にはプロジェクトの雛形が生成されて、依存ライブラリーがnode_modules内にインストールされます。プロジェクトは次のような構成になります。

react-quiz/
  README.md
  index.html
  favicon.ico
  node_modules/
  package.json
  src/
    App.css
    App.js
    index.css
    index.js
    logo.svg

インストール完了後、次のようにアプリケーションを起動することができます。

$ npm start

ブラウザで http://localhost:3000 を開くと、アプリケーションを試すことができます。雛形のコードに慣れるために、プロジェクト内のコードをじっくり見てください。プロジェクト内のコードを変更すると、自動でアプリケーションがリロードされます。もしビルドエラーやリントの警告があった場合、コンソール上で確認することができます。最新の開発環境ができました。では、クイズアプリの開発を始めましょう。

作成するアプリケーションについて

作成するクイズは、質問は複数の選択肢を持ち、どの選択肢を選ぶかによって異なる結果が表示されるアプリケーションです。今回扱うデータは、3つのゲーム開発会社(任天堂、Sony、Microsoft)のうち、どの会社のゲームが一番好きかを判定するためのものです。このクイズでユーザーは、5問の質問のなかで3つの選択肢のうち1つを選択します。質問と答えの数は増やすことが可能です。

スクリーンショット 2019-08-05 21.35.48.png

スクリーンショット 2019-08-05 21.36.45.png

アプリケーションを構築するにあたり、Facebookが提唱する Thinking in React で示される手法で開発を進めます。この手法では、小さなコンポーネントを組み合わせながらアプリケーションを構築します。このクイズアプリでは、次の4つのコンポーネントを定義します。

  • Question (質問)
  • Question count (質問のカウント)
  • Answer options (回答の選択肢)
  • Result (結果)

これらのコンポーネントは、クイズアプリをビルドする際、Containerコンポーネントで組み合わされます。

1つ目のコンポーネントを作成する

まず、プロジェクトルートで次のコマンド実行して、prop-typesをインストールします。

$ npm install prop-types

次に、 components という名前のディレクトリを作成して、その中に Question.js という名前で新しくファイルを作成します。ここに1つ目のReactコンポーネントを書きます。次のようにファイルを編集してください。

import React from 'react';
import PropTypes from 'prop-types';

function Question(props) {
  return (
    <h2 className="question">{props.content}</h2>
  );
}

Question.propTypes = {
  content: PropTypes.string.isRequired
};

export default Question;

なぜクラスを使わずにコンポーネントを作成したか疑問に思った方もいるでしょう。クラスを使わないのは、このコンポーネントが stateをもたないpresentationコンポーネント であるからです。ここではクラスを使わないことにより、クラスに由来する多くの記述を省くことができます。

Reactでは、コンポーネントを2つの種類(presentationalcontainer )に分類する慣習があります。物事の動作に関わるものは container コンポーネント、見た目に関わるものは presentational コンポーネントと分類するのが基本的な考え方です。詳しくはこの記事を参照してください。

ここで定義した Question コンポーネントは、質問を表示するだけのものです。質問の内容は、container コンポーネントから propsを通して渡されます。 Reactにおいて propTypes (property typesの省略形) は、開発者を手助けするために使われます。 propTypes は prop の型と、どの props が必要かをを定義します。

次のようにimport文を書き、Questionコンポーネントを mainコンポーネント (App.js) に追加します。

import Question from './components/Question';

App コンポーネントのrender関数で Questionコンポーネントを使います。

render (
  <div className="App">
    <div className="App-header">
      <img src={logo} className="App-logo" alt="logo" />
      <h2>React Quiz</h2>
    </div>
    <Question content="What is your favourite food?" />
  </div>
);

ここでは、コンポーネントのテストのために、文字列を propsのcontentに渡しています。この箇所は後に変更します。もしアプリケーションを試しに起動すると、質問が What is your favourite food? として表示されるはずです。

他のコンポーネントを作成する

次に、QuestionCountコンポーネントを作成します。 components フォルダの中に、 QuestionCount.js という名前でファイルを作成して、次のように編集します。

import React from 'react';
import PropTypes from 'prop-types';

function QuestionCount(props) {
  return (
    <div className="questionCount">
      Question <span>{props.counter}</span> of <span>{props.total}</span>
    </div>
  );
}

QuestionCount.propTypes = {
  counter: PropTypes.number.isRequired,
  total: PropTypes.number.isRequired
};

export default QuestionCount;

QuestionCountコンポーネントは、先ほどのQuestionコンポーネントと似ています。containerコンポーネントから、2つのprops(countertotal)を受け取ります。

次に、回答の選択肢を表示するために AnswerOptionコンポーネントを作成します。 components フォルダの中に、 AnswerOption.js という名前でファイルを作り、以下のように編集します。

import React from 'react';
import PropTypes from 'prop-types';

function AnswerOption(props) {
  return (
    <li className="answerOption">
      <input
        type="radio"
        className="radioCustomButton"
        name="radioGroup"
        checked={props.answerType === props.answer}
        id={props.answerType}
        value={props.answerType}
        disabled={props.answer}
        onChange={props.onAnswerSelected}
      />
      <label className="radioCustomLabel" htmlFor={props.answerType}>
        {props.answerContent}
      </label>
    </li>
  );
}

AnswerOption.propTypes = {
  answerType: PropTypes.string.isRequired,
  answerContent: PropTypes.string.isRequired,
  answer: PropTypes.string.isRequired,
  onAnswerSelected: PropTypes.func.isRequired
};

export default AnswerOption;

AnswerOptionコンポーネントは、ラジオボタンとラベルをもつリストとして構成されます。11行目のchecked プロパティの値に比較演算子が含まれる点に注目してください。この値は、回答として選んだ選択肢が、回答の選択肢の型に合うかどうかにより、true か falseが設定されます。

コンポーネントを組み合わせる

ここまでに作成した3つのコンポーネント(Question, QuestionCount, AnswerOption)を Quizコンポーネントとして結合させましょう。components フォルダの中に、 Quiz.js という名前でファイルを作ります。

まず、import文を用いて作成したコンポーネントを取り込みます。

import React from 'react';
import PropTypes from 'prop-types';
import Question from '../components/Question';
import QuestionCount from '../components/QuestionCount';
import AnswerOption from '../components/AnswerOption';

では、Quizコンポーネントを定義しましょう。

function Quiz(props) {
  return (
      <div className="quiz">
        <QuestionCount
          counter={props.questionId}
          total={props.questionTotal}
        />
        <Question content={props.question} />
        <ul className="answerOptions">
          {props.answerOptions.map(renderAnswerOptions)}
        </ul>
      </div>
  );
}

Quiz.propTypes = {
  answer: PropTypes.string.isRequired,
  answerOptions: PropTypes.array.isRequired,
  counter: PropTypes.number.isRequired,
  question: PropTypes.string.isRequired,
  questionId: PropTypes.number.isRequired,
  questionTotal: PropTypes.number.isRequired,
  onAnswerSelected: PropTypes.func.isRequired
};

export default Quiz;

ここでは、以前に作成したコンポーネントを用いて、Quizコンポーネントを組み立て、必要なpropsを渡しています。3つのコンポーネント(Question, QuestionCount, AnswerOption)に渡されていたpropsが、Quizコンポーネントのpropsでも渡されています。なので、Quizコンポーネントも同様にpresentationalコンポーネントとして扱います。表示に関わるコードは、アプリの機能性(functionality)から分離するようにしましょう。

AnswerOptionsの各々を作成するためには、renderAnswerOptions 関数を定義する必要があります。return式の直前に、次のようにコードを書いてください。

function renderAnswerOptions(key) {
  return (
    <AnswerOption
      key={key.content}
      answerContent={key.content}
      answerType={key.type}
      answer={props.answer}
      questionId={props.questionId}
      onAnswerSelected={props.onAnswerSelected}
    />
  );
}

AnswerOptionのプロパティは、後にmainのcontainerコンポーネント (App.js)にて定義されるので心配する必要はありません。ここでは、stateで定義されている回答の選択肢に合わせて AnswerOption コンポーネントがrenderされます。

スタイルを追加する

Create React App では、モジュールごとにCSSファイルを定義できるようにWebpackが設定されます。各々CSSファイルを変更するたびに、全てのCSSファイルが1つにまとめられます。このクイズアプリではスタイルにはこだわらないので、1つのCSSファイルにスタイルを記述します。こちらからコードをコピーして、 index.css の内容を書き換えてください。

機能を追加する

クイズの機能を作成する前に、アプリケーションのstateを定義する必要があります。 App.js のクラスのconstructor関数で起動時のstateを定義します。これはES6で初期状態を定義するときによく使われる手法です。 App クラスを次のように編集します。

constructor(props) {
  super(props);

  this.state = {
    counter: 0,
    questionId: 1,
    question: '',
    answerOptions: [],
    answer: '',
    answersCount: {},
    result: ''
  };
}

stateは、コンポーネントのイベントハンドラーがUIを更新するために変更するデータを含む必要があります。上のコードでは、クイズアプリで必要な全てのstateを含んでいます。では、実際にstateにデータを入れて試してみましょう。apiという名前のフォルダの中にquizQuestions.js というファイルを作成して、こちらのコードをコピーして貼り付けてください。 App.js にインポートします。

import quizQuestions from './api/quizQuestions';

次に、Reactの標準機能の componentDidMountライフサイクルを用いてアプリケーションのstateにデータを注入します。次のコードをconstructorの直下に配置してください。

componentDidMount() {
  const shuffledAnswerOptions = quizQuestions.map((question) => this.shuffleArray(question.answers));  

  this.setState({
    question: quizQuestions[0].question,
    answerOptions: shuffledAnswerOptions[0]
  });
}

コンポーネントがマウントされるとすぐに componentDidMount ライフサイクルのイベントが発動します。componentDidMount メソッド内で setState を呼び出すと、stateが更新されるたびにrender()が一度だけ実行されます。

shuffleArray という関数は、回答の選択肢をランダムにシャッフルするために使います。componentDidMount の下で次のように定義します。

shuffleArray(array) {
  var currentIndex = array.length, temporaryValue, randomIndex;

  // While there remain elements to shuffle...
  while (0 !== currentIndex) {

    // Pick a remaining element...
    randomIndex = Math.floor(Math.random() * currentIndex);
    currentIndex -= 1;

    // And swap it with the current element.
    temporaryValue = array[currentIndex];
    array[currentIndex] = array[randomIndex];
    array[randomIndex] = temporaryValue;
  }

  return array;
};

この関数は配列をシャッフルするためのものです。ここでは実装方法について詳しく取り上げませんが、興味がある方はこちらを参考にしてください。

では、 App コンポーネントのための render関数を定義しましょう。

render() {
  return (
    <div className="App">
      <div className="App-header">
        <img src={logo} className="App-logo" alt="logo" />
        <h2>React Quiz</h2>
      </div>
      <Quiz
        answer={this.state.answer}
        answerOptions={this.state.answerOptions}
        questionId={this.state.questionId}
        question={this.state.question}
        questionTotal={quizQuestions.length}
        onAnswerSelected={this.handleAnswerSelected}
      />
    </div>
  )
}

render関数の中でイベントハンドラーを束縛している点に注目してください。パフォーマンス上の理由で、イベントハンドラーはconstructorに置くのが最善策です。次のコードをconstructor関数の末尾に加えてください。

this.handleAnswerSelected = this.handleAnswerSelected.bind(this);

stateを更新する

では、回答を選択する機能を追加しましょう。次の関数をrender()関数の上に加えてください。

handleAnswerSelected(event) {
  this.setUserAnswer(event.currentTarget.value);
  if (this.state.questionId < quizQuestions.length) {
      setTimeout(() => this.setNextQuestion(), 300);
    } else {
      // do nothing for now
    }
}

この関数では、setUserAnswerが回答のセット、setNextQuestionが次問のセットをしています。では、setUserAnswer 関数をhandleAnswerSelected関数の前に書きましょう。

setUserAnswer(answer) {
  this.setState((state) => ({
    answersCount: {
      ...state.answersCount,
      [answer]: (state.answersCount[answer] || 0) + 1
    },
    answer: answer
  }));
}

setUserAnswer 関数の実装についてみていきましょう。ユーザの選択に基づいて答えをセットします。これは、アプリ内でユーザがstateを変える初めての場面です。1行目で引数のanswerに渡される値は、ユーザが選択する回答です。ここでは Nintendo、Microsoft、Sony のいずれかになります。

2行目では関数を引数にして setState を呼び出しています。1つの状態は第1引数として渡されて、1つ前のstateにアクセスすることができます。 setState は、イベントハンドラーとサーバからのリクエストを受けてUIの更新をトリガーするために使われる組み込みのメソッドです。Reactでは、 state は不変のもの(イミュータブル)として扱う必要があります。このような理由で、3行目では新しいオブジェクトを作成しています。このオブジェクトは、新たなanswerCount の値と共に this.state.answersCount の元のプロパティを保持します。(スプレッド構文が使われています。) こうすることで、直接的にstateを変更せずに、stateを更新することができます。

次にsetNextQuestion 関数を定義します。この関数は、stateを更新して、次の質問を表示させます。updatedAnswersCount 関数の下に次のコードを加えてください。

setNextQuestion() {
  const counter = this.state.counter + 1;
  const questionId = this.state.questionId + 1;
  this.setState({
    counter: counter,
    questionId: questionId,
    question: quizQuestions[counter].question,
    answerOptions: quizQuestions[counter].answers,
    answer: ''
  });
}

ここでは、counterquestionId の2つの変数を作成後に setState を経由してそれぞれのstateをインクリメントしています。さらに、questionanswerOption をcounter変数を元に更新しています。これで関数型のアプリケーションが完成しました。あなたが回答を選ぶと、stateが更新されて、次の質問が表示されるはずです。

結果を計算する

結果を計算するために、まずは handleAnswerSelected 関数を更新する必要があります。先ほど空欄だった else文の処理を次のように更新します。

handleAnswerSelected(event) {
  this.setUserAnswer(event.currentTarget.value);
  if (this.state.questionId < quizQuestions.length) {
      setTimeout(() => this.setNextQuestion(), 300);
    } else {
      setTimeout(() => this.setResults(this.getResults()), 300); // この行を追加
    }
}

ここではsetResults を0.3秒後に呼び出しています。この遅延は、ユーザが選択した回答を確認するために設定しています。選ばれた回答の結果はgetResults関数の中で渡されます。では、getResults関数を定義しましょう。

getResults() {
  const answersCount = this.state.answersCount;
  const answersCountKeys = Object.keys(answersCount);
  const answersCountValues = answersCountKeys.map((key) => answersCount[key]);
  const maxAnswerCount = Math.max.apply(null, answersCountValues);

  return answersCountKeys.filter((key) => answersCount[key] === maxAnswerCount);
}

getResults関数は、回答が3つのタイプ(この場合は、Sony、Microsoft、Nintendoのうちいずれか)のうちどれが一番大きな数値だかを計算します。この関数定義はES6のシンタックスで定義されていますが、ES5で定義するより回りくどうように見えます。3行目では、answersCountKeys はオブジェクトの全てのプロパティを表現する文字列の配列を返すために Object.keys を利用しています。

['nintendo', 'microsoft', 'sony']

4行目では、 answersCountValues が値の配列を返すために、この配列にマッピングをしています。その後、Math.max.applyでその配列で一番大きい数値の値をえて。5行目のmaxAnswerCount 変数に割り当てられます。最後に7行目で、filterメソッドを用いて、どのキーがmaxAnswerCount` と等しい値を持つかを計算して返します。

次は setResults 関数をgetResults の下に作成します。

setResults (result) {
  if (result.length === 1) {
    this.setState({ result: result[0] });
  } else {
    this.setState({ result: 'Undetermined' });
  }
}

この関数は getResults から配列の形式で結果を受け取り、その配列が1つの値を持つかを確認します。もし1つの値があるとき、setStateを通して値を設定します。もし配列が1つ以外の値をもつときは、結論が出なかった場合です。その場合は、結果をUndeterminedとします。

結果を表示する

ついに結果を表示するところまできました。 components フォルダーの中にResult.js というファイルを作成します。

import React from 'react';
import PropTypes from 'prop-types';

function Result(props) {
  return (
    <div className="result">
      You prefer <strong>{props.quizResult}</strong>!
    </div>
  );
}

Result.propTypes = {
  quizResult: PropTypes.string.isRequired,
};

export default Result;

Resultコンポーネントは、結果を表示するpresentationコンポーネントです。次に、App.js のrender関数を更新する必要があります。render関数のQuizコンポーネントを次のように更新します。

{this.state.result ? this.renderResult() : this.renderQuiz()}

ここでは、クイズか結果のどちらを表示するかを判断するために、JSXの三項演算子を用いて書きます。もし state.result に値が設定されている場合は結果を表示します。

最後に、これらの2つの関数を作成する必要があります。以下のコードをrender関数の直前に加えます。

renderQuiz() {
  return (
    <Quiz
      answer={this.state.answer}
      answerOptions={this.state.answerOptions}
      questionId={this.state.questionId}
      question={this.state.question}
      questionTotal={quizQuestions.length}
      onAnswerSelected={this.handleAnswerSelected}
    />
  );
}

renderResult() {
  return (
    <Result quizResult={this.state.result} />
  );
}

これで関数型のクイズアプリの完成です!もしアプリをデプロイする場合は、npm run build コマンドで最適化されたビルドを作成してください。最小化されてアプリケーションが書き出されて、デプロイすることができます。

付録: アニメーションを追加する

ユーザの印象を良くするために、アニメーションを少し追加しましょう。Reactのアニメーションコンポーネントをインストールします。ここではバージョン1を利用しています。バージョン2では少し変更があるようです。

npm install react-transition-group@1.x

このプラグインを使うと、ReactのコンポーネントにCSSを用いて簡単にトランジションやアニメーションを加えることができます。もしAngularを用いていると慣れているかと思いますが、Angularの ng-animate ライブラリに影響を受けているようです。質問に対してフェードインとフェードアウトの効果を追加しています。

Quiz.js を開き、次のimport文を追加しましょう。

import { CSSTransitionGroup } from 'react-transition-group';

CSSTransitionGroup はアニメーションに関するコンポーネントをラップしているシンプルな要素です。全てのクイズコンポーネントにアニメーションを追加することができます。そうするためには、次のようにrender関数を更新します。

return (
  <CSSTransitionGroup
    className="container"
    component="div"
    transitionName="fade"
    transitionEnterTimeout={800}
    transitionLeaveTimeout={500}
    transitionAppear
    transitionAppearTimeout={500}
  >
    <div key={props.questionId}>
      <QuestionCount
        counter={props.questionId}
        total={props.questionTotal}
      />
      <Question content={props.question} />
      <ul className="answerOptions">
        {props.answerOptions.map(renderAnswerOptions)}
      </ul>
    </div>
  </CSSTransitionGroup>
);

ここでは CSSTransitionGroup 要素の中で、クイズの要素をラップします。CSSTransitionGroup の子要素は固有のkey 属性を提供される必要があります。このようにして、Reactはどの子要素がenter、left、stayedの状態かを判定します。11行目で props.questionId としてキーを定義して、その値が各々の質問で異なる値になります。

CSSTransitionGroup 要素がもつ多くのプロパティについて見ていきましょう。component プロパティでは、このコンポーネントがどのHTML要素としてレンダーされるかを特定します。 transitionName プロパティは、要素に加えられるCSS要素の名前を特定します。ここでは、要素がレンダーされるときはfade-enterfade-enter-activeが特定され、要素が削除されるときはfade-leavefade-leave-activeが特定されます。transitionEnterTimeouttransitionLeaveTimeoutは、アニメーションの長さを特定しますが、これはCSSの中でも指定する必要があります。必要とされるCSSが index.css で読み込まれていることに気が付いた方もいるでしょうindex.css では次のように記述します。

.fade-enter {
  opacity: 0;
}

.fade-enter.fade-enter-active {
  opacity: 1;
  transition: opacity 0.5s ease-in-out 0.3s;
}

.fade-leave {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  opacity: 1;
}

.fade-leave.fade-leave-active {
  opacity: 0;
  transition: opacity 0.5s ease-in-out;
}

このCSSでは、透過性の値を変更して、トランジションの長さとタイプを特定しています。 CSSトランジションの.fade-enterに0.3秒のディレイを加えるために、transitionEnterTimeout は0.8秒に指定しています。 transitionAppear プロパティは、最初にマウントされるときに、どのコンポーネントがアニメーションされるかを特定するためのものです。transitionAppearTimeout では、アニメーションの長さを指定しています。ここでのCSSは、他のアニメーションにおいても同じように指定されます。

.fade-appear {
  opacity: 0;
}

.fade-appear.fade-appear-active {
  opacity: 1;
  transition: opacity 0.5s ease-in-out;
}

最後に、Result.jsのrender関数を次のように変更する必要があります。

return (
  <CSSTransitionGroup
    className="container result"
    component="div"
    transitionName="fade"
    transitionEnterTimeout={800}
    transitionLeaveTimeout={500}
    transitionAppear
    transitionAppearTimeout={500}
  >
    <div>
      You prefer <strong>{props.quizResult}</strong>!
    </div>
  </CSSTransitionGroup>
);

これにより、Resultコンポーネントが確かにアニメーションされるようになります。これでアニメーションの機能は完成です!

クイズアプリの完成

このGithubのレポジトリでソースコードを確認することができます。このチュートリアルがあなたの役にたつことを望んでいます。多くの概念をすごく手短に説明した。もし質問がある場合は、Twitterのアカウント にコンタクトをとってください。

著作権

©️ Mitch Gavan 2019

© 2018 t-cool