【React】Contextから渡される値や関数の振る舞いを動的に切り替える
スペースマーケット技術部の和山です、みなさんこんにちは。
先日劇団四季のアナと雪の女王を見てきました。
これだけは言わせてください。
「本当にすごい」
僕は四季に詳しいわけでもないのですが、ここ2、3年でノートルダムの鐘から始まり、アラジンのために何度か足を運び、そして先日アナと雪の女王を見ました。
プロジェクションマッピングの使い方ももちろんなんですが、僕が個人的にすごいなと思ったのがアナとエルサの子役の役者様たちです。
出だしの二人の演技に一気に作品の世界に没入させられました。
また見たい、明日にでも見たいという気持ちでいっぱいです。
そのくらいよかったので是非チケット取れそうな方は見て欲しい作品です。
と雑談はこのくらいにして、今回はタイトルの通りでReactのContextを使ったお話です。
前回React Contextとは?という話をしましたので、まだ読んでない方は是非こちらもご一読ください。
今回はこのContextを使ってContextから渡される値や関数のを切り替えるという話になります。
今回の記事の説明用のコードをGithubのリポジトリにご用意していますので、合わせてこちらも使いながら読んでいただけると幸いです。
おさらい
まずは詳しい話を始める前に、React Contextのおさらいをしておきましょう。
Contextとは
各階層で手動でプロパティを下に渡すことなく、コンポーネントツリー内でデータを渡す方法
と定義されています。
これがどういうことかというと、通常だと親から孫コンポーネントへ値を渡す場合は、子コンポーネントのPropsを一度経由してそこから孫コンポーネントのPropsへ渡すと思います。
さらにコンポーネントの階層が深くなると親から来たPropsを子へ受け流すだけのコンポーネントが発生します。
俗に言う「Propsのバケツリレー」ですね。
具体的な例を挙げるとすると、この値がプロジェクト全体で利用するカラーテーマなどを定義しているオブジェクトの場合、Atomic Designでいうところのatomsレベルまでバケツリレーが必要になるケースが出てきます。
そうなってくるとただただ大変というだけではなく、受け渡し時に抜け漏れが発生したり、受け渡しのためだけのPropsを受け取るとコンポーネントの仕様自体が分かりづらくなったりと非効率且つ、開発体験が著しく下がることは容易に想像できるでしょう。
そこでこれを解決するのがContextです。
Contextは値をPropsで渡すのではなく、Contextのツリー階層内で共有できるようになります。
とても便利ですが、使い方にも気を付ける必要があります。
この辺りについては実際にどのようにして実装していくのかという点については前回の記事を参考にいただけると幸いです。
今回やること
では、今回やることをご紹介します。
今回のテーマは表題にもある通り「Contextから配布される値や関数を動的に切り替える」です。
説明のために軽く動くものを作っていきましょう。
動くものがあればよりイメージしやすくなります。
今回は簡単にデータの一覧画面とそのデータを登録・編集・削除する画面を作っていきます。
よくある簡単なCRUD処理を行う掲示板のようなアプリケーションです。
簡単に画面イメージを書くとこんな感じになります。
一覧ページから「追加する」といったボタンを押すと登録画面へ遷移します。
さらに一覧で表示されているデータの「編集」ボタンを押すと編集画面へ行き、「削除」を押すと削除画面に遷移します。
大変シンプルな仕様です。
また、登録画面・編集画面・削除画面では以下のような仕様が存在しているとします。
・編集、削除画面では、現在登録されているデータがフォームに表示されていること
・登録画面では、フォームが空であること
・削除画面では、フォームの編集ができないこと
・登録、削除画面では、項目が埋まっていない場合はボタンが活性化しないこと
・各画面で表示するUIについてはボタンの文言や上記の仕様など軽微な差はあるが同じもので良い
今回は同じコンポーネントを使い回していきたいので、この仕様を取り込むためにContextを使っていきます。
それでは早速実装していきましょう。
今回はAPI用のサーバを別途立てるのが面倒なので、APIも実装できるNext.jsを使っていきます。
APIの環境を用意する
ということで、フロントエンドを開発する前にAPI環境を整えていきましょう。
フロントエンドのコードを期待した方はごめんなさい。
たまには簡単なバックエンドのコードも書いていきましょう笑
実装については今回のテーマからずれるためあまり詳細には説明しませんが、今回はPostgreSQL + Docker + Prismaを使い環境を整えていきます。
PostgreSQL + Dockerの環境の作成はこちらのymlを参考にしてください。
Prismaのスキーマ定義や初期データについてもこちらをご参考にお願いします!
今回使う簡単なテーブル定義はこちらです。
ご自身の好きなように改変していただいてOKです。
// マイグレーション実行後に出力されるSQL
CREATE TABLE "Item" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"price" INTEGER NOT NULL,
"area" TEXT NOT NULL,
"description" TEXT NOT NULL,
CONSTRAINT "Item_pkey" PRIMARY KEY ("id")
);
さて、これでデータベースの準備はできましたのでAPIを用意していきましょう。
Next.jsはpagesディレクトリで動的ルーティングを行なっています。
さらに pages/api というディレクトリでAPIを用意することも可能になっています。
今回はこちらの機能を使ってREST APIを作成していきます。
APIの実装はこちらの実装を参考にしてください。
/api/admin/itemsで呼ばれた場合は、全件取得を行います。
/api/admin/items/[id] で呼ばれた場合は、1件取得、編集、削除のいずれかがリクエストされたメソッドに応じて実行されます。
/api/admin/items/new でPOSTリクエストされた場合は、登録が実行されます。(切り分けるのが面倒だったのでファイル的には[id]の中に登録も含んでいます)
データベースとのアクセスについてはRepository層の実装をご確認ください。
個人的な話ではありますが、Prismaの使い方には少しクセがあるなと思いました。
動的な検索条件を流し込みたい時にWhereのデータ作成に少し手惑いました。
Whereの条件を生成するための関数みたいなのが必要そうだなとか思ったりしたんですが、実はこうするといいよみたいなベストプラクティスがあれば是非教えていただきたいと思います!
一覧画面を実装
作ったAPIを使って一覧画面を先に作っていきましょう。
今回はページネーションなども用意しないので、APIのデータはSSR時に取得するようにしておき、データをPropsで流しますようにします。
おそらく以下のような処理になると思います。
// src/pages/admin/items/index.tsx
export const getServerSideProps = async (
context: GetServerSidePropsContext,
) => {
const { resolvedUrl, req } = context
// GETリクエストなのでクエリパラメータを付与したままリクエストを飛ばす
// -> 絞り込みの条件を追加しなくて良い
const result = await fetch(`http://${req.headers.host}/api/${resolvedUrl}`)
const data = await result.json()
return {
props: { data },
}
}
const AdminItemPage: VFC<Props> = (props) => {
return <ItemsPage {...props} />
}
export default AdminItemPage
コンポーネントの詳細実装についてはこちらをご確認ください。
今回はおまけで簡単なフィルタ実装も行っています。
同じプロジェクトを立ち上げているときっと以下のような画面になると思います。
この中にはアイテムを追加する、編集、削除ボタンを用意しておき各ボタンからページ遷移することが可能です。
詳細画面などは特に用意していないので、好みで別途用意してもらっても良いです。
登録/編集/削除画面の作成
それでは上記の画面を作っていきましょう。
要件を再度確認すると以下になります。
・編集、削除画面では、現在登録されているデータがフォームに表示されていること
・登録画面では、フォームが空であること
・削除画面では、フォームの編集ができないこと
・登録、削除画面では、項目が埋まっていない場合はボタンが活性化しないこと
・各画面で表示するUIについてはボタンの文言や上記の仕様など軽微な差はあるが同じもので良い
まずは「編集、削除画面では、現在登録されているデータがフォームに表示されている」とのことなので、データを取得してくるところを作成しましょう。
今回もgetServerSidePropsを使っていきます。
// src/pages/admin/items/[id].tsx
export const getServerSideProps = async (
context: GetServerSidePropsContext,
) => {
const { query, req } = context
// IDをもとにデータを取得
const result = await fetch(
`http://${req.headers.host}/api/admin/items/${query.id}`,
)
const data = await result.json()
return {
props: { data },
}
}
こんな感じでしょうか。
また、今回削除画面でも全く同じデータを引いてくるので以下のようにしてしまうことをお勧めします。
// src/pages/admin/items/delete/[id].tsx
export { getServerSideProps } from '../[id]'
こうすることで別々にデータ取得処理を管理する必要がなくなります。
登録画面については取得できるデータがないため、getServerSidePropsの実装は不要です。
それでは次にコンポーネントを作っていきましょう。
今回細かいコンポーネントは使い回す為、pagesの実装は先ほどと同じように編集画面でexport defaultしたものを登録/削除画面で同じくimportし、exportしてしまいましょう。
// src/pages/admin/items/delete/[id].tsx
export { getServerSideProps, default } from '../[id]'
また、コンポーネントの細かい実装はこちらを見ていただきたいのですが、必要な項目は以下になります。
・name(名前)の入力フォームがある
・price(価格)の入力フォームがある
・area(エリア)の入力フォームがある
・description(説明)の入力フォームがある
・上記のフォームの内容をsubmitできるボタンがある
・操作を止め一覧画面へ遷移する(戻る)ボタンがある
僕の実装を参考にしつつご自身でテーブルを作った場合はそちらに合わせて作ってもらえれば大丈夫です!
それでは、実際にできた画面がこちらです。
さて画面は一通りできました。
それでは処理を作っていきます。
ここからがContextの出番です。
Contextの実装
登録/編集/削除画面ではフォームに表示するデータやsubmitするボタンの振る舞いを切り替える必要があります。
どのように実装するのが良いでしょうか。
画面構成が全く同じである為、処理を切り替えるためにコンポーネントを分離するとなるとかなり手間がかかりますし、改修コストも増えます。
コンポーネントの中でURLを見て内部的に処理を切り替えることもできるでしょう。
しかし、そうした場合コンポーネント内の処理が肥大化することが容易に想像できます。
例えばsubmitするボタンの処理では「登録」「更新」「削除」の3種類の場合分けが必要になります。
また、フォームの活性状態も「登録/更新」「削除」の2種類の場合分けが必要になるなどかなり厄介です。
こんなに分岐の入り混じったコードを改修の際にあまり触りたくないですよね。
これらの課題を解決するために今回はContextを利用します。
どういうことかというと、Contextからsubmitするボタンの処理や、フォームの活性状態を配布してあげることで、コンポーネント側ではその値や関数を利用するだけの状態にしてあげます。
しかしちょっと待ってください。
先ほどの説明から行くとコンポーネントの中で分岐していたものが、ただContext側に移っただけで結局はややこしい分岐が入り混じるのではないか、そうなのであればカスタムフックにするのと大した差はないのではないかと思った方もいらっしゃるのではないでしょうか。
ご安心ください。
Contextへ渡す値や関数は「登録」「編集」「削除」の3種類の状態によってロジックを完全に分離することが可能です。
これから簡単に説明していきます。
インターフェイスを明確にする
まずは3種類の状態に応じてバラバラのデータを渡すことはできません。
画面が共通である以上、流し込む値も同一のインターフェイスである必要があります。
インターフェイスが異なってしまうと結局使う側でどの画面にいるかを判別しなければいけなくなります。
今回は簡単な画面ですので「Submitするボタン」「入力フォーム」のそれぞれの情報を流し込めるようなContextを用意します。
こちらを一括りに「フォームのContext」として作成していきましょう。
ボタンには「活性状態」「ボタンのテキスト」「Submit時の処理」が必要になります。
入力フォームには「活性状態」「初期値」が必要になります。
また、ボタンでSubmitする際のデータをコンポーネント側で管理するのは厄介なので入力フォーム側から更新できるように「dispatch」できるような関数も渡しておきましょう。
イメージとしてはフォームのonBlurイベントで値を更新させます。
onChangeで発火させてしまうと入力するたびにContextが更新されるので不必要な再レンダリングが発生するので気を付けましょう。
コアロジックの実装
インターフェイスは決まったのでこのインターフェイスに合わせて、登録/編集/削除の値や関数を用意するコアロジックを別々に作っていきましょう。
Contextで利用するのでここはカスタムフックで作っていきます。
ディレクトリ構成は以下のようにしておきます。
/context/ItemForm
└── injectable
├── add
│ └── カスタムフック
├── edit
│ └── カスタムフック
└── delete
└── カスタムフック
こうすることで、それぞれの実装が完全に分離した状態になります。
追加の場合はaddを、編集の場合はeditを、削除の場合はdeleteを確認すれば良くなり、内部的なロジックに複雑な分岐がなくなります。
内部の実装についてはこちらを参考にしてください。
Contextへ値と関数を流し込む
内部のロジックは分離できました。
それでは情報を流し込むためのContextを作っていきましょう。
type ItemFormInputContextType = {
disabled: boolean
defaultItem: ItemValueType
dispatch: (editValue: ItemValueType) => void
}
type ItemFormButtonContextType = {
disabled: boolean
text: string
onSubmit: () => Promise<void>
}
// 入力フォーム用のContext
export const ItemFormInputContext =
createContext<ItemFormInputContextType | null>(null)
// ボタン用のContext
export const ItemFormButtonContext =
createContext<ItemFormButtonContextType | null>(null)
// Wrapper
export const Provider: VFC<{
form: ItemFormInputContextType
button: ItemFormButtonContextType
children: ReactNode
}> = ({ form, button, children }) => {
return (
<ItemFormInputContext.Provider value={form}>
<ItemFormButtonContext.Provider value={button}>
{children}
</ItemFormButtonContext.Provider>
</ItemFormInputContext.Provider>
)
}
ここではコアロジックを呼び出さずにコンポーネントのPropsとして生成した値や関数を受け取ります。
Propsで受け取った値をContextのProviderへ流し込みます。
それではこのContextの値を使うためのカスタムフックを作ります。
import { useContext } from 'react'
import { ItemFormInputContext, ItemFormButtonContext } from './Provider'
// 入力フォーム側で使うフック
export const useItemFormInput = () => {
const context = useContext(ItemFormInputContext)
if (context === null) {
throw new Error('ItemFormInputのProviderが使われていません')
}
return context
}
// ボタン側で使うフック
export const useItemFormButton = () => {
const context = useContext(ItemFormButtonContext)
if (context === null) {
throw new Error('ItemFormButtonのProviderが使われていません')
}
return context
}
これで外部で使う際にはこのカスタムフックを使えばContextを使えるようになりました。
今度はContextにPropsとして情報を流し込む必要があります。
これは登録/編集/削除用のディレクトリから流し込むようにします。
import { ReactNode, VFC } from 'react'
import { ItemResponse } from '@/types/item'
import { Provider } from '../Provider'
import { useItemFormCore } from './useItemFormCore'
export const ItemFormProvider: VFC<{
data: ItemResponse
children: ReactNode
}> = ({ data, children }) => {
// コアロジックのカスタムフックを呼び出す
const [form, button] = useItemFormCore(data)
// 先ほどのContextへ流し込むためのコンポーネントへ情報を渡す
// -> Contextへformとbuttonの値が流し込まれる
return (
<Provider form={form} button={button}>
{children}
</Provider>
)
}
add/edit/deleteのディレクトリで同じ実装になると思います。
これでContextへ値を流し込めるようになりました。
現在の構成は以下のようになります。
/context/ItemForm
└── injectable
├── index.ts
├── Provider.tsx (Contextを持つコアのProvider)
├── useItemForm.ts (外部インターフェイスとなるカスタムフック)
├── add
│ ├── index.ts
│ ├── Provider.tsx (injectable/Providerへ情報を流し込むProvider)
│ └── useItemFormCore.ts (コアロジックのカスタムフック)
├── edit
│ ├── index.ts
│ ├── Provider.tsx (injectable/Providerへ情報を流し込むProvider)
│ └── useItemFormCore.ts (コアロジックのカスタムフック)
└── delete
├── index.ts
├── Provider.tsx (injectable/Providerへ情報を流し込むProvider)
└── useItemFormCore.ts (コアロジックのカスタムフック)
この時点で少しわかりづらくなってくると思います。
そのため現状の構成を図示してみました。
このような形になっています。
図で見てみるとなんとなく分かりやすくなりますね。
Contextに流し込む情報を切り替える
さてここまでできましたが、このままだとContextに流し込む情報は別々になっただけで、利用用途に応じてadd/edit/deleteのProviderをimportしコンポーネントをWrapする必要が出てきます。
これではやろうとしていることは実現できません。
ではこの問題をどのように解決すべきでしょうか。
今回はさらにもう1段階Wrapperコンポーネントを用意します。
そのWrapperの内部でどのProviderコンポーネントを使うか決めることで、情報の切り替えを実現させます。
/context/ItemForm/useInjectionProvider.ts
import { useMemo } from 'react'
import { useRouter } from 'next/router'
import {
ItemFormAddProvider,
ItemFormEditProvider,
ItemFromDeleteProvider,
} from './injectable'
export const useInjectionProvider = () => {
const { asPath, query } = useRouter()
const Provider = useMemo(() => {
if (!query.id) {
// IDを持っていない場合は追加
return ItemFormAddProvider
}
if (asPath.includes('delete')) {
// Pathにdeleteが含まれていれば削除
return ItemFromDeleteProvider
}
// それ以外の場合は編集
return ItemFormEditProvider
}, [asPath, query.id])
// 上記で使うProviderを確定させreturnする
return Provider
}
以下のようになります。
つまり、このカスタムフックの中でどのProviderを使うか決めます。
このカスタムフックをWrapしたコンポーネントで使うことで任意のProviderのデータが利用できるようになります。
/context/ItemForm/Provider.tsx
import { ReactNode, VFC } from 'react'
import { ItemResponse } from '@/types/item'
import { useInjectionProvider } from './useInjectionProvider'
export const ItemFormProvider: VFC<{
data: ItemResponse
children: ReactNode
}> = ({ data, children }) => {
// 確定されたProviderを受け取る
const Provider = useInjectionProvider()
// Providerを展開する
return <Provider data={data}>{children}</Provider>
}
今回は内部的に切り替えを完結させていますが、このProviderを外側からPropsで渡すような実装ももちろん可能です。
React ContextでDI(Dependency Injection)を実現させるようなイメージですね。
さて、最終的な状態のツリー図と構成図が下記になります。
/context/ItemForm
├── index.ts (外部インターフェイスとなるファイルをexport)
├── Provider.tsx (外部インターフェイスとなるProvider)
├── useInjectionProvider.ts (injectableにあるadd/edit/deleteのどれを使うか決めるカスタムフック)
└── injectable
├── index.ts (injectableディレクトリ内で上の階層から呼ばれるファイルをexport)
├── Provider.tsx (Contextを持つコアのProvider)
├── useItemForm.ts (外部インターフェイスとなるカスタムフック)
├── add
│ ├── index.ts (addディレクトリ内で上の階層から呼ばれるファイルをexport)
│ ├── Provider.tsx (injectable/Providerへ情報を流し込むProvider)
│ └── useItemFormCore.ts (コアロジックのカスタムフック)
├── edit
│ ├── index.ts (editディレクトリ内で上の階層から呼ばれるファイルをexport)
│ ├── Provider.tsx (injectable/Providerへ情報を流し込むProvider)
│ └── useItemFormCore.ts (コアロジックのカスタムフック)
└── delete
├── index.ts (deleteディレクトリ内で上の階層から呼ばれるファイルをexport)
├── Provider.tsx (injectable/Providerへ情報を流し込むProvider)
└── useItemFormCore.ts (コアロジックのカスタムフック)
長くなりましたが、これで実装が完了しました。
実際に動かしていただくと動作が切り替わっていることが分かると思います。
登録の際は、フォームが空になっており、フォーム入力して登録ボタンを押すと一覧にそのデータが表示されます。
編集の際は、フォームは入力済みになっており、どこかの値を更新して更新ボタンを押すと一覧にそのデータが表示されます。
削除の際は、フォームは非活性の状態であり、削除ボタンを押すと一覧からデータが消えます。
コンポーネントは全く同じものですが、ページが切り替わることでContextから渡ってきた値や関数が異なるため、仕様通りの動作になっています。
今後編集ボタンを押したらこうしたいという要望が出た時は編集用のコアロジックを修正するだけで良くなりました。
かなりDXが向上したと思います。
どういう時に使うべきか
さてここまで実装の説明してきましたが、どのようなユースケースで使うと良いのか少し考えてみたいと思います。
先ほどの同一画面で振る舞いが変わるケースにはもちろん良さそうです。
他にどのようなケースがあるでしょうか。
まずは純粋なカスタムフックとしてロジックが提供できないケースです。
例えばstateを一つのインスタンスで共有して持ちたいケースがこれに該当します。
const useInput = (defaultValue: string) => {
const [value, setValue] = useState<string>(defaultValue)
const onChange = useCallback(
(newValue: string) => setValue(newValue)
, [])
return [value, onChange] as const
}
このようなカスタムフック があった時にuseInputというカスタムフックの利用箇所ではインスタンスは使用箇所分作られるため、シングルトンではありません。
const Hoge: VFC = () => {
const [value, onChange] = useInput('hoge')
return <input value={value} onChange={(e) => onChange(e.target.value)} />
}
const Fuga: VFC = () => {
const [value, onChange] = useInput('fuga')
return <input value={value} onChange={(e) => onChange(e.target.value)} />
}
const Piyo: VFC = () => {
const [piyoValue, onChangePiyo] = useInput('piyo')
const [poyoValue, onChangePoyo] = useInput('poyo')
return (
<React.Fragment>
<input value={piyoValue} onChange={(e) => onChangePiyo(e.target.value)} />
<input value={poyoValue} onChange={(e) => onChangePoyo(e.target.value)} />
</React.Fragment>
)
}
上記を見ていただければわかりますが、このいずれの記述でもinputのフォームに値を入力しても他のフォームの値は変更されません。
当然ですが、これらはuseInputで提供されるvalueのインスタンスが異なるためです。
例のようなフォームの値をシングルトンで持ちたいケースはおそらくないでしょうが、そのようなケースでは有効な使い方だと思います。
他には外部のAPIを利用しており、外部APIに応じて挙動を変更しなければいけないケースです。
これは実際にVercelのプロジェクトで利用されています。
このプロジェクトではCommerceというECサイト用のアプリケーションがあり、内部的なデータなどについてはShopifyなどのAPIを利用しています。
カート機能、決済機能、商品の取得などのロジックは外部のAPIごとに変える必要があるのでそのインターフェイスごとにロジックを持つ必要があります。
vercel/commerceでは今回のように内部的な切り替えではなく、環境変数などを利用して使うContextの実装切り替えを行っています。
具体的には framework というディレクトリにそれぞれのロジックを持っており、環境変数に応じてどのロジックを使うかというのを決めています。
そしてその環境変数によって import '@/framework' を使った時に、 framework/shopifyを呼び出すのか、framework/bigcommerceを呼び出すのかという設定を変更させています。
パッケージとして様々な機能を提供し、ユーザ側で任意のものを使うといった場合はとても有効な手段です。
紹介したもの以外でも色々な実装ができそうです。
さいごに
ここまでババっと説明してきましたが、なんとなくでもイメージできたでしょうか。
おそらくこういった実装はあまり使うことはないと思うのですが、一つの手法として覚えておいてもらえると嬉しいです。
また、Contextであるため使い方については十分気をつける必要があります。
ReduxやRecoilなどにも言えますがなんでもかんでも状態管理すればいいというものではありません。
用量と用法は正しく使う必要がありますのでご注意ください。
それでは最後に、話は変わりますがスペースマーケットでは現在一緒にサービスを盛り上げてくれるエンジニアを募集中しております!
気になる方は是非以下のリンクからチェックしていただけると嬉しいです。
(気付いたら僕が写っている写真が採用のページに使われていました笑)
この記事が気に入ったらサポートをしてみませんか?