🤒

【React】グローバル状態管理で作るpopup良きかも!?😍

2024/09/28に公開

はじめに(いつも通り雑談を挟んで)

どうもこんにちは!
てるし〜です。
現在9月も終盤を迎え10月に入ろうかとしていますが急に涼しくなり見事に風邪をひきました😷
みなさん、くれぐれも体調には気をつけてください!!

さてさて、本日の記事のテーマはグローバルの状態管理で作るpopupです。
webアプリやモバイルアプリのwebviewのアプリ開発でpopupを作ることがあるのではないでしょうか?中にはアプリ内の複数のページでたくさんのpopupを作成しなければならないなんていうこともあるのではないでしょうか?

解決方法の一つとしてはuseStateを用いて各ページでtruefalseでポップアップを表示するという方法がありますが、ページ数が多いアプリで中身のデザインも数種類のポップアップを多くのページで宣言しなくてはいけないとなるとかなりしんどくかつソースコードも汚くなって保守しにてしまうのではないか思います。

それを解消できそうだな〜と思うものが、useReduceruseContextとカスタムhookを用いた実装方法です。今回はその実装方法を紹介していこうと思います。

実際に作ったポップアップのデモ

説明に入る前に作成したものを見ていきます。
まず1ページ目には時間で閉じるpopupを設置しました。

2ページ目には「閉じる」ボタンを押して閉じるpopupを設置しました。

今回開くと閉じるときにアニメーションを入れてみました。
その際にframer-motionを用いました。
https://www.framer.com/motion/

useReduceruseContextについて

それでは実際にメインのhooks2つを説明していきます。

useReducer

公式から引用します。

useReducer は、リデューサ (reducer) をコンポーネントに追加するための React フックです。

ではリデューサとはなんなのでしょうか?
リデューサはコンポーネントの外部にある単一の関数であり状態(state)を更新するためのロジックを集約したものになります。コンポーネントが大きくなってくると状態を変更するためのロジックが大きくなっていきます。それを一つの関数にまとめたものと思えば良いですかね(すっげ〜曖昧な説明🤪)。

https://ja.react.dev/reference/react/useReducer
https://ja.react.dev/learn/extracting-state-logic-into-a-reducer

useContext

公式から引用します。

useContext はコンポーネントでコンテクスト (Context) の読み取りとサブスクライブ(subscribe, 変更の受け取り)を行うための React フックです。

useContextはコンポーネント深くなっていったとしても状態を直接取得できるものだと私は認識しています。
もしuseContextを使わない場合はuseStateを使ってpropsにバケツリレーをしていくと思いますが、子コンポーネントが多ければ多いほどバケツリレーが長くなっていってしまいますね。

バケツリレーというとメタリカが、、、流れてくる、、、🎸(意味がわからない人は「空耳 メタリカ」とでもYoutubeで検索してみてください)

https://ja.react.dev/reference/react/useContext
https://ja.react.dev/learn/passing-data-deeply-with-context

実装に使用した技術

実装の説明に入る前に使用技術の説明です。
https://nextjs.org/
https://vanilla-extract.style/

私の記事を見てくれている人なら馴染みのあるセットです。

実装 part1

早速実装のフェーズに入りましょう!

https://github.com/ShionTerunaga/popup-sample

この章ではリデューサとコンテキストの実装についてを説明していきます。
リポジトリではsrc/storeの中の部分に当たります。

1. 型定義

型定義から入っていきます。型定義すべきものとしては

  • 状態
  • アクション
  • コンテキスト
    の3つになります。
popup.types.ts
import { Dispatch, ReactNode } from "react"

export type popupState = {
    isShow: boolean
    children?: ReactNode
}

export type popupAction = {
    type: "show" | "hide"
    children?: ReactNode
}

export type popupContextType = {
    state: popupState
    dispatch: Dispatch<popupAction>
}

今回typeは開くか閉じるかの2つになりますが、場合によっては3つ、4つ...と増えていくことがあります。childrenはpopupの中身の部分です。

2. リデューサ

ポップアップの状態を変更するロジックを書いていきます。

popup-reducer.tsx
import { popupAction, popupState } from "./popup.types"

export const popupReducer = (
    popupState: popupState,
    popupAction: popupAction
): popupState => {
    switch (popupAction.type) {
        case "show":
            return {
                isShow: true,
                children: popupAction.children
            }
        case "hide":
            return {
                isShow: false
            }
        default:
            return {
                ...popupState
            }
    }
}

割とシンプルだとは思いますが、実際の案件ではここがtypeによって複雑になることがあります。処理としてはtypeによってそれぞれに該当した状態を返しています。

3. 状態の初期値とコンテキスト

まずは状態の初期化を定義しておきます。

popup-initial-state.ts
import { popupState } from "./popup.types"

export const popupInitialState: popupState = {
    isShow: false
}

popupは最初は開いていない状態なので上記のような初期値にしておきます。
次にコンテキストを作成します。

popup-context.tsx
import { createContext } from "react"
import { popupContextType } from "./popup.types"
import { popupInitialState } from "./popup-initial-state"

export const PopupContext = createContext<popupContextType>({
    state: popupInitialState,
    dispatch: () => {}
})

たった今、定義した初期値およびdispatchを空の関数を定義しコンテキストを作成します。

これでpart1はの実装は終了です。

実装 part2

次にカスタムhookを作成していきます。
カスタムhookを作ることでview,view/modelの部分の実装が軽くなります。
共通のロジックはなるべく集約してしまえといった考えです。
該当ディレクトリはsrc/hooksです。

1. カスタムhook

popup.ts
import { PopupContext } from "@/store/popup-context"
import { ReactNode, useContext } from "react"

export const usePopup = () => {
    const { state, dispatch } = useContext(PopupContext)

    /** popupを閉じる */
    const closePopup = () => {
        dispatch({
            type: "hide"
        })
    }

    /** 時間で閉じるpopupを開く */
    const openPopupClosingInTime = (children: ReactNode, ms?: number) => {
        const timer: number = ms || 3000

        dispatch({
            type: "show",
            children: children
        })

        setTimeout(() => {
            closePopup()
        }, timer)
    }

    /** クローズボタンを押さないと閉じないpopupを開く */
    const openPopupClosingInButton = (children: ReactNode) => {
        dispatch({
            type: "show",
            children: children
        })
    }

    return {
        state,
        openPopupClosingInTime,
        openPopupClosingInButton,
        closePopup
    }
}

ここに決まったロジックを集約しておけばview、view/model側ではこのhooksを宣言しpopupの中身や表示時間等を渡してあげるだけで良いという嬉しい状態(私だけがそう思っているのかも?)になります。

実装 part3

次にpopup骨組み部分とproviderを作っていきます。
該当ディレクトリはsrc/providerです。

1. 骨組み

では骨組みを書いていきます。

layout/popup-layout.tsx
"use client"

import { usePopup } from "@/hooks/popup"
import styles from "./style.css"
import { AnimatePresence, motion } from "framer-motion"

const PopupLayout = () => {
    const { state } = usePopup()

    return (
        <AnimatePresence>
            {state.isShow && (
                <div className={styles.container}>
                    <motion.div
                        className={styles.box}
                        initial={{ opacity: 0 }}
                        animate={{ opacity: 1 }}
                        exit={{ opacity: 0 }}
                        transition={{ duration: 0.3 }}
                    >
                        {state.children}
                    </motion.div>
                </div>
            )}
        </AnimatePresence>
    )
}

export default PopupLayout

先ほど作ったカスタムhookを先頭で呼び出し状態をそれぞれ利用して描画しています。ここの部分でアニメーションの実装もしています。

2. provider

providerの実装をしましょう。

popup-provider.tsx
"use client"

import { PopupContext } from "@/store/popup-context"
import { popupInitialState } from "@/store/popup-initial-state"
import { popupReducer } from "@/store/popup-reducer"
import { ReactNode, useReducer } from "react"
import PopupLayout from "./layout/popup-layout"

interface props {
    children: ReactNode
}

export const PopupProvider = (props: props) => {
    const [state, dispatch] = useReducer(popupReducer, popupInitialState)

    return (
        <PopupContext.Provider value={{ state, dispatch }}>
            <PopupLayout />
            {props.children}
        </PopupContext.Provider>
    )
}

情報量が段々多くなってきましたが、

  • useReducerを定義してリデューサと初期値を引数とする
  • 作成したコンテキストのproviderでページコンポーネントであるchildrenと骨組みを囲う
    • valueにはuseReducerの状態とアクションをそれぞれ渡してあげる
      といった形になります。

https://ja.react.dev/blog/2024/04/25/react-19#context-as-a-provider

実装 part5

下周りのロジックの実装が終わったので次はpopupのコンテンツとコンポーネントの実装をしていきます。

1. ポップアップのコンテンツ

今回は2つサンプルを用意しました。
該当のディレクトリはsrc/contentsです。

sample1.tsx
import styles from "./style.css"

export const Sample1 = () => {
    return (
        <div className={styles.container}>
            <div className={styles.box}>
                <h1>サンプル1</h1>
                <p className={styles.text}>
                    これはサンプル1のポップアップです。
                </p>
            </div>
        </div>
    )
}
sample2.tsx
import { PopupCloseButton } from "@/components/popup-close-button"
import styles from "./style.css"

export const Sample2 = () => {
    return (
        <div className={styles.container}>
            <div className={styles.box}>
                <h1>サンプル2</h1>
                <p className={styles.text}>
                    これはサンプル2のポップアップです。
                </p>
                <div>
                    <PopupCloseButton />
                </div>
            </div>
        </div>
    )
}

sample1は閉じるボタンなしのpopup、sample2は閉じるボタンありのpopupにしています。
閉じるボタンの実装に関しては次のセクションで話します。

2. コンポーネント

コンポーネントは今回は閉じるボタンと開くボタンのみです。

2.1. 開くボタン

まずはpopupを開くボタンの実装です。

popup-open-button.tsx
"use client"

import { usePopup } from "@/hooks/popup"
import { ReactNode } from "react"
import styles from "./style.css"

interface props {
    hasButton?: boolean
    children: ReactNode
}

export const PopupOpenButton = (props: props) => {
    const { hasButton = false } = props
    const { openPopupClosingInButton, openPopupClosingInTime } = usePopup()

    const handleClick = () => {
        if (hasButton) {
            openPopupClosingInButton(props.children)
        } else {
            openPopupClosingInTime(props.children)
        }
    }

    return (
        <button className={styles.button} onClick={handleClick}>
            開く
        </button>
    )
}

propsはボタンがあるpopupかどうかのhasButtonとpopupのコンテンツのchildrenを設定しています。先ほど作成したカスタムhooksを利用してボタンがクリックされた時の処理を書いています。

2.2. 閉じるボタン

popupを閉じるボタンを実装します。

popup-close-button.tsx
"use client"

import { usePopup } from "@/hooks/popup"
import styles from "./style.css"

export const PopupCloseButton = () => {
    const { closePopup } = usePopup()

    return (
        <button onClick={closePopup} className={styles.button}>
            閉じる
        </button>
    )
}

こちらはpropsを与えてはいません。同様にカスタムhooksを呼び出してボタンが押されて閉じるまでの処理を書いています。

カスタムhooksを書いていることでコンポーネントのロジックの記述が軽くなるので個人的には好きです!

実装 part6

では最後に画面のコンポーネントを実装していきます。いつもはfeaturesviewに書きますが、爆速で作ったのでsrc/appの中に全部書き込みます。

1. providerを呼び出す。

まずはlayout.tsxにproviderを呼び出します。

layout.tsx
import type { Metadata } from "next"
import localFont from "next/font/local"
import "./globals.css"
+ import { PopupProvider } from "@/provider/popup-provider"

const geistSans = localFont({
    src: "./fonts/GeistVF.woff",
    variable: "--font-geist-sans",
    weight: "100 900"
})
const geistMono = localFont({
    src: "./fonts/GeistMonoVF.woff",
    variable: "--font-geist-mono",
    weight: "100 900"
})

export const metadata: Metadata = {
    title: "popup-sample",
    description: ""
}

export default function RootLayout({
    children
}: Readonly<{
    children: React.ReactNode
}>) {
    return (
        <html lang="ja">
            <body className={`${geistSans.variable} ${geistMono.variable}`}>
-               {children} 
+               <PopupProvider>{children}</PopupProvider>
            </body>
        </html>
    )
}

ここではchildrenをproviderで囲うだけです。

2. ページ1

page.tsx
import { PopupOpenButton } from "@/components/popup-open-button"
import styles from "./styles.css"
import { Sample1 } from "@/contents/sample1"
import Link from "next/link"

export default function Home() {
    return (
        <div className={styles.container}>
            <h1 className={styles.heading}>ページ1</h1>
            <PopupOpenButton>
                <Sample1 />
            </PopupOpenButton>
            <br />
            <Link href="/page2">ページ2へ</Link>
        </div>
    )
}

ここでは作成したコンポーネントやコンテンツをペタペタ貼り付けていくだけになっています(まるでライブラリのよう〜〜)。

3. ページ2

ページ2もページ1のようにコンポーネントとコンテンツを貼り付けるだけです。

page2/page.tsx
import { PopupOpenButton } from "@/components/popup-open-button"
import { Sample2 } from "@/contents/sample2"
import styles from "../styles.css"
import Link from "next/link"

const Page2 = () => {
    return (
        <div className={styles.container}>
            <h1 className={styles.heading}>ページ2</h1>
            <PopupOpenButton hasButton>
                <Sample2 />
            </PopupOpenButton>
            <br />
            <Link href="/">ページ1へ</Link>
        </div>
    )
}

export default Page2

ということで実装についてはここまでです!

まとめ

今回の記事はグローバルな状態管理でのpopupを作成してみました。他にも通信中にloading-progressでも似たようなロジックを組めると思います。

案件等でpopupの種類が多く実装に困ったときはぜひ参考にしてみてください。

これ、ライブラリ化するのもありかも...🧐

今回も読んでいただきありがとうございました。

それではまた〜👋

Discussion