【React】グローバル状態管理で作るpopup良きかも!?😍
はじめに(いつも通り雑談を挟んで)
どうもこんにちは!
てるし〜です。
現在9月も終盤を迎え10月に入ろうかとしていますが急に涼しくなり見事に風邪をひきました😷
みなさん、くれぐれも体調には気をつけてください!!
さてさて、本日の記事のテーマはグローバルの状態管理で作るpopupです。
webアプリやモバイルアプリのwebviewのアプリ開発でpopupを作ることがあるのではないでしょうか?中にはアプリ内の複数のページでたくさんのpopupを作成しなければならないなんていうこともあるのではないでしょうか?
解決方法の一つとしてはuseState
を用いて各ページでtrue
かfalse
でポップアップを表示するという方法がありますが、ページ数が多いアプリで中身のデザインも数種類のポップアップを多くのページで宣言しなくてはいけないとなるとかなりしんどくかつソースコードも汚くなって保守しにてしまうのではないか思います。
それを解消できそうだな〜と思うものが、useReducer
とuseContext
とカスタムhookを用いた実装方法です。今回はその実装方法を紹介していこうと思います。
実際に作ったポップアップのデモ
説明に入る前に作成したものを見ていきます。
まず1ページ目には時間で閉じるpopupを設置しました。
2ページ目には「閉じる」ボタンを押して閉じるpopupを設置しました。
今回開くと閉じるときにアニメーションを入れてみました。
その際にframer-motion
を用いました。
useReducer
とuseContext
について
それでは実際にメインのhooks2つを説明していきます。
useReducer
公式から引用します。
useReducer
は、リデューサ (reducer) をコンポーネントに追加するための React フックです。
ではリデューサとはなんなのでしょうか?
リデューサはコンポーネントの外部にある単一の関数であり状態(state)を更新するためのロジックを集約したものになります。コンポーネントが大きくなってくると状態を変更するためのロジックが大きくなっていきます。それを一つの関数にまとめたものと思えば良いですかね(すっげ〜曖昧な説明🤪)。
useContext
公式から引用します。
useContext はコンポーネントでコンテクスト (Context) の読み取りとサブスクライブ(subscribe, 変更の受け取り)を行うための React フックです。
useContext
はコンポーネント深くなっていったとしても状態を直接取得できるものだと私は認識しています。
もしuseContext
を使わない場合はuseState
を使ってpropsにバケツリレーをしていくと思いますが、子コンポーネントが多ければ多いほどバケツリレーが長くなっていってしまいますね。
バケツリレーというとメタリカが、、、流れてくる、、、🎸(意味がわからない人は「空耳 メタリカ」とでもYoutubeで検索してみてください)
実装に使用した技術
実装の説明に入る前に使用技術の説明です。
私の記事を見てくれている人なら馴染みのあるセットです。
実装 part1
早速実装のフェーズに入りましょう!
この章ではリデューサとコンテキストの実装についてを説明していきます。
リポジトリではsrc/store
の中の部分に当たります。
1. 型定義
型定義から入っていきます。型定義すべきものとしては
- 状態
- アクション
- コンテキスト
の3つになります。
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. リデューサ
ポップアップの状態を変更するロジックを書いていきます。
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. 状態の初期値とコンテキスト
まずは状態の初期化を定義しておきます。
import { popupState } from "./popup.types"
export const popupInitialState: popupState = {
isShow: false
}
popupは最初は開いていない状態なので上記のような初期値にしておきます。
次にコンテキストを作成します。
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
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. 骨組み
では骨組みを書いていきます。
"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を先頭で呼び出し状態をそれぞれ利用して描画しています。ここの部分でアニメーションの実装もしています。
provider
2. provider
の実装をしましょう。
"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
の状態とアクションをそれぞれ渡してあげる
といった形になります。
-
実装 part5
下周りのロジックの実装が終わったので次はpopupのコンテンツとコンポーネントの実装をしていきます。
1. ポップアップのコンテンツ
今回は2つサンプルを用意しました。
該当のディレクトリはsrc/contents
です。
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>
)
}
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を開くボタンの実装です。
"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を閉じるボタンを実装します。
"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
では最後に画面のコンポーネントを実装していきます。いつもはfeatures
やview
に書きますが、爆速で作ったのでsrc/app
の中に全部書き込みます。
1. providerを呼び出す。
まずはlayout.tsx
にproviderを呼び出します。
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
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のようにコンポーネントとコンテンツを貼り付けるだけです。
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