Sat Apr 10 2021
つぶやきしかできないWebアプリを作りました。ReactでWebサービスを作りたい人はこのチュートリアルを読み進め、足りない機能を作ってみると良いでしょう。
Demo: https://single-board-3c001.web.app/
Code: https://github.com/shuent/single-board
今どきなので、関数コンポーネントとHooksを使います。筆者は関数コンポーネントが出てからReactを勉強したので、Class時代のReactを書いたことがない。
というメリットがあるので利用しています。初心者にとってコードを書く量が多くなるというデメリットを差し引いても、メリットが余りあります。
状態管理にはHooksのuseReducerと Context APIを利用して、Fluxの思想を取り入れます。Reduxは使いませんが、実装の流れとしては一緒なので使い方は一度見ておくと良さそうです。
Chakra UI は TailwindCSS のようなユーティリティベースなComponentを提供するUIライブラリです。Reactコンポーネントになっているので、Tailwindより使いやすく、Material UIなどのUIフレームワークよりは自由度があるので好きです。
今回はデータ構造が簡単なため、NoSQLであるFirestoreを利用します。NoSQLはDB設計に正解がないので難しく、複雑なリレーションを張るには向いてません。反面、バックエンドが要らず手軽に利用できるので、小規模で単純なデータ構造のアプリには使いやすいです。
認証にはFirebase Authentication を利用します。tokenの管理などを裏でやってくれるので、とても楽です。さらにFirebase UIを使い、ログイン画面もほぼコード書かずに済みました。
コマンド一つでデプロイ、urlを発行してくれます。今回はこれを使います
フロントエンドのホスティングサービスは他にもいろいろ出ています。Vercel, Netlify, Amplify. どれも簡単にデプロイできるので、試してみてください。
次章から、ハンズオン形式でチュートリアルを書いていきます。コードを全て書いているわけではないので、説明が足りない部分はGithubリポジトリを参照してください。もしわからない部分があれば、質問していただけると記事の改善につながります。
どんなアプリを作るにしても設計が大事です。まず一言で何を作るかを決めます。
次にアプリに欲しい機能を決めます。Single Boardは世に出す意識がなかったので、最低限と練習したい機能をつけることにしました。
紙でもUIツールでもパワポでも、ラフで良いので、画面を作ります。実際作ったのがこのくらいラフ。笑 トップページと、投稿コンポーネントを描いています。
どうせUIライブラリに依存することになるので、丁寧にSketchやFigmaを使ってデザインする必要はないです。画面数が多い場合、雑にプロトタイプとしてFigmaかなんかで作ってみるのはアリ。
ここからは実装していきます。まず、create-react-app(CRA)でプロジェクトを作成します。
1npx create-react-app single-board --template typescript
CRAでは必要なライブラリが全部入っているので、設定なしにコードを書き始められます。eslintも入っています。
ただ、コードフォーマットツールのprettierはありません。自動でコードを綺麗に整形したい人はインストールしましょう。vscodeを使っている人は、prettierの拡張機能をインストールすればnpm installする必要がありません。.prettierrc
で自分の設定でコードフォーマットしてくれます。
参考:
https://create-react-app.dev/docs/setting-up-your-editor/#formatting-code-automatically
https://www.digitalocean.com/community/tutorials/how-to-format-code-with-prettier-in-visual-studio-code-ja
手元でできた画面を確認してみてください。デフォルトの画面が立ち上がります。
1npm start
プロジェクトフォルダの中の、基本的にはsrc/
の中にコードを書いていくことになります。
それぞれの初期ファイルの説明は公式ページを読んでみてください。
https://www.digitalocean.com/community/tutorials/how-to-format-code-with-prettier-in-visual-studio-code-ja
プロジェクトを作った時、何から書いていけば良いか迷いますよね。データモデルから書くことによって、データ中心にアプリを作っていけるのでおすすめです。今回は、
という機能に必要なデータモデルを定義します。models.ts
というファイルを作成します。
1.
2└── src/
3 └── models.ts
1export type IUser = {
2 displayName: string | null | undefined
3 photoURL: string | null | undefined
4}
5export type IComment = {
6 user: IUser
7 content: string
8 createdAt: Date
9 id: string
10}
11export type ICommentAdd = {
12 user: IUser
13 content: string
14}
表示に使う属性だけ定義します。
次にView、見た目の部分を作っていきます。Reactで開発する上で大事な考え方が、コンポーネント志向です。画面を適切な役割ごとにコンポーネントで切り分けて実装することで可読性、保守性が上がります。
コンポーネントの種類には2種類あります。
私が今回アプリを作っていくときには、
という風に作っていきました。
1- App
2- Home
3 - Header
4 - Editor
5 - CommentList
6 - Comment
7 - UserAvatar
8 - Content
9 - Footer
10- Login
11 - Header
12 - Form
13
考え方としては、Atomic Designを参考に、簡易化しています。実体コンポーネント、関数コンポーネントはそれぞれ Organism, molecules に対応するかと思います。
大事なのは、難しく考えずだいたいで切り分けてあとで共通化する、ということです。最初からDRYでやるのは悪手です。
実装していきます。
先に必要になりそうなファイルを全部作っていきます。
1.
2├── package-lock.json
3├── package.json
4├── public
5├── src
6│ ├── App.tsx # 各コンポーネントを呼び出す
7│ ├── api # firestoreのインターフェース
8│ │ └── commentsApi.ts
9│ ├── components
10│ │ ├── CommentList.tsx
11│ │ ├── Editor.tsx
12│ │ ├── Footer.tsx
13│ │ ├── Header.tsx
14│ │ ├── Home.tsx
15│ │ ├── Login.tsx
16│ │ ├── MainVisual.tsx
17│ │ └── UserAvatar.tsx
18│ ├── contexts
19│ │ ├── authContext.tsx # ユーザー認証状態管理
20│ │ └── commentsContext.tsx # つぶやきの状態管理
21│ ├── reducers
22│ │ └── commentsReducer.ts # つぶやきのFlux (あとで解説)
23│ ├── firebase.ts
24│ ├── index.tsx # App.tsxを呼び出しているだけ
25│ ├── models.ts # データモデル
26│ └── theme.ts # 全体UIの設定
27└── tsconfig.json
UIライブラリのChakra UIをインストールします。
1npm i @chakra-ui/react @emotion/react@^11 @emotion/styled@^11 framer-motion@^4
react-routerを使い、urlによって、トップ画面とログイン画面を出し分けます。公式ドキュメントではサンプルを動かせるので、めちゃわかりやすいです。
1npm i react-router-dom
1import { BrowserRouter as Router, Switch, Route } from 'react-router-dom'
2
3import { Header } from './components/Header'
4import { Login } from './components/Login'
5import { Home } from './components/Home'
6
7function App() {
8 return (
9 <Router>
10 <Header />
11 <Switch>
12 <Route exact path='/'>
13 <Home />
14 </Route>
15 <Route path='/login'>
16 <Login />
17 </Route>
18 </Switch>
19 </Router>
20 )
21}
22
23export default App
1import { CommentList } from './CommentList'
2import { MainVisual } from './MainVisual'
3import { Editor } from './Editor'
4import { Footer } from './Footer'
5export const Home = () => (
6 <>
7 <MainVisual /> // 一番上のメインビジュアル
8 <Editor /> // つぶやき編集フォーム
9 <CommentList /> // つぶやきリスト
10 <Footer /> // フッター
11 </>
12)
ダミーデータを作り、とりあえず表示するの画面を作っていきます。
1import { HStack, Box, Avatar, Heading, Text } from '@chakra-ui/react'
2import { IComment, IUser } from '../models'
3
4// ダミーデータ
5const user1: IUser = { displayName: 'testuser1', photoURL: 'sample.jpg' }
6const dcomments: IComment[] = [
7 {
8 user: user1,
9 content:
10 'first comment ss',
11 createdAt: new Date(),
12 id: 'comment1id',
13 },
14 {
15 user: user1,
16 content: '元気ですか',
17 createdAt: new Date(),
18 id: 'comment2id',
19 },
20
21export const CommentList = () => {
22 return (
23 <>
24 <Heading>
25 Posted Comments
26 </Heading>
27 <ul>
28 {comments === [] ? (
29 <p>No Post</p>
30 ) : (
31 // Comment 実装は省略
32 comments.map((comment) => (
33 <Comment key={comment.id} comment={comment} />
34 ))
35 )}
36 </ul>
37 </>
38 )
39}
省略したコンポーネントはレポジトリをみてみてください。
ログイン画面を作っていきます。Firebase AuthenticationとFirebaseUIを使うことで簡単に実装できます。
firebase consoleでプロジェクトを作成します。
https://console.firebase.google.com/u/0/?hl=ja
作成したら、プロジェクトの設定 > Firebase SDK snippet を取得します。
CRAは元々の設定で、REACT_APP_
から始まる環境変数名を.env
ファイルからアプリに組み込んでくれます。そして、ビルド時に値を埋め込んでくれます。これで外に変数が漏れることはありません。
https://create-react-app.dev/docs/adding-custom-environment-variables/
先ほど取得した値を変数として.local.env
ファイルに宣言し、プロジェクトのルートにおきます。
1 REACT_APP_APIKEY=xxxxxx
2 REACT_APP_AUTHDOMAIN=xxxxxx
3 REACT_APP_PROJECTID=xxxxxx
4 REACT_APP_STORAGEBUCKET=xxxxxx
5 REACT_APP_MESSAGINGSENDERID=xxxxxx
6 REACT_APP_APPID=xxxxxx
7 REACT_APP_MEASUREMENTID=xxxxxx
プロジェクト内では、firebaseを扱うファイルを作り、環境変数を埋めます。ついでにFirebaseの認証とデータベースにfirestoreを使うので、exportしておきます。
1import firebase from 'firebase'
2
3const fireConfig = {
4 apiKey: process.env.REACT_APP_APIKEY,
5 authDomain: process.env.REACT_APP_AUTHDOMAIN,
6 projectId: process.env.REACT_APP_PROJECTID,
7 storageBucket: process.env.REACT_APP_STORAGEBUCKET,
8 messagingSenderId: process.env.REACT_APP_MESSAGINGSENDERID,
9 appId: process.env.REACT_APP_APPID,
10 measurementId: process.env.REACT_APP_MEASUREMENTID,
11}
12firebase.initializeApp(fireConfig)
13const auth = firebase.auth()
14const firedb = firebase.firestore()
15export { firebase, auth, firedb }
ログイン画面を作っていきます。
firebaseUIのReact用ライブラリがあるのでインストールします。
*公式の開発者がストップしているみたいなので、canary版を使います。
https://github.com/firebase/firebaseui-web-react/pull/122
1npm install react-firebaseui@canary
ログインコンポーネントを作ります。ログインフォームの挙動はuiConfig
変数で設定します。
1import { Center, Heading, VStack } from '@chakra-ui/layout'
2import { primaryTextColor } from '../theme'
3import StyledFirebaseAuth from 'react-firebaseui/StyledFirebaseAuth'
4import { firebase, auth } from '../firebase'
5
6const uiConfig = {
7 signInFlow: 'popup',
8 signInSuccessUrl: '/',
9 signInOptions: [
10 firebase.auth.GoogleAuthProvider.PROVIDER_ID,
11 firebase.auth.EmailAuthProvider.PROVIDER_ID,
12 ],
13}
14
15export const Login = () => {
16 return (
17 <Center mt={8}>
18 <VStack>
19 <Heading size='md' color={primaryTextColor}>
20 Sign In
21 </Heading>
22 <StyledFirebaseAuth uiConfig={uiConfig} firebaseAuth={auth} />
23 </VStack>
24 </Center>
25 )
<StyledFirebaseAuth firebaseAuth={auth} />
でプロジェクトのfirebaseインスタンスとUIをつなげています。
ログイン・登録ができるようになったので、セッション情報:(「ログインしているかどうか」と「ログインしているユーザー情報」)をアプリ内で使えるようにします
ユーザー認証の状態管理には、Context APIを使用します。流れとしては、Contextを作成し、Providerで状態を保存し、useContextで使います。
公式: https://ja.reactjs.org/docs/context.html
認証用のContextを扱う、authContext.ts
を作成します。
1import React, { createContext, useContext, useState, useEffect } from 'react'
2import { firebase, auth } from '../firebase'
3
4type AuthContextProps = {
5 user: firebase.User | null
6}
7
8const AuthContext = createContext<AuthContextProps>({
9 user: null,
10})
11
12export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
13 const [user, setUser] = useState<firebase.User | any>(null)
14 const [loading, setLoading] = useState(true)
15
16 useEffect(() => {
17 const unsubscribe = auth.onAuthStateChanged((user) => {
18 setUser(user)
19 setLoading(false)
20 })
21 return unsubscribe
22 }, [])
23
24 return (
25 <AuthContext.Provider value={{ user }}>
26 {!loading && children}
27 </AuthContext.Provider>
28 )
29}
30
31export const useAuth = () => {
32 return useContext(AuthContext)
33}
34
auth.onAuthStateChanged
ではユーザーの認証状態を監視して、ログイン、ログアウト時、と認証情報が変わる度に引数に渡しているコールバック関数を実行します。Provider
をUnmountする時に監視を捨てる必要があるので、useEffectの返り値に設定してます。
https://firebase.google.com/docs/auth/web/manage-users?hl=ja
今定義した関数を使い、アプリ上でセッションを取得できるようにしましょう。アプリ全体をAuthProvider
で囲みます。これで囲んだどのコンポーネント内でもuseAuth()
が使えることになります。
1
2...
3import { AuthProvider } from './contexts/authContext'
4
5function App() {
6 return (
7+ <AuthProvider>
8 <ChakraProvider theme={theme}>
9 <Router>
10 <Header />
11 <Switch>
12 <Route exact path='/'>
13 <Home />
14 </Route>
15 <Route path='/login'>
16 <Login />
17 </Route>
18 </Switch>
19 </Router>
20 </ChakraProvider>
21+ </AuthProvider>
22 )
23}
ヘッダーでログインしている時はログアウトボタン、ログインしていないときはログインリンクを表示します。
1import { Link } from 'react-router-dom'
2import { useAuth } from '../contexts/authContext'
3import { auth } from '../firebase'
4
5export const Header = () => {
6 const { user } = useAuth()
7 return (
8 <>
9 // ...省略
10 {user ? (
11 <Text as='button' onClick={() => auth.signOut()}>
12 Log Out
13 </Text>
14 ) : (
15 <Link to='/login'>
16 <Text color='white'> Sign In</Text>
17 </Link>
18 )}
19 // ...
20 </>
21 )
22
トップ画面のエディターでもログインしている時のみ投稿できるようにします。
1export const Editor = () => {
2 const { user } = useAuth()
3 const [content, setContent] = useState('')
4
5 const handleSubmit = (e: React.FormEvent) => {
6 e.preventDefault()
7 if (content !== '' && user) {
8 // post content to server
9 } else if (!user) {
10 alert('Sign in first')
11 }
12 setContent('')
13 }
14 const handleChange = (e: React.FormEvent<HTMLTextAreaElement>) => {
15 setContent(e.currentTarget.value)
16 }
17
18 return (
19 <div>
20 <VStack
21 as='form'
22 onSubmit={handleSubmit}
23 >
24 <Textarea
25 name='content'
26 value={content}
27 onChange={handleChange}
28 placeholder="What's on your mind?"
29 />
30 <Button type='submit' colorScheme='orange'>
31 post
32 </Button>
33 </VStack>
34 </div>
35 )
36}
これで認証状態管理は終わりです。
つぶやきの状態管理でもContextAPIを使い、状態を保持できるようにします。加えて、useReducerというHookを使いFluxアーキテクチャでの状態管理を行います。Contextだけでも管理できないことはないですが、状態を変更する機能が多くなってきた時に分かりやすいです。
reducerから定義していきます。
1import { IComment } from '../models'
2
3export type CommentsAction =
4 | { type: 'SET_COMMENTS'; comments: IComment[] }
5 | { type: 'ADD_COMMENT'; comment: IComment }
6
7export type CommentsState = {
8 comments: IComment[]
9}
10
11export const initialState: CommentsState = {
12 comments: [],
13}
14
15export const commentsReducer = (
16 state: CommentsState,
17 action: CommentsAction
18): CommentsState => {
19 switch (action.type) {
20 case 'SET_COMMENTS':
21 return { comments: action.comments }
22 case 'ADD_COMMENT':
23 return { comments: [action.comment, ...state.comments] }
24 default:
25 return state
26 }
27
後々firestoreにつぶやきを投稿したタイミングでまたつぶやきリストを取得するかストリーミングすれば最新の状態になるので、ADD_COMMENT
はあってもなくても良いのですが、毎回APIを呼ばなくても良いようにと、練習のために作っています。
Contextを作ります。
1import {
2 createContext,
3 Dispatch,
4 ReactNode,
5 useReducer,
6 useContext,
7} from 'react'
8import {
9 CommentsAction,
10 commentsReducer,
11 CommentsState,
12 initialState,
13} from '../reducers/commentsReducer'
14
15type CommentsContextProps = {
16 state: CommentsState
17 dispatch: Dispatch<CommentsAction>
18}
19
20const CommentsContext = createContext<CommentsContextProps>({
21 state: initialState,
22 dispatch: () => initialState,
23})
24
25export const CommentsProvider = ({ children }: { children: ReactNode }) => {
26 const [state, dispatch] = useReducer(commentsReducer, initialState)
27 return (
28 <CommentsContext.Provider value={{ state, dispatch }}>
29 {children}
30 </CommentsContext.Provider>
31 )
32}
33
34export const useComments = () => useContext(CommentsContext)
35
EditorとCommentListで使うので、それらを含むHomeコンポーネントで囲んでおきます。
1export const Home = () => (
2<>
3+ <CommentsProvider>
4 <MainVisual />
5 <Editor />
6 <CommentList />
7 <Footer />
8+ </CommentsProvider>
9<>
10)
フォーム送信時にDispatchします。
1export const Editor = () => {
2 const { user } = useAuth()
3 const { dispatch } = useComments()
4 const [content, setContent] = useState('')
5
6 const handleSubmit = (e: React.FormEvent) => {
7 e.preventDefault()
8 if (content !== '' && user) {
9 const toPost: ICommentAdd = {
10 user: { displayName: user.displayName, photoURL: user.photoURL },
11 content,
12 }
13 dispatch({
14 type: 'ADD_COMMENT',
15 comment: {
16 ...toPost,
17 createdAt: new Date(),
18 id: Date(),
19 },
20 })
21 } else if (!user) {
22 alert('Sign in first')
23 }
24 setContent('')
25 }
26
27return (...)
28}
useEffect
でコンポーネントを読み込むタイミングでDispatchします。
1export const CommentList = () => {
2 const { state, dispatch } = useComments()
3 const dcomments: IComment[] = [
4 {
5 user: user1,
6 content:
7 'first comment',
8 createdAt: new Date(),
9 id: 'comment1id',
10 },
11 {
12 user: user1,
13 content: '元気ですか',
14 createdAt: new Date(),
15 id: 'comment2id',
16 },
17 ]
18
19 useEffect(() => {
20 let unmount = false
21 if (!unmount) {
22 console.log('set comments called')
23 dispatch({ type: 'SET_COMMENTS', comments: dcomments })
24 }
25 return () => {
26 unmount = true
27 }
28 }, [dispatch])
29
30 return (...)
31}
これでつぶやき(コメント)の状態管理は終わりです。
firestore上でデータを管理できるようにします。
firebaseコンソールで firestoreを有効にします。
firestoreへのインターフェースを実装します。ここで実装することで、将来別のAPIを使った時にも 関数名、引数、返り値を同じにすることでView側を変更しなくても良いように、疎結合に実装します。もっと厳密にやるならinterfaceを定義したり、Dipendency Injectionをすることになります。
1import { firedb, firebase } from '../firebase'
2import { IComment, ICommentAdd } from '../models'
3
4export const getComments = async () => {
5 const snapShot = await firedb
6 .collection('comments')
7 .orderBy('createdAt', 'desc')
8 .get()
9 const data = snapShot.docs.map<IComment>((doc) => ({
10 user: doc.data().user,
11 content: doc.data().content,
12 createdAt: doc.data().createdAt.toDate(),
13 id: doc.id,
14 }))
15 return data
16}
17
18export const addComment = async (comment: ICommentAdd) => {
19 return firedb.collection('comments').add({
20 user: comment.user,
21 content: comment.content,
22 createdAt: firebase.firestore.Timestamp.now(),
23 })
24
使用時には、主にDispatch呼び出し前におき、結果をDispatchに渡します。
コメントリスト
1export const CommentList = () => {
2 const {
3 state: { comments },
4 dispatch,
5 } = useComments()
6 useEffect(() => {
7+ getComments().then((data) => {
8+ dispatch({ type: 'SET_COMMENTS', comments: data })
9+ })
10 }, [dispatch])
11
12 return (...)
13}
エディター
1export const Editor = () => {
2 const { user } = useAuth()
3 const { dispatch } = useComments()
4 const [content, setContent] = useState('')
5
6 const handleSubmit = (e: React.FormEvent) => {
7 e.preventDefault()
8 if (content !== '' && user) {
9 const toPost: ICommentAdd = {
10 user: { displayName: user.displayName, photoURL: user.photoURL },
11 content,
12 }
13+ addComment({ ...toPost })
14 dispatch({
15 type: 'ADD_COMMENT',
16 comment: {
17 ...toPost,
18 createdAt: new Date(),
19 id: Date(),
20 },
21 })
22 } else if (!user) {
23 alert('Sign in first')
24 }
25 setContent('')
26 }
ブラウザでつぶやいてみると、firestoreにもデータが追加されているのが分かります
Editor Component
で、ユーザーではない場合投稿できないようにしましたが、直接APIを知られてしまった場合、投稿できてしまいます。さらに今のままだと投稿するユーザー名を偽装して、本人以外の名を騙り投稿できてしまいます。
そのようなことがないように、コンソールでrule
を書くことで、セキュリティを守ります。ローカル環境で書いてデプロイすることも可能ですが、ここではコンソールに直接書いてます。
左下のルールプレイグラウンドでは、いろいろな条件でルールをテストできるので、試してみると良いです。
今回のルールはこちら
1rules_version = '2';
2service cloud.firestore {
3 match /databases/{database}/documents {
4 match /{document=**} {
5 match /comments/{comment} {
6 allow read: if true;
7 allow create: if request.auth.token.name == request.resource.data.user.displayName
8 }
9 }
10 }
11}
公式: https://firebase.google.com/docs/rules/basics?hl=ja
ここまででアプリが完成したらFirebase Hostingサービスにデプロイします。
コンソールからHostingを有効にします。
1# firebase cliをインストールして、deployコマンドを使えるようにします。
2npm install -g firebase-tools
3# 認証してコンソールで作ったプロジェクトを選択します。
4firebase login
firebaseのファイルを作成します。
1firebase init
いろいろ聞かれます。Hostingだけ選択し、
What do you want to use as your public directory? には build
を指定します。
あとは好きなものを選んでください。
最終的にこんなファイルができていれば大丈夫です。
1{
2 "hosting": {
3 "public": "build",
4 "ignore": [
5 "firebase.json",
6 "**/.*",
7 "**/node_modules/**"
8 ],
9 "rewrites": [
10 {
11 "source": "**",
12 "destination": "/index.html"
13 }
14 ]
15 }
16}
Githubと連携してCICDやプルリクでデプロイしてくれるのですが、その設定は扱いません。調べてみてください。
プロジェクトをビルド後、デプロイします!
1npm run build
2firebase deploy --only hosting
うまくいけば、ターミナルに出てくるurlがデプロイ先です!!!
今回作ったアプリにはつぶやきの削除、ユーザー設定、ユーザーページなど機能が足りません。ここまで読んでくれた方はこれを発展させて、改造させて、面白いものを作ってみてください。もし作った時はコメントから報告してくれると嬉しいです。
初めて包括的な記事を書いたので足りないところはあると思いますが、楽しんでいただけたら何らかのアクションをしてくれると嬉しいです。ここまで読んでくれてありがとうございました。次回はもうちょっと高度なことか、コンポーネント設計に関することを書きたいと思います。