ReduxとFirebaseで匿名投稿サイトを作る


個人開発をやってると、同じスタックを使いがちになります。自分の場合、Railsのチュートリアルで学んだときのまんまで揃えてしまいます。動けば合格なので、なるべく楽なやり方を取ろうとするし、面倒臭いものは機能として省いたりもします。

逆に、個人開発だから普段使わないインフラに挑戦してみるのもありですよね。そうなってくると、勉強要素が高くなってしまうのですが。今回は、復習要素の高いスタックで、匿名投稿サイトを作ってみました。

![qiita.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/116375/61b624da-4375-941c-73c0-bfeb14e27287.png)

3日くらい前にアイデアを思いついて、先ほどデプロイしました。これを機に、本格的にSPA開発も増やしていきたいなとか思ったりしています。

## 問題

今年の1月にアメリカに移住してきて、もう9ヶ月経過しました。その間、一回も日本には帰ってないし、会社に属しているわけでもないです。その理由は、一昨年の今頃に応募したグリーンカード ロトに当選したからです。（ちなみに、[今年の](https://travel.state.gov/content/travel/en/us-visas/immigrate/diversity-visa-program-entry.html)は先日応募が始まったばかり。）

そんなわけで今でこそ、ビザの苦悩はないのですが、取得するまではずっとストレスでした。海外に暮らしたいと考える全人類にとって、ビザはめっちゃ辛い問題なのです。それは日本人だけでなく、他の外国人も同じで、こっちのネイティブじゃない人と出会うと、まず「ビザのステータスは？」、「どうやって取得したの？」と話題になります。

## 解決方法

ビザの取得エピソードを共有できる [Visa Stories](https://visa-stories.com) を作りました。

![qiita_2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/116375/bf86ecf3-a4b8-7886-b223-c7934a8debcf.png)


どんな種類のビザをどうやって獲得したのかを匿名で投稿する、めちゃくちゃシンプルなサイトです。できることは、ストーリーの投稿、コメント、いいねだけです。

## Visa Storiesの実装に使った技術

* React
* Redux
* Netlify
* Firebase

## チュートリアル

### Firebaseを仕込む

公式サイトで作成したプロジェクトのコードを仕込む

```javascript:src/config/firebase.js
import * as firebase from "firebase";

var FirebaseConfig = {
  apiKey: "<FIREBASE_API_KEY>",
  authDomain: "<FIREBASE_AUTH_DOMAIN>",
  databaseURL: "<FIREBASE_DATABASE_URL>",
  projectId: "<FIREBASE_PROJECT_ID>",
  storageBucket: "<FIREBASE_STORAGE_BUCKET>",
  messagingSenderId: "<FIREBASE_MESSAGING_SENDDER_IDD>",
  measurementId: "<FIREBASE_MEADUREMENT_ID>"
};

firebase.initializeApp(FirebaseConfig);

const databaseRef = firebase.firestore();
export const postsRef = databaseRef.collection("posts");
export const provider = new firebase.auth.GoogleAuthProvider();
```

### 投稿の作成

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

```javascript:src/components/posts_new.js
class PostsNew extends Component {
  render() {
    return (
      <div />
    );
  }
}

export default PostNew;
```

actionを作成する

```JavaScript:src/actions/index.js
import { postsRef } from "../config/firebase";
export const CREATE_POSTS = "create_posts";

export function createPost(values, callback) {
  return dispatch => {
    postsRef
      .add({
        content: values.content,
      })
      .then(() => {
        dispatch({ type: "CREATE_POSTS" });
        callback();
      });
  };
}
```

storeを作成する

```javascript:src/reducers/index.js
import { createStore, applyMiddleware, combineReducers } from "redux";
import reduxThunk from "redux-thunk";

import { reducer as formReducer } from "redux-form";

const rootReducer = combineReducers({
  form: formReducer
});

const store = createStore(rootReducer, applyMiddleware(reduxThunk));
export default store;
```

PostsNewを更新する

```javascript:src/components/posts_new.js
import React, { Component } from "react";
import { Field, reduxForm } from "redux-form";
import { connect } from "react-redux";
import { createPost } from "../actions";

class PostsNew extends Component {
  renderField(field) {
    const {
      meta: { touched, error }
    } = field;
    const className = `form-group${touched && error ? "has-danger" : ""}`;

    return (
      <div className={className}>
        <label>{field.label}</label>
        <textarea
          className="form-control"
          type="text"
          rows="10"
          {...field.input}
        ></textarea>
        <div className="text-help">
          {field.meta.touched ? field.meta.error : ""}
        </div>
      </div>
    );
  }

  onSubmit(values) {
    this.props.createPost(values, () => {
      this.props.history.push("/");
    });
  }

  render() {
    const { handleSubmit } = this.props;

    return (
      <div>
        <form onSubmit={handleSubmit(this.onSubmit.bind(this))}>
          <div class="formInput">
            <Field name="content" component={this.renderField} />
          </div>
          <button type="submit" className="btn">
            Post story
          </button>
        </form>;
      </div>
    );
  }
}

function validate(values) {
  const errors = {};

  if (!values.content) {
    errors.content = "enter a content!";
  }
  return errors;
}

export default reduxForm({
  validate,
  form: "PostsNewForm"
})(
  connect(
    null,
    { createPost }
  )(PostsNew)
);
```

### 投稿の一覧表示

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

```javascript:src/components/posts_index.js
class PostsIndex extends Component {
  render() {
    return (
      <div />
    );
  }
}

export default PostIndex;
```

actionを作成する

```javascript:src/actions/index.js
import { postsRef } from "../config/firebase";
export const FETCH_POSTS = "fetch_posts";

export function fetchPosts() {
  return dispatch => {
    postsRef
      .orderBy("created_at", "desc")
      .get()
      .then(snapshot => {
        const posts = snapshot.docs.map(doc => {
          return {
            ...doc.data(),
            id: doc.id
          };
        });
        return posts;
      })
      .then(posts => {
        dispatch({ type: FETCH_POSTS, payload: posts });
      });
  };
}
```

reducerを作成する

```javascript:src/reducers/reducer_posts.js
import _ from "lodash";
import { FETCH_POSTS } from "../actions";

const initialState = {};

export default (state = initialState, action) => {
  switch (action.type) {
    case FETCH_POSTS:
      return action.payload;

    default:
      return state;
  }
};
```

storeを作成する

```javascript:src/reducers/index.js
import { createStore, applyMiddleware, combineReducers } from "redux";
import PostsReducer from "./reducer_posts";
import reduxThunk from "redux-thunk";

import { reducer as formReducer } from "redux-form";

const rootReducer = combineReducers({
  posts: PostsReducer,
  form: formReducer
});

const store = createStore(rootReducer, applyMiddleware(reduxThunk));
export default store;
```

PostsIndexを更新する

```javascript:src/components/posts_index.js
import _ from "lodash";
import React, { Component } from "react";
import { connect } from "react-redux";
import { Link } from "react-router-dom";
import { fetchPosts } from "../actions";

class PostsIndex extends Component {
  componentDidMount() {
    this.props.fetchPosts();
  }
  renderPosts() {
    return _.map(this.props.posts, post => {
      const comments = [];

      commentsRef
        .where(`post_id`, "==", post.id)
        .get()
        .then(querySnapshot => {
          querySnapshot.forEach(function(doc) {
            comments.push(doc.data());
          });
        });

      return (
        <li key={post.id}>
          <Link to={`/posts/${post.id}`}>
            <div>
              <p>{post.content}</p>
            </div>
          </Link>
        </li>
      );
    });
  }

  render() {
    return (
      <div>
        <ul>{this.renderPosts()}</ul>
      </div>
    );
  }
}

function mapStateToProps(state) {
  return { posts: state.posts };
}

export default connect(
  mapStateToProps,
  { fetchPosts }
)(PostsIndex);
```

### 投稿の詳細表示

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

```javascript:src/components/posts_show.js
class PostsShow extends Component {
  render() {
    return (
      <div />
    );
  }
}

export default PostShow;
```

actionを作成する

```javascript:src/actions/index.js
import { postsRef } from "../config/firebase";
export const FETCH_POST = "fetch_post";

export function fetchPost(id) {
  return dispatch => {
    postsRef
      .doc(id)
      .get()
      .then(function(doc) {
        // Document was found in the cache. If no cached document exists,
        // an error will be returned to the 'catch' block below.
        console.log("Cached document data:", doc.data());
        dispatch({ type: FETCH_POST, payload: doc.data() });
      });
  };
}
```

reducerを作成する

```javascript:src/reducers/reducer_posts.js
import _ from "lodash";
import { FETCH_POSTS, FETCH_POST } from "../actions";

const initialState = {};

export default (state = initialState, action) => {
  switch (action.type) {
    case FETCH_POSTS:
      return action.payload;
    case FETCH_POST:
      return action.payload;

    default:
      return state;
  }
};
```

PostsShowを更新する

```javascript:src/components/posts_show.js
import _ from "lodash";
import React, { Component } from "react";
import { connect } from "react-redux";
import { fetchPost } from "../actions";

class PostsShow extends Component {
  componentDidMount() {
    const { id } = this.props.match.params;
    this.props.fetchPost(id);
  }

  render() {
    const { post } = this.props;

    if (!post) {
      return <div>Loading...</div>;
    }

    return (
      <div>
       <p>{post.content}</p>
      </div>
    );
  }
}

function mapStateToProps({ posts }) {
  return { post: posts };
}

export default connect(
  mapStateToProps,
  { fetchPost }
)(PostsShow);
```

## さいごに

久しぶりにReduxを使ったので、初歩的なところで何時間もスタックしてしまいました。。やっぱり、定期的に書かないとダメですね。

毎度のことですが、本日（10月6日付）でProduct Huntのレースに参戦しているので、記事を読んでいいね！と思った方は、Upvoteよろしくお願いしますー！

<a href="https://www.producthunt.com/posts/visa-stories?utm_source=badge-featured&utm_medium=badge&utm_souce=badge-visa-stories" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=170225&theme=light" alt="Visa Stories - Anonymous stories about applying visa | Product Hunt Embed" style="width: 250px; height: 54px;" width="250px" height="54px" /></a>

##  関連リンク

* [JavaScript初心者がReact+Reduxでブログのフロントエンドを作成するまで](https://qiita.com/tomoyamachi/items/d64f99b9fd2acab91999)

* [Product Hunt](https://www.producthunt.com/posts/visa-stories)

* [Visa Stories](https://visa-stories.com)