🦄

Redux と Firebase バージョン9 を使ってログインとCRUD処理を実装

2022/06/02に公開約10,400字

Redux の使い方について学習したいと思い、React, Redux, Firebase を使ったシングルページアプリケーションの開発を行いました。

Firebase SDK バージョン9 を利用しており最終的なコードは GitHub にて公開しており下記のリンクからアクセスできます。

https://github.com/t-aono/redux-ec

Redux の基本

ストアで管理している値をどのコンポーネントからでも取得できるのが Redux の良いところです。

コンポーネントでのイベント→ Operation → Action → Reducer の順にデータが渡ってきて最終的にストアに値が保存されます。

今回は例としてログイン処理やCRUD処理を行う際の状態管理について見ていきます。

ログイン処理

ログイン用コンポーネントでは signIn という関数に入力したメールアドレスとパスワードを渡すようにしています。この時 useDispatch を使う必要があります。

import { useDispatch } from "react-redux";
import { signIn } from "../reducks/users/operations";

const SignIn = () => {
  const dispatch: any = useDispatch();

  ・・・

  return (
	・・・
        <PrimaryButton
          label={"サインインする"}
          onClick={() => dispatch(signIn(email, password))}
        />
	・・・
  );
};

export default SignIn;

operations の signIn 関数では Firebase Authentication のメールアドレス認証を使ってログイン処理を行います。ログインに成功したら actions の signInAction を呼び出します。

import { signInAction } from "./actions";

export const signIn = (email, password) => {
  return async (dispatch) => {
    if (email === "" || password === "") {
      alert("必須入力項目が未入力です");
      return false;
    }

    signInWithEmail(email, password).then((result) => {
      const user = result.user;

      if (user) {
        const uid = user.uid;

        getSnapshot("users", [uid]).then((snapshot) => {
          const data = snapshot.data();

          dispatch(
            signInAction({
              isSignedIn: true,
              role: data.role,
              uid: uid,
              username: data.username,
            })
          );

          dispatch(push("/"));
        });
      }
    });
  };
};

firebase/index.ts に用意した関数を使ってログイン判定とユーザー情報の取得を行います。

import { signInWithEmailAndPassword } from "firebase/auth";
import { getDoc } from "firebase/firestore";

export const signInWithEmail = (email, password) =>
  signInWithEmailAndPassword(auth, email, password);

export const getSnapshot = (path: string, segments: string[]) =>
  getDoc(doc(db, path, ...segments));

actions の signInAction では以下のように書きます。

export const SIGN_IN = "SIGN_IN";
export const signInAction = (userState: UserState) => {
  return {
    type: "SIGN_IN",
    payload: {
      isSignedIn: true,
      role: userState.role,
      uid: userState.uid,
      username: userState.username,
    },
  };
};

この actions が呼び出されることで reducers が実行され store の値が更新されます。

import * as Actions from "./actions";
import initialState from "../store/initialState";

export const UserReducer = (state = initialState.users, action) => {
  switch (action.type) {
    case Actions.SIGN_IN:
      return {
        ...state,
        ...action.payload,
      };

コンポーネント側でログイン状態をチェックする際は useSelector を使って store の値を取得することができます。今回の例だと未ログインの場合は子コンポーネントの内容を非表示にしています。

import { useDispatch, useSelector } from "react-redux";
import { getIsSignedIn } from "./reducks/users/selectors";

const Auth = ({ children }: { children: ReactNode }) => {
  const dispatch: any = useDispatch();
  const selector = useSelector((state: UsersState) => state);
  const isSignedIn = getIsSignedIn(selector);

 ・・・

  if (!isSignedIn) {
    return <></>;
  } else {
    return <>{children}</>;
  }
};

export default Auth;

ストアの値を取得する際は createSelector を使って selector を作成することで取得できます。

import { createSelector } from "reselect";

const usersSelector = (state: UsersState) => state.users;

export const getIsSignedIn = createSelector(
  [usersSelector],
  (state) => state.isSignedIn
);

CURD処理

商品の登録や編集、削除、一覧表示を行う際の operations, actions, reducers について見ていきます。

商品の登録と編集

コンポーネント側で登録ボタンを押すと operations の saveProducts を呼び出すようにします。編集時は商品の id がURLから取得できるのでそれを元に既存データを渡します。

import { useEffect } from "react";
import { saveProduct } from "../reducks/products/operations";
import { useDispatch } from "react-redux";

const ProductEdit = () => {
  const dispatch: any = useDispatch();
	let id = window.location.pathname.split("/product/edit")[1];

  if (id !== "") {
    id = id.split("/")[1];
  }

 useEffect(() => {
    if (id !== "") {
      getSnapshot("products", [id]).then((snapshot) => {
        const data = snapshot.data();
        setImages(data.images);
        setName(data.name);
        setDescription(data.description);
        setCategory(data.category);
        setGender(data.gender);
        setPrice(data.price);
        setSizes(data.sizes);
      });
    }
  }, [id]);

	・・・

	return (
	  ・・・ 
          <PrimaryButton
            label={"商品情報を保存"}
            onClick={() =>
              dispatch(
                saveProduct(
                  id,
                  name,
                  description,
                  category,
                  gender,
                  images,
                  price,
                  sizes
                )
              )
            }
          />
	  ・・・

既存データは firestore/index.ts に用意した関数を使って取得します。

import { getDoc } from "firebase/firestore";

export const getSnapshot = (path: string, segments: string[]) =>
  getDoc(doc(db, path, ...segments));

operations の saveProducts で Firestore に登録するようにします。actions, reducers は作成しませんでした。

import { push } from "connected-react-router";

export const saveProduct = (
  id,
  name,
  description,
  category,
  gender,
  images,
  price,
  sizes
) => {
  return async (dispatch) => {
    const timestamp = FirebaseTimestamp.now();

    const data: Product = {
      category: category,
      description: description,
      gender: gender,
      images: images,
      name: name,
      price: parseInt(price, 10),
      sizes: sizes,
      update_at: timestamp,
    };

    return updateDoc("products", [id], data)
      .then(() => {
        dispatch(push("/"));
      })
      .catch((error) => {
        throw new Error(error);
      });
  };
};

登録処理は firebase/index.ts に用意した関数を利用。

import { setDoc } from "firebase/firestore";

export const updateDoc = (path: string, segments: string[], data) =>
  setDoc(doc(db, path, ...segments), data, { merge: true });

商品の一覧表示

コンポーネント側で operations の fetchProducts と selectors の getProducts をそれぞれ使うようにします。引数については商品を絞り込むために指定しています。

import { useEffect } from "react";
import { fetchProducts } from "../reducks/products/operations";
import { getProducts } from "../reducks/products/selectors";

const ProductList = () => {
  const selector = useSelector((state: any) => state);
  const products = getProducts(selector);

  useEffect(() => {
    dispatch(fetchProducts(gender, category, perPage));
  }, [query]);

  ・・・

operations の fetchProducts では以下のようにして firestore から商品データを取得します。

export const fetchProducts = (gender, category, perPage) => {
  return async (dispatch) => {
    const productList = [];
    let query = getQuery("products", [], "update_at", "desc");
    ・・・
    const snapshots = await getCollection(query);
    snapshots.forEach((snapshot) => {
      const product = snapshot.data();
      productList.push(product);
    });
    const maxPage = Math.ceil(productList.length / perPage);
    dispatch(fetchProductsAction(productList, maxPage));
  };
};

コレクションの取得は firebase/index.ts に用意した関数を利用。

import { getDoc, query, collection, orderBy } from "firebase/firestore";

export const getQuery = (
  path: string,
  segments: string[],
  order: string,
  sort: OrderByDirection
) => query(collection(db, path, ...segments), orderBy(order, sort));

export const getCollection = async (query) => await getDocs(query);

actions の fetchProductsAction ではストアに登録するためのデータを定義。

export const FETCH_PRODUCTS = "FETCH_PRODUCTS";
export const fetchProductsAction = (products, maxPage) => {
  return {
    type: "FETCH_PRODUCTS",
    payload: { list: products, maxPage: maxPage },
  };
};

reducers でこの action が呼ばれた際の処理を作成する。今回の例だと state.products の中の list に商品データのリストがセットされるはずです。

import * as Actions from "./actions";
import initialState from "../store/initialState";

export const ProductReducer = (state = initialState.products, action) => {
  switch (action.type) {
    case Actions.FETCH_PRODUCTS:
      return {
        ...state,
        list: [...action.payload.list],
        maxPage: action.payload.maxPage,
      };

selectors の getProducts では state.products の list を取得することができます。

import { createSelector } from "reselect";
import { ProductsState } from "./types";

const productsSelector = (state: ProductsState) => state.products;

export const getProducts = createSelector(
  [productsSelector],
  (state) => state.list
);

これでコンポーネント側でストアの値を取得できました。

商品の削除

コンポーネント側で operations の deleteProduct を呼び出しようにします。この際に商品の id を引数で渡します。

import { useDispatch } from "react-redux";
import { deleteProduct } from "../reducks/products/operations";
	・・・
	const dispatch: any = useDispatch();
	・・・

	return (
	  ・・・
          <MenuItem
            onClick={() => {
              dispatch(deleteProduct(props.id));
              handleClose();
            }}
          >
            <ListItemText>削除する</ListItemText>
          </MenuItem>

operations の deleteProduct では getState を使うことで変更前のデータを取得できます。
今回の例では商品一覧から削除対象を除いたものを actions に渡すようにしました。

export const deleteProduct = (id) => {
  return async (dispatch, getState) => {
    removeDoc("products", [id]).then(() => {
      const prevProducts = getState().products.list;
      const nextProducts = prevProducts.filter((product) => product.id !== id);
      dispatch(deleteProductAction(nextProducts));
    });
  };
};

データベースの値削除は firebase/index.ts で用意した関数で行っています。

import { deleteDoc } from "firebase/firestore";

export const removeDoc = (path: string, segments: string[]) =>
  deleteDoc(doc(db, path, ...segments));

actions の deleteProductActions では商品一覧のデータを渡すようにします。

export const DELETE_PRODUCT = "DELETE_PRODUCT";
export const deleteProductAction = (products) => {
  return {
    type: "DELETE_PRODUCT",
    payload: products,
  };
};

reducers で削除後の商品一覧データがストアにセットされます。

import * as Actions from "./actions";
import initialState from "../store/initialState";

export const ProductReducer = (state = initialState.products, action) => {
  switch (action.type) {
    case Actions.DELETE_PRODUCT:
      return {
        ...state,
        list: [...action.payload],
      };

まとめ

Redux の使い方について例を示して解説してみました。

React, Redux, Firebase を使ったシングルページアプリケーションの開発は最近よく見るので基礎を理解して使いこなせるようになっておきたいですね。

今回の内容が理解の手助けになれば幸いです。

Discussion

ログインするとコメントできます