ReactのRedux(@reduxjs/toolkit)とFirebaseを使ったアプリ開発に取り組んでみた

こんにちは、次世代デジタル基盤開発事業部の渡邊です。

今回もReactに関連した記事をご紹介します。 過去にアドベントカレンダーでご紹介したアプリを別の技術を採用して構築したので、その際に取り組んだことの備忘録となります。

開発環境

今回の取り組みでは以下の環境で開発を行いました。

[環境]

  • WSL2
  • Ubuntu 20.04.1 LTS
  • Node.js 16.13.0
  • Firebase 9.8.2
  • npm 8.1.0
  • React 18.1.0
  • VS Code 1.70.1

[パッケージ(※)]※主要なものだけ掲載

  • @mui/material 5.8.2
  • @reduxjs/toolkit 1.8.3
  • react-redux 8.0.2
  • firebase 9.8.2
  • firebase-tools 10.2.0

完成イメージの紹介

この後で実装したコードなど載せていきますが、先に今回の取り組みで作ったアプリを紹介します。 完成イメージは画像の通りになります。

[初期表示時]

[検索時(該当データありの場合)]

[検索時(該当データなしの場合)]

これらで表示されているデータは予めFirebaseに登録してあります。 今回の目標としては、「予めFirebaseに登録されているデータを検索条件に応じて表示させる」というものになります。

環境構築

それでは、この章では開発を行うための環境構築についてご紹介していきます。 ただ、WSL2,Ubuntu,Node.js,npm,VS Codeの導入手順に関しては割愛させていただきます。 詳しくはこちらに手順が載っていますので参考にしてください。

[参考]
WSL 2 上で Node.js を設定する | Microsoft Docs

ここでは大きく分けて以下の2つの作業を行います。

  • CRA(create-react-app)を使ってプロジェクトを作成
  • Firebaseのインストール,設定
CRA(create-react-app)によるプロジェクト作成

Reactを使った開発でプロジェクトを作成するにあたり、今回もCRA(create-react-app)を活用しました。VS CodeのターミナルでUbuntu環境下の任意フォルダにて以下のコマンドを実行します。

npx create-react-app 作成するプロジェクト名

ターミナルで作成したプロジェクトまでカレントディレクトリを移動した後に[npm start]を実行することでCRAを使って作成して起動したときに表示されるお馴染みの画面が出てきます。

Firebaseのインストールと設定

次はFirebaseのインストールと設定を行います。 こちらは手順が多いので詳細は割愛しますが、作業を行う際に参考となった動画とサイトを載せておきます。

[参考動画]
①. 日本一わかりやすいReact-Redux入門#2...CRAとFirebaseで環境構築 - YouTube
②. Firebase Authで認証機能を作ろう【日本一わかりやすいReact-Redux講座 実践編#2】 - YouTube

[Firebaseのインストールと設定]

手順 作業内容 参考URL 上記の該当動画
1 プロジェクト作成 リンク
2 リソースロケーションの設定 リンク
3 Firestoreのデータ作成 リンク
4 Firebase CLIのインストール リンク
5 Firebaseプロジェクトの初期化 リンク
6 Firebase用の設定ファイルを生成 リンク

[補足]
参考動画として載せている[【とらゼミ】トラハックのエンジニア学習講座]というチャンネルではReactに関する動画も投稿されていて、 自分もReactを学習する際はとても重宝しているのでReactを学習したい方にはオススメです。

www.youtube.com

UIの実装

環境構築が出来上がったので、ここからは今回作成したアプリのUI実装内容についてご紹介します。改めて載せますが、今回作成したアプリは以下のようなものになります。

このアプリでは実装していくうえでコンポーネントを大きく3つの分類に分けました。

  • ヘッダー
  • 検索フォーム
  • リスト表示

それでは、各構成の実装を行っていきます。

ヘッダー(Header.jsx)

Material UIの公式ドキュメントに載っているAppBarコンポーネントを活用します。

ただし、今回はメニューバーやログイン機能の実装はしないので、不要な部分は取り除きます。取り除いた結果が以下になります。

import React from 'react'
import AppBar from '@mui/material/AppBar';
import Box from '@mui/material/Box';
import Toolbar from '@mui/material/Toolbar';
import Typography from '@mui/material/Typography';

const Header  = () => {
    return (
        <Box sx={{ flexGrow: 1 }}>
          <AppBar position="static">
            <Toolbar>
              <Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
                マンガ管理アプリ
              </Typography>
            </Toolbar>
          </AppBar>
        </Box>
      );
}

export default Header
検索フォーム

検索フォームでは、さらに入力欄ごとにコンポ―ネントを分けて実装しました。今回だと以下の4つに構成を分けています。

  • フォーム全体
  • テキストボックス
  • プルダウン
  • ボタン

入力欄で分けることで、他の画面を実装する際に共通コンポーネントとして活用することが可能となります。それでは、各コンポーネントのコードを載せていきます。

[フォーム全体(Form.jsx)]
このコンポーネントが検索フォームのまとめ役を担っています。上記で説明した各コンポーネントをインポートしています。 useStateを活用して入力欄の情報を保持して、検索ボタン押下時にそれらの情報をまとめてdispatch処理しています。

import React, { useState } from 'react'
import FormControl from '@mui/material/FormControl';
import ComButton from './common/ComButton'
import PullDown from './common/PullDown'
import TextInput from './common/TextInput'
import { useDispatch } from 'react-redux';
import { getSearchResult } from '../redux/slice/comicSlice';

const Form = () => {
    const [title, setTitle] = useState("")
    const [categories, setCategories] = useState([])
    const dispatch = useDispatch()

    const textInputSet = (event) => {
        setTitle(event.target.value)
    }

    const search = () => {
        let searchData = {}
        searchData.title = title
        searchData.categories = categories
        dispatch(getSearchResult(searchData))
    }

    return (
        <FormControl sx={{ py: 2 }}>
            <TextInput value={title} onChange={textInputSet} />
            <PullDown value={categories} onSelect={setCategories} />
            <ComButton onClick={search} />
        </FormControl>
    );
}

export default Form

[テキストボックス(TextInput.jsx)]
こちらのコンポーネントでは、呼ばれた際にMaterial-UIのTextFieldコンポーネントを返すように実装しています。

import React from 'react'
import TextField from '@mui/material/TextField';

const TextInput = (props) => {
    return (
        <TextField
            id="standard-basic"
            label="タイトル"
            value={props.value}
            onChange={props.onChange}
            sx={{ width: 300 }}
        />
    )
}

export default TextInput

[プルダウン(PullDown.jsx)]
Material-UIの公式ドキュメントに載っているSelectコンポーネントの実装例を少しカスタマイズして使用しています。

import React from 'react';
import { useTheme } from '@mui/material/styles';
import OutlinedInput from '@mui/material/OutlinedInput';
import InputLabel from '@mui/material/InputLabel';
import MenuItem from '@mui/material/MenuItem';
import FormControl from '@mui/material/FormControl';
import Select from '@mui/material/Select';

const ITEM_HEIGHT = 48;
const ITEM_PADDING_TOP = 8;
const MenuProps = {
  PaperProps: {
    style: {
      maxHeight: ITEM_HEIGHT * 4.5 + ITEM_PADDING_TOP,
      width: 250,
    },
  },
};

const names = [
  'アクション',
  'ミステリー',
  'スポーツ',
  'ファンタジー',
  'ドラマ',
  '日常',
  'グルメ'
];

function getStyles(name, personName, theme) {
  return {
    fontWeight:
      personName.indexOf(name) === -1
        ? theme.typography.fontWeightRegular
        : theme.typography.fontWeightMedium,
  };
}

export default function MultipleSelect({ value, onSelect }) {
  const theme = useTheme();

  const handleChange = ({ target }) => {
    onSelect(target.value)
  };

  return (
    <div>
      <FormControl sx={{ my: 1, width: 300 }}>
        <InputLabel id="demo-multiple-name-label">カテゴリ</InputLabel>
        <Select
          labelId="demo-multiple-name-label"
          id="demo-multiple-name"
          multiple
          value={value}
          onChange={handleChange}
          input={<OutlinedInput label="カテゴリ" />}
          MenuProps={MenuProps}
        >
          {names.map((name) => (
            <MenuItem
              key={name}
              value={name}
              style={getStyles(name, value, theme)}
            >
              {name}
            </MenuItem>
          ))}
        </Select>
      </FormControl>
    </div>
  );
}

[ボタン(ComButton.jsx)]
こちらのコンポーネントでは、呼ばれた際にMaterial-UIのButtonコンポーネントを返すように実装しています。

import React from 'react'
import Button from '@mui/material/Button';

const ComButton = (prop) => {
    return (
        <Button
            sx={{ width: 75 }}
            variant="contained"
            onClick={() => prop.onClick()}
        >
            検索
        </Button>
    )
}
リスト表示(List.jsx)

最後はListコンポーネントになります。こちらはMaterial-UIの公式ドキュメントに載っているTableコンポーネントの実装例をカスタマイズして実装しました。

この中でFirestoreから取得したデータを整形してリスト表示させているのですが、処理としては大きく2つになります。

  • 初期表示時:useEffectを用いてデータ取得処理を実施。
  • 検索時:@reduxjs/toolkitパッケージの機能を用いてデータ取得処理を実施。

実際のコードは以下になります。

import React, { useEffect, useId, useRef, useState } from 'react'
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import Paper from '@mui/material/Paper';
import { getList } from '../firebase/firestore'
import { useSelector } from 'react-redux'

const List = () => {
  const [result, setResult] = useState([])
  const id = useId()
  const data = useSelector((state) => state.comic.resultData)
  const errorMessage = useSelector((state) => state.comic.errorMessage)
  let renderFlag = useRef(false)
  let list = useRef([])

  if (result && result.length !== 0) {
    list.current = result
    setResult([])
  } else if (data && data.length !== 0) {
    list.current = data
  }

  useEffect(() => {
    console.log('useEffect内')
    if (!renderFlag.current) {
      getList().then((snap) => {
        for (let i = 0; i < snap.length; i++) {
          setResult((result) => [...result, snap[i]])
        }
      })
      renderFlag.current = true
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [])

  return (
    <>
      <TableContainer component={Paper}>
        <Table sx={{ minWidth: 650 }} size="small" aria-label="a dense table">
          <TableHead>
            <TableRow>
              <TableCell>タイトル</TableCell>
              <TableCell>カテゴリ</TableCell>
              <TableCell>読んだ巻数</TableCell>
            </TableRow>
          </TableHead>
          {errorMessage === '' && (
            <TableBody>
              {list.current.length >= 1 && (
                list.current.map((data, index) => (
                  <TableRow
                    key={`index${id}_${data.id}`}
                    sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
                  >
                    <TableCell>{data.title}</TableCell>
                    <TableCell>{data.category}</TableCell>
                    <TableCell>{data.volume}</TableCell>
                  </TableRow>
                ))
              )}
            </TableBody>
          )}
        </Table>
      </TableContainer>
      {errorMessage !== '' && (
          <div>{errorMessage}</div>
      )}
    </>
  );
}

export default List

この章ではUIの実装部分をご紹介したいので、一旦ListコンポーネントのuseEffect処理をコメントアウトした状態でブラウザでどのようにアプリが表示されるかを確認します。そうすると、リストのところはヘッダーだけが表示された状態となります。

これでUIの実装は完了です。次からはバックエンドを中心とした実装を説明していきます。

Firebaseからのデータ取得に関する実装

この章ではFirestoreからデータを取得する処理についてご紹介します。 その前の事前準備としてFirestoreにlistというコレクションを用意して、その中に今回使うデータを登録します。

今回のアプリ作成に当たってfirestore.jsというファイルを用意して、その中でメイン処理を2つ実装しました。

  • getList:ブラウザ初期表示時にFirestoreからデータを全件取得する処理
  • searchResult:検索条件に応じた結果をFirestoreから取得する処理

それでは、各データ取得タイミングについてご説明してきます。

初期表示時(getList)

初期表示時はgetList関数を呼び出してFirestoreからデータを全件取得します。 getList関数内は以下のような実装になっています。

export const getList = async () => {

    const list = []

    const q = query(listRef);
    const querySnapshot = await getDocs(q);
    querySnapshot.forEach((doc) => {
        let record = {}
        record = doc.data()
        record.id = doc.id.substring(0, 7)
        list.push(record)
    });

    return list
}

また、それを呼び出すのはListコンポーネントのuseEffect内で実現しています。

  useEffect(() => {
    console.log('useEffect内')
    if (!renderFlag.current) {
      getList().then((snap) => {
        for (let i = 0; i < snap.length; i++) {
          setResult((result) => [...result, snap[i]])
        }
      })
      renderFlag.current = true
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [])

上記ではuseEffectの中でuseRefを用いて処理を少し工夫しました。 というのも以下の記事でも紹介されていますが、React v18からStructCode配下のコンポーネント内で実装されているuseEffectの挙動が変わったことが関係しています。

とくに第二引数に空の配列を指定したuseEffectは影響が出るらしく、複数回呼ばれる可能性があるようです。

[参考]
React 18 alpha版発表まとめ
Adding Reusable State to StrictMode · Discussion #19 · reactwg/react-18 · GitHub

たしかに、useEffect内にconsole.log()を入れると2回呼び出されるのは確認していました。 そのため、それを抑止する方法として冒頭でお話したuseRefを活用しています。 活用方法は以下の記事を参考に実装しました。

[参考]
useEffect 初回レンダリングで走るな!!! - Qiita

useRefを使ってフラグを用意して、そのフラグがtrueの場合にgetList関数を呼び出さないよう制御しています。今回の実装だと以下の部分が該当箇所になります。

let renderFlag = useRef(false)
if (!renderFlag.current) {
  getList().then((snap) => {
    for (let i = 0; i < snap.length; i++) {
      setResult((result) => [...result, snap[i]])
    }
  })
  renderFlag.current = true
}

これによって、アプリが起動した際にgetList関数が呼び出されてブラウザには全データが表示されるようになります。

検索時(searchResult)

検索時はsearchResult関数を呼び出してFirestoreから検索条件に応じたデータを取得しています。今回の実装では、検索フォームの入力パターンに応じてクエリが生成されるよう分岐処理を入れました。

export const searchResult = async (title, categories) => {
    let searchQuery = ""
    let resultData = []

    try {
        if (title && categories.length === 0) {
            searchQuery = query(listRef, orderBy('title'), startAt(title), endAt(title + '\uf8ff'))
        } else if (title && categories.length !== 0) {
            searchQuery = query(listRef, orderBy('title'), startAt(title), endAt(title + '\uf8ff'), where('category', 'in', categories))
        } else if (!title && categories.length !== 0) {
            searchQuery = query(listRef, where('category', 'in', categories))
        } else {
            searchQuery = query(listRef);
        }

        const querySnapshot = await getDocs(searchQuery);
        querySnapshot.forEach((doc) => {
            let record = {}
            record = doc.data()
            record.id = doc.id.substring(0, 7)
            resultData.push(record)
        });

        if (resultData.length === 0) {
            throw new Error('該当データなし')
        }

    } catch (error) {
        throw error
    }

    return resultData
}

今回のクエリ生成では、Firestoreの制約で部分一致検索では前方一致のみ対応しています。 公式ドキュメントなどに目を通してみたのですが、後方一致での検索方法が見つからず。。。
前方一致の実装方法を参考に以下のような実装で後方一致が出来たりしないかと試してみたりしてみましたが、やっぱりうまくいきませんでした。

[後方一致として試したコード(複数パターン)]

query(listRef, orderBy('title'), startAt('\uf8ff' + title), endAt('\uf8ff' + title + '\uf8ff'))
query(listRef, orderBy('title'), startAt('\uf8ff'), endAt('\uf8ff' + title + '\uf8ff'))

searchResult関数の呼び出しは、ブラウザで[検索]ボタンを押下した際にReactの@reduxjs/toolkitパッケージの機能を用いて行っています。処理結果としては以下の通りです。

Firestoreによるデータ取得処理の説明は以上となります。 最後に出てきた@reduxjs/toolkitに関しては次の章でご説明します。

@reduxjs/toolkitによるstate管理

この章では検索フォームの入力内容に応じて取得したデータを@reduxjs/toolkitを用いてstateで管理する方法についてご紹介していきます。今回の実装では大きく分けて2つの構成を用意して実装しました。

  • Store
  • Slice&createAsyncThunk

それでは、最初に@reduxjs/toolkitについて簡単に説明した後で上記についてご説明していきます。

@reduxjs/toolkitとは

最初に@reduxjs/toolkitについて簡単にご説明すると、データを一元管理するのに役立つライブラリとなっています。

同じような役割を持つライブラリとしてreduxというものもありますが、React v18からはreduxライブラリを使ってstate管理する際に使う一部機能の使用を推奨していないようです。 というのも、実際にreduxライブラリに含まれるcreateStoreを使おうとした際に取り消し線が付きました。

該当箇所にマウスのカーソルを合わせると、以下のように[createStoreではなく@reduxjs/toolkitのconfigureStoreを使ってねー]というメッセージが出てきます。

We recommend using the configureStore method of the @reduxjs/toolkit package, which replaces createStore.
Redux Toolkit is our recommended approach for writing Redux logic today, including store setup, reducers, data fetching, and more.
For more details, please read this Redux docs page: https://redux.js.org/introduction/why-rtk-is-redux-today
configureStore from Redux Toolkit is an improved version of createStore

そのため、Reactを使って開発する際にreduxを使ってstateを管理したい場合は@reduxjs/toolkitを使って実装するのが良いかもしれません。

Store

stateに保存される検索情報をリスト表示するListコンポーネントから取り出せるようにアクセスできる環境としてStoreと呼ばれるものを実装します。

import { configureStore } from '@reduxjs/toolkit';
import comicSlice from './slice/comicSlice'

export const store = configureStore({
    reducer: {
        comic: comicSlice,
    },
});

reducerの中にプロパティとして後ほどご紹介するSliceを定義することで、Slice内でstateに保存されている検索情報を取得することができます。

また、Storeを使えるようにするためにCRAプロジェクト作成時に生成されたindex.jsでインポートされているAppコンポーネントをラッピングするようにstoreをpropsとして持つProviderを実装する必要があります。

import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import { Provider } from 'react-redux';
import { store } from './redux/store'

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
  <React.StrictMode>
    <Provider store={store}>
      <App />
    </Provider>
  </React.StrictMode>
);

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

以上でStoreの実装は完了となります。

slice&createAsyncThunk

state管理する際にinitialState,action,reducerを目的別で用意するための方法としてSliceと呼ばれるものを実装します。

これまでは、それらを目的別/機能別に別ファイルを用意して実装していたと思います。 ただ、@reduxjs/toolkitパッケージ内のcreateSliceを使うことでそれらを1つのソースファイル内で実装することが可能となりました。

また、今回は非同期処理を実現するためにcrateAsyncThunkという機能も使って実装しています。それらを実装した結果が以下になります。

import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";
import { searchResult } from "../../firebase/firestore";

export const getSearchResult = createAsyncThunk('comic/searchResult', async (searchData) => {
    const title = searchData.title
    const categories = searchData.categories
    return await searchResult(title, categories)
});

export const comicSlice = createSlice({
    name: 'comic',
    initialState: {
        resultData: [],
        errorMessage: "",
    },
    extraReducers: {
        [getSearchResult.fulfilled]: (state, action) => {
            state.resultData = action.payload
        },
        [getSearchResult.rejected]: (state, action) => {
            state.errorMessage = action.error.message
        }
    }
});

export const {search} = comicSlice.actions;

export default comicSlice.reducer;

createAsyncThunkの処理結果に応じてstateへの更新を制御できるようにcreateSliceのextraReducers内で処理をパターン分けしました。

  • 処理が成功(fulfilled)した場合はFirestoreから取得したデータをstateで管理するresultDataに保存。
  • 処理が失敗(rejected)した場合はエラーメッセージをstateで管理するerrorMessageに保存。

実装したSliceとcreateAsyncThunkを使えるようにFormとListコンポーネントを修正します。

[Formコンポーネントの修正]
検索時に検索結果がstateに保存できるように、useDispatchを設定します。

具体的には、上記のSlice内で実装したcreateAsyncThunkを呼び出せるようuseDispatchを使ってdispatch処理させます。

const dispatch = useDispatch()
const search = () => {
    let searchData = {}
    searchData.title = title
    searchData.categories = categories
    dispatch(getSearchResult(searchData))
}

[Listコンポーネントの修正]
useSelecterを設定して、SliceとcreateAsyncThunkによってstateに保存された情報を取得します。

const data = useSelector((state) => state.comic.resultData)
const errorMessage = useSelector((state) => state.comic.errorMessage)

これによって、検索フォームの入力に応じてリスト表示が変わるようになりました。

[検索時(該当データがある場合]

[検索時(該当データがない場合]

以上で、今回作成したアプリの実装が完了となります。

まとめ

いかがだったでしょうか。 アドベントカレンダーの際に取り組んだ時とは別の技術を採用して今回も似たようなReactのアプリを開発してみました。

@reduxjs/toolkitは今回初めて使ったので、まだ理解が不十分で手探りでやった感が否めないです。。。 この辺りも勉強を継続して理解を深めていきたいです。

今回の記事が少しでも参考になったら幸いです。最後まで読んでくださりありがとうございました!

www.tecotec.co.jp