Reactはユーザーインターフェイスの構築に使われるJavaScriptのライブラリーです。Create-React-Appを利用すれば、Reactアプリケーションのひな形を簡単に準備できます。
この記事ではCreate-React-AppとFirebaseを使って、ユーザーが作成したリンクにほかのユーザーが投票できる機能を持つRedditのようなアプリを構築します。
完成形のデモを実際に操作してみてください。
Firebaseを使う理由
Firebaseは、ユーザーの投票を即座に反映するような、リアルタイムのデータを簡単にユーザーへ提示できます。実装には、Firebaseのリアルタイムデータベースを使います。ReactアプリケーションをFirebaseを使ってすばやく立ち上げる方法を解説します。
Reactを使う理由
Reactは、主にコンポーネントアーキテクチャーを使ってユーザーインターフェイスを構築するために使います。それぞれのコンポーネントは内部ステート(State)を持つか、プロップ(Prop)としてデータを受け取ります。Reactを使うには、2つのコンセプト「ステートとプロップ」を理解しましょう。ステートとプロップでアプリケーションの状態が規定されます。ステートとプロップに関してはReactドキュメントに目を通してください。
この記事のプロジェクトはすべてGithubで入手できます。
プロジェクトを設定
プロジェクトを立ち上げて依存オブジェクトを設定する方法を、ステップごとに説明します。
Create-React-Appをインストール
次のコマンドをターミナルに打ち込んで、Create-React-Appをインストールします。
npm install -g create-react-app
グローバルインストールすれば、どのフォルダーからでもReactプロジェクトのひな形を作成できます。
reddit-cloneという名前の新しいアプリを作成します。
create-react-app reddit-clone
新しいCreate-React-Appプロジェクトのひな形がreddit-cloneディレクトリに作成されます。Bootstrapを終えると、reddit-cloneディレクトリへ移動し開発用サーバーを起動します。
npm start
http://localhost:3000/にアクセスして、アプリのスケルトンが起動していることを確認してください。
アプリの大枠
メンテナンスしやすくするために、コンテナとコンポーネントを分離しています。コンテナは判断を伴うコンポーネントで、アプリケーションのビジネスロジックを記載して、Ajaxリクエストを処理します。一方、コンポーネントは判断を伴わないデザインのコンポーネントです。内部のステートを持ち、コンポーネントのロジックをコントロールします(例:コントロールされたインプットコンポーネントの現在のステートを表示)。
不要なロゴとCSSファイルを消去して、次のフォルダー構成にします。componentsフォルダーとcontainersフォルダーを作成してApp.jsをcontainers/Appフォルダーに移動し、utilsフォルダーにregisterServiceWorker.jsを作成します。
src/containers/App/index.jsには次の内容を記載します。
// src/containers/App/index.js
import React, { Component } from 'react';
class App extends Component {
render() {
return (
<div className="App">
Hello World
</div>
);
}
}
export default App;
src/index.jsは次のとおりです。
// src/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import App from './containers/App';
import registerServiceWorker from './utils/registerServiceWorker';
ReactDOM.render(<App />, document.getElementById('root'));
registerServiceWorker();
問題がなければ、ブラウザーに「Hello World」と表示されます。
ここまでのファイルをGithubにコミットしました。
React-Routerを追加
React-Routerはアプリのルートを定義するツールで、カスタマイズの自由度が高く、Reactとセットで広く使われています。
ここではバージョン3.0.0を使います。
npm install --save react-router@3.0.0
srcフォルダーに新しいファイル「routes.js」を作成し、次のコードを記載します。
// routes.js
import React from 'react';
import { Router, Route } from 'react-router';
import App from './containers/App';
const Routes = (props) => (
<Router {...props}>
<Route path="/" component={ App }>
</Route>
</Router>
);
export default Routes;
Routerコンポーネントの内にすべてのRouteコンポーネントを配置します。Routeコンポーネントのpathプロップに従って、componentプロップに渡されたコンポーネントがページ上にレンダリングされます。ここではRouterコンポーネントを使ってルートURL(/)にAppコンポーネントをロードします。
<Router {...props}>
<Route path="/" component={ <div>Hello World!</div> }>
</Route>
</Router>
上記のコードでも動作します。/パスに<div>Hello World!</div>がマウントされます。
次にsrc/index.jsからroutes.jsを呼び出します。src/index.jsには次のコードを記載します。
// src/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import { browserHistory } from 'react-router';
import App from './containers/App';
import Routes from './routes';
import registerServiceWorker from './utils/registerServiceWorker';
ReactDOM.render(
<Routes history={browserHistory} />,
document.getElementById('root')
);
registerServiceWorker();
routes.jsからRouterコンポーネントをマウントします。historyプロップを渡すことで、Routesが履歴を追跡できます。
ここまでのファイルをGithubにコミットしました。
Firebaseを追加
Firebaseのアカウントを持っていなければ、Webサイトにアクセスして取得してください(無料です)。アカウント作成後ログインし、コンソールの「プロジェクトを追加」をクリックします。
プロジェクト名(ここではreddit-clone)を入力、国を選択して、「プロジェクトを作成」ボタンをクリックします。
デフォルトだと、Firebaseのデータ読み込みと書き込みにはユーザー認証が必要です。そこで次に進む前にデータベースのルールを変更します。プロジェクトを選択して左端のDatabaseタブをクリックして、データベースを表示し、上部のRulesタブをクリックして、次のデータを含む画面を表示します。
{
"rules": {
".read": "auth != null",
".write": "auth != null"
}
}
以下の通り修正します。
{
"rules": {
".read": "auth === null",
".write": "auth === null"
}
}
これでログインしていないユーザーでもデータベースを更新できるようになりました。データベースの更新に認証が必要なフローにするのなら、Firebaseのデフォルトルールのまま使います。この記事ではアプリケーションをシンプルにするために、ユーザー認証は用いません。
重要:この修正をしないと、アプリからFirebaseのデータベースを更新できません。
次のコマンドで、firebaseのnpmモジュールをアプリに追加します。
npm install --save firebase
以下のコードでFirebaseをApp/index.jsにインポートします。
// App/index.js
import * as firebase from "firebase";
Firebaseにログインしてこのプロジェクトを選択すると、「Add Firebase to your web app」のボタンが表示されます。
そのボタンをクリックすると、config変数の値が表示されます。これはcomponentWillMountメソッドで使います。
Firebaseの設定ファイルをfirebase-config.jsという名前で作成します。Firebaseへの接続に必要なすべての設定情報を、このファイルに記載します。
// App/firebase-config.js
export default {
apiKey: "AIzaSyBRExKF0cHylh_wFLcd8Vxugj0UQRpq8oc",
authDomain: "reddit-clone-53da5.firebaseapp.com",
databaseURL: "https://reddit-clone-53da5.firebaseio.com",
projectId: "reddit-clone-53da5",
storageBucket: "reddit-clone-53da5.appspot.com",
messagingSenderId: "490290211297"
};
Firebaseの設定情報をApp/index.jsにインポートします。
// App/index.js
import config from './firebase-config';
constructorでFirebaseへのデータベース接続を初期化します。
// App/index.js
constructor() {
super();
// Initialize Firebase
firebase.initializeApp(config);
}
componentWillMount()ライフサイクルフックで、先ほどインストールしたfirebaseパッケージのinitializeAppメソッドを呼び出して、config変数を渡します。このオブジェクトにはアプリに関するデータすべてを格納します。initializeAppメソッドはアプリをFirebaseデータベースに接続して、データの読み書きができるようにします。
正しく設定できたか確かめるため、Firebaseにデータを追加します。Databaseタブへ移動し、次のデータをデータベースに追加してください。
Addをクリックすると、データベースに保存されます。
データを画面に表示するコードをcomponentWillMountメソッドに追加します。
// App/index.js
componentWillMount() {
...
let postsRef = firebase.database().ref('posts');
let _this = this;
postsRef.on('value', function(snapshot) {
console.log(snapshot.val());
_this.setState({
posts: snapshot.val(),
loading: false
});
});
}
firebase.database()でデータベースサービスへの参照を取得します。ref()でデータベースからの具体的な参照を取得します。たとえばref('posts')でデータベースからposts参照を取得して、postsRefに格納します。
postsRef.on('value', ...)はデータベースが変更されたときに変更後の値を扱います。データベースイベントに基づいてリアルタイムでユーザーインターフェイスを更新するときに使用します。
postsRef.once('value', ...)がデータを返すのは1度だけで、再読み込みが不要なデータに適しており、頻繁に更新されたりアクティブリスニングが必要なデータには不向きです。
更新後の値をon()コールバックで取得して、postsステートに保存します。
これでコンソールにデータが表示されます。
子要素にもデータを渡せるように、App/index.jsのrender関数を書き換えます。
// App/index.js
render() {
return (
<div className="App">
{this.props.children && React.cloneElement(this.props.children, {
firebaseRef: firebase.database().ref('posts'),
posts: this.state.posts,
loading: this.state.loading
})}
</div>
);
}
react-routerで渡されたpostsデータのすべてを子要素で使えるようにします。
this.props.childrenの存在を確認し、存在すれば要素をクローンして、すべてのプロップを子要素に渡しています。動的な子要素にプロップを渡す効率的な方法です。
cloneElementは、this.props.childrenに存在するプロップと渡すプロップ(firebaseRef、posts、loading)をシャローマージします。
これでfirebaseRefとposts、loadingプロップをすべてのRoutesで使えるようになります。
ここまでファイルをGithubにコミットしました。
アプリをFirebaseに接続
Firebaseはデータをオブジェクトとして保存します。標準では配列をサポートしていないので、データを以下の形式で保存します。
上記のスクリーンショットのデータを手動で追加して、ビューをテストできるようにします。
Postsのビューを追加
すべてのPostsを表示するビューを追加します。次の内容でsrc/containers/Posts/index.jsを作成します。
// src/containers/Posts/index.js
import React, { Component } from 'react';
class Posts extends Component {
render() {
if (this.props.loading) {
return (
<div>
Loading...
</div>
);
}
return (
<div className="Posts">
{ this.props.posts.map((post) => {
return (
<div>
{ post.title }
</div>
);
})}
</div>
);
}
}
export default Posts;
データをマップして、ユーザーインターフェイスにレンダリングし、routes.jsに追加します。
// routes.js
...
<Router {...props}>
<Route path="/" component={ App }>
<Route path="/posts" component={ Posts } />
</Route>
</Router>
...
Postsが/postsルートに表示されます。Postsコンポーネントをcomponentプロップに渡し、/postsをReact-RouterのRouteコンポーネントのpathプロップに渡します。
localhost:3000/postsにアクセスすると、FirebaseのデータベースからPostsが表示されます。
ここまでのファイルをGithubにコミットしました。
新しいPostを投稿するビューを追加
新しいPostを追加するビューを作成します。次の内容でsrc/containers/AddPost/index.jsを作成します。
// src/containers/AddPost/index.js
import React, { Component } from 'react';
class AddPost extends Component {
constructor() {
super();
this.handleChange = this.handleChange.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
}
state = {
title: ''
};
handleChange = (e) => {
this.setState({
title: e.target.value
});
}
handleSubmit = (e) => {
e.preventDefault();
this.props.firebaseRef.push({
title: this.state.title
});
this.setState({
title: ''
});
}
render() {
return (
<div className="AddPost">
<input
type="text"
placeholder="Write the title of your post"
onChange={ this.handleChange }
value={ this.state.title }
/>
<button
type="submit"
onClick={ this.handleSubmit }
>
Submit
</button>
</div>
);
}
}
export default AddPost;
handleChangeメソッドが、インプットボックスに入力された値でステートを更新します。ボタンをクリックするとhandleSubmitメソッドが呼び出されて、データベースに書き込むAPIのリクエストを出します。この際、子要素へ渡したfirebaseRefプロップを使います。
this.props.firebaseRef.push({
title: this.state.title
});
上記のコードで、現在のタイトルの値をデータベースに書き込みます。
新しいPostがデータベースに保存されると、インプットボックスを空にして、再び新しいPostが追加できるようにします。
Routesに追加します。
// routes.js
import React from 'react';
import { Router, Route } from 'react-router';
import App from './containers/App';
import Posts from './containers/Posts';
import AddPost from './containers/AddPost';
const Routes = (props) => (
<Router {...props}>
<Route path="/" component={ App }>
<Route path="/posts" component={ Posts } />
<Route path="/add-post" component={ AddPost } />
</Route>
</Router>
);
export default Routes;
/add-postルートを追加して、新しいPostをこのルートから追加できるようにしました。AddPostコンポーネントをコンポーネントプロップに渡しています。
Firebaseは配列をサポートしていないためsrc/containers/Posts/index.jsのrenderメソッドを修正して、配列の代わりにオブジェクトを走査できるようにします。
// src/containers/Posts/index.js
render() {
let posts = this.props.posts;
if (this.props.loading) {
return (
<div>
Loading...
</div>
);
}
return (
<div className="Posts">
{ Object.keys(posts).map(function(key) {
return (
<div key={key}>
{ posts[key].title }
</div>
);
})}
</div>
);
}
localhost:3000/add-postにアクセスすると、新しいPostを追加できるようになっています。submitボタンをクリックすると、即座に新しいPostがPostページに表示されます。
ここまでのファイルをGithubにコミットしました。
投票機能を追加
ユーザーがPostに投票する機能を追加します。src/containers/App/index.jsのrenderメソッドを修正します。
// src/containers/App/index.js
render() {
return (
<div className="App">
{this.props.children && React.cloneElement(this.props.children, {
// https://github.com/ReactTraining/react-router/blob/v3/examples/passing-props-to-children/app.js#L56-L58
firebase: firebase.database(),
posts: this.state.posts,
loading: this.state.loading
})}
</div>
);
}
firebaseプロップをfirebaseRef: firebase.database().ref('posts')からfirebase: firebase.database()に修正し、Firebaseのsetメソッドで投票数を更新します。Firebaseのrefが増えても、firebaseプロップで簡単に対応できます。
投票機能を実装する前に、src/containers/AddPost/index.jsのhandleSubmitメソッドを少し修正します。
// src/containers/AddPost/index.js
handleSubmit = (e) => {
...
this.props.firebase.ref('posts').push({
title: this.state.title,
upvote: 0,
downvote: 0
});
...
}
firebaseRefプロップをfirebaseプロップに名称変更したので、this.props.firebaseRef.pushをthis.props.firebase.ref('posts').pushに変更します。
src/containers/Posts/index.jsを修正して、投票機能を実装します。
renderメソッドを修正します。
// src/containers/Posts/index.js
render() {
let posts = this.props.posts;
let _this = this;
if (!posts) {
return false;
}
if (this.props.loading) {
return (
<div>
Loading...
</div>
);
}
return (
<div className="Posts">
{ Object.keys(posts).map(function(key) {
return (
<div key={key}>
<div>Title: { posts[key].title }</div>
<div>Upvotes: { posts[key].upvote }</div>
<div>Downvotes: { posts[key].downvote }</div>
<div>
<button
onClick={ _this.handleUpvote.bind(this, posts[key], key) }
type="button"
>
Upvote
</button>
<button
onClick={ _this.handleDownvote.bind(this, posts[key], key) }
type="button"
>
Downvote
</button>
</div>
</div>
);
})}
</div>
);
}
ボタンをクリックすると、Firebaseデータベースの賛成票か反対票の数が増えます。このロジックをhandleUpvote()メソッドとhandleDownvote()メソッドに記述します。
// src/containers/Posts/index.js
handleUpvote = (post, key) => {
this.props.firebase.ref('posts/' + key).set({
title: post.title,
upvote: post.upvote + 1,
downvote: post.downvote
});
}
handleDownvote = (post, key) => {
this.props.firebase.ref('posts/' + key).set({
title: post.title,
upvote: post.upvote,
downvote: post.downvote + 1
});
}
2つのメソッドは、ユーザーが投票ボタンをクリックしたときに、データベースの賛成票数か反対票数に1を加えて、即座にブラウザーへ反映します。
localhost:3000/postsを2つのタブで開いて投票ボタンをクリックすると、両方のタブがほぼ即時に更新されます。これがFirebaseをはじめ、リアルタイムデータベースの真骨頂です。
ここまでのファイルをGithubにコミットしました。
レポジトリーではデフォルトでPostをlocalhost:3000に表示するために、/posts RouteをアプリケーションのIndexRouteに追加します。ここまでのファイルもGithubにコミットしています。
最後に
最終形でもデザインをしていないので、殺風景なままです(デモはスタイルを追加しました)。プログラムが複雑になって記事が長くなるのを避けるために認証プロセスを実装しませんでしたが、実際のアプリケーションには必要でしょう。
Firebaseは、バックエンドアプリケーションの開発とメンテナンスが不要なので、API開発に多大な時間を費やすことなくリアルタイムデータを扱えます。FirebaseはReactと相性が良いことも記事から分かるでしょう。
さらに学びたい人へ
- クイックヒント:Create-React-AppでReact Projectsをロケットスタート
- ログインと認証機能付きReact.jsアプリの構築
- WebサイトでのFirebase認証
- Reactのレベルアップ:React Router
本記事はMichael Wanyoikeが査読を担当しています。最高のコンテンツに仕上げるために尽力してくれたSitePointの査読担当者のみなさんに感謝します。
(原文:How to Create a Reddit Clone Using React and Firebase)
[翻訳:内藤 夏樹/編集:Livit]