こんにちは、Inflabでフロントエンド開発をしているLuc77yです。
Inflearnの受講画面では、特定のタイミングでさまざまな関数やモーダル表示のロジックが実行されます。例えば、講座の受講中に特定の進捗率に達すると、授業動画の一時停止、受講レビュー作成モーダルの表示、お祝いメッセージモーダルの表示といったロジックが順番に実行されます。
しかし、これらのロジックの複雑な実行順序と条件分岐のせいで、コードがどんどん複雑になり、保守が難しくなるという問題を抱えていました。この問題を、ファネル(Funnel)パターンを活用した useFunnel hookで解決した経験を共有します。
問題:モーダルが次々に連鎖する地獄
既存構造の問題点
Inflearnの受講画面では、状況に応じてさまざまなモーダルやポップオーバーのUIが連鎖的に表示されます。
- 講座開始時: 開始のお祝いメッセージ → 受講画面のチュートリアルポップオーバー
- 授業完了時: (知識共有者が設定した)授業完了メッセージ → 次の授業の案内
- 特定の進捗率に到達したとき: 進捗率到達のお祝いメッセージ → 受講レビュー作成モーダル → 授業終了の案内
講座(Course): 複数の「授業」が集まって1つのカリキュラムを構成する単位です。
授業(Unit): 「講座」を構成する個々の動画、資料、または学習コンテンツを指します。
これまでは、各モーダルコンポーネントの handleClose 関数から次のモーダルを直接呼び出す方式を使っていました。
// 受講レビューモーダルコンポーネント
const ReviewModal = () => {
// 受講レビューの作成完了時
const handleConfirm = () => {
... // 受講レビュー作成APIの呼び出し
handleClose();
};
// モーダルの閉じるボタンのクリック時
const handleClose = () => {
closeReviewModal(); // 受講レビューモーダルを閉じる
if (isFromComplete) { // 講座の修了によって受講レビューモーダルが表示された場合
setIsFromCompleteModal(false); // 修了関連のグローバルステートを更新
findAndNavigateToNextCourse(); // 修了後に次の講座を探すロジックを実行
}
};
};
上記のコードには、次のような問題があります。
1. 強い結合度
各モーダルは、次に実行されるモーダルを自分で把握し、直接呼び出さなければなりません。モーダルの表示順序を変えたり、新しいモーダルを途中に挟んだりするには、関連するコンポーネントをすべて修正する必要があります。
例えば、A → B → Cの順で表示していたモーダルをA → C → Bの順に変える場合を考えます。AはBの代わりにCを開くように修正し、BからはCを開くコードを削除し、CにはBを開くコードを追加しなければなりません。
2. 条件分岐の拡散
isKo(韓国語ページでのみ表示すべきか)、isReviewable(受講レビューを作成できる状態か)、isFromComplete(たった今修了したか)など、特定の条件によってはモーダルを表示せずにスキップしなければならない場合があります。既存の方式では条件分岐のロジックが各モーダルコンポーネントに散らばっているため、ポリシーが変わるたびに関連するコンポーネントを探し出して修正しなければなりません。
特に私たちはフィーチャーフラグを使っているため、条件分岐のロジックが追加されたり削除されたりすることが頻繁にありました。
複雑さの例
実際には、次のようなシンプルな要件であっても、コードの作業量は思ったより多くなります。
AS-IS: 進捗率n%到達のお祝いメッセージモーダル → 受講レビュー作成モーダル
TO-BE: 進捗率n%到達のお祝いメッセージモーダル → 受講レビュー作成モーダル → (進捗率100%到達(修了)の場合)次に学習する講座のおすすめモーダルを追加で表示
この要件を実装するには、次の対応が必要です。
- 進捗率100%到達時(
progressが初めて100になったとき)にisFromComplete変数をtrueに設定 - 受講レビュー作成モーダルで、
isFromCompleteがtrueなら講座おすすめモーダルをopen - 受講レビュー作成モーダルで、
isFromCompleteがfalseならそのままreturn
// 受講レビュー作成モーダル
const ReviewModal = () => {
const handleClose = () => {
closeReviewModal();
if (isFromComplete) {
openRecommendationModal(); // 受講レビュー作成モーダルで、isFromCompleteがtrueなら講座おすすめモーダルをopen
return ;
}
...
};
};
こうした複雑さが生じる根本的な理由は次のとおりです。
-
isFromComplete(たった今修了したか)というcontextの中でのみ有効な分岐なので、そのcontextをグローバルステートなどで管理する必要があります。 -
要件が「講座おすすめモーダル」に関するものであれば、講座おすすめモーダルを呼び出しているのが「受講レビュー作成モーダル」であることを把握し、そこに分岐のコードを追加しなければなりません。
- 受講レビューモーダルの代わりに講座おすすめモーダルの中で
isFromCompleteの分岐を直接処理することもできますが、モーダルの流れやコードの把握がさらに難しくなります。
const ReviewModal = () => { const handleClose = () => { closeReviewModal(); openRecommendationModal(); // 受講レビュー作成モーダルで分岐せずに無条件で講座おすすめモーダルを開くと、 }; }; const RecommendationModal = () => { useEffect(() => { // 講座おすすめモーダルの中で直接分岐ロジックを処理しなければならない if (!isFromComplete) { closeRecommendationModal(); // 修了でない場合はモーダルをすぐに閉じる return; } // 修了の場合のみ実際のおすすめロジックを実行 }, []); /** * 新たな問題 * 1. 受講レビューモーダルがcloseされたあとに講座おすすめモーダルがopenされる、という全体の流れがさらに把握しにくくなる * 2. 講座おすすめモーダルが別の要因(ex> ユーザーのアクション)でopenされた場合にもisFromCompleteの分岐をチェックするため、バグが発生する可能性がある */ }; - 受講レビューモーダルの代わりに講座おすすめモーダルの中で
ロジック刷新の決定と既存ロジックの洗い出し
リファクタリングの決定
ポリシーを変更するたびに複数のコンポーネントを修正しなければならない構造は、持続可能とは思えませんでした。特に、自動メッセージ機能を追加する中で、既存のモーダルの流れに自動メッセージモーダルを割り込ませるという要件が出てきました。
自動メッセージ:授業の開始/終了、特定の進捗率への到達など、特定のタイミングで知識共有者が設定したメッセージを表示する機能
自動メッセージの例
自動メッセージは、さまざまなタイミングのモーダルの流れに割り込ませる必要がありました。表示条件も複雑で、フィーチャーフラグも使う必要がありました。そこで、自動メッセージのスプリントの期間中に、リファクタリングも並行して行うことにしました。
既存ロジックの洗い出し
本格的なリファクタリングに先立ち、受講画面のモーダルに関するロジックをすべて調査しました。
調査の結果、10個ほどのコンポーネントが複雑に絡み合った構造であることが分かりました。
モーダルコンポーネント:
-
StartMessageModal(開始メッセージ) -
CompleteModal(修了メッセージ) -
ReviewModal(受講レビューモーダル) -
NextUnitModal(次の授業を見る) - ...
モーダル表示ロジックのエントリーポイント:
- 講座開始時
- 授業終了時: 動画の再生終了、「見ました」ボタンのクリック
- 修了時
- 特定の進捗率への到達時
- ...
条件分岐の変数:
-
isReviewable(受講レビューを作成できる状態) -
isVideoUnit(動画の授業でのみ表示) -
isKo(韓国語ページでのみ表示) -
isFirstView(ブラウザごとに初回1回のみ表示) - 修了時のみ表示
- フィーチャーフラグ
- ...
これらのロジックを調査しながら、モーダル同士が複雑に絡み合っていて、1つのポリシー変更が複数ファイルの修正につながる構造であることを改めて実感しました。
ファネルパターン導入の決定
こうした複雑な状況を目の当たりにして、ファネル(Funnel)パターンを導入することにしました。
TossのSLASH 23での発表にヒントを得て、各コンポーネントを1つのStepと見なし、複数のStepを順番に表示するFunnel方式がこの状況に適していると判断しました。
Funnelコンポーネントは、通常次のような形式で書きます。
<Funnel>
<Funnel.Step name="自動メッセージ">
<AutoMessageModal onClose={nextStep} />
</Funnel.Step>
<Funnel.Step name="受講レビュー">
<ReviewModal onClose={nextStep} />
</Funnel.Step>
<Funnel.Step name="次の授業を見る">
<NextUnitModal onClose={finishFunnel} /> // プレイヤーライブラリへの依存があるため、このコンポーネントをここに移すのは難しい
</Funnel.Step>
</Funnel>
しかし受講画面の場合は、次のような状況も考慮する必要がありました。
- 各stepがUIコンポーネントであるとは限りません。
- 一般的に各stepは、UIの「次へ」ボタンを押すと
nextStepを呼び出す方式で動作します。 - しかし、コールバック関数を実行するだけで自動的に
nextStepを呼び出すstepにも対応する必要がありました。
- 一般的に各stepは、UIの「次へ」ボタンを押すと
- 既存のモーダルコンポーネントを1つの
<Funnel>の下にまとめられない状況です。- プレイヤーライブラリと結合したコンポーネントは、プレイヤー関連のコードの外に移せないといった事情があります。
そこで、既存のファネルの動作方式を参考にしながら、受講画面の状況に合わせて新しいファネルを設計することにしました。
解決策:新しいuseFunnel Hookの設計と実装
useFunnel Hookの設計
// useFunnel hookのreturn型
const useFunnel: () => {
startFunnel: (type: string) => void; // 特定のタイプ(修了、授業開始、授業終了など)のファネルの実行を開始します。
nextStep: () => void; // 現在のファネルの次のstepを実行します。
}
// ファネルのStepインターフェース
interface FunnelStep {
name: string; // stepの名前
/*
* modal: コンポーネントで「次へ」/「閉じる」ボタンを押したときにnextStep関数を実行
* function: コールバック関数の実行後、すぐにnextStep関数を自動実行
*/
type: 'modal' | 'function';
condition: () => boolean; // stepの実行条件(満たさない場合は自動でskip)
action: {
fire: () => void; // そのstepを開始するときに実行するコールバック関数(ex> モーダルをopen状態に変更)
cleanup?: () => void; // そのstepを終了するときに実行するコールバック関数
};
}
宣言的なファネル定義
各ファネル(修了ファネル、授業開始ファネルなど)は FunnelStep の配列です。
useFunnel の利用側で startFunnel 関数によって新しいファネルを開始すると、ファネルの各stepが順番に実行されます。
これで、既存の複雑な条件分岐を宣言的に定義できるようになりました。
const 修了ファネル = (): FunnelStep[] => {
return [
{
name: '自動メッセージ',
type: 'modal',
condition: () => isKo && isAutoMessageFeatureFlagOn,
action: {
fire: openCompleteModal,
},
},
{
name: '受講レビューモーダル',
type: 'modal',
condition: () => isReviewable,
action: {
fire: openReviewModal,
},
},
{
name: '次の授業を見るモーダル',
type: 'modal',
condition: () => isVideoUnit,
action: {
fire: openNextUnitModal,
},
},
{
name: 'ロードマップ関連関数',
type: 'function',
condition: () => isKo && isInRoadmap,
action: {
fire: findNextCourse,
},
},
];
};
コアロジック
useFunnel Hookのコアロジックを単純化したコードは次のとおりです。
// ファネルキューの管理
// ファネルが複数積み重なる状況に備えて配列を使います。説明では1つの場合のみを扱います。
const funnelQueueRef = useRef<FunnelQueueItem[]>([]);
// startFunnel: 新しいファネルをキューに追加
const startFunnel = (type: 'END_COURSE' | 'START_UNIT' | 'END_UNIT') => {
const newFunnel = { type, currentStepIndex: 0 }; // step indexを0に設定
funnelQueueRef.current = [...funnelQueueRef.current, newFunnel];
processStep(); // 新しいファネルの最初のstepを実行
};
// 現在アクティブなステップを取得
const getCurrentStep = () => {
const currentFunnel = funnelQueueRef.current[0];
return getFunnelSteps(currentFunnel); // ファネルのcurrentStepIndexに応じて現在のstepを返す
};
// nextStep: 現在のファネルの次のstepに進む
const nextStep = () => {
... // 実行中のファネルがなければすぐにreturn
// 現在のstepをcleanup
const currentStep = getCurrentStep();
currentStep.action.cleanup?.();
// step indexを増やす
const currentFunnel = funnelQueueRef.current[0];
currentFunnel.currentStepIndex += 1;
// stepを実行
processStep();
};
// processStep: 実際にstepを実行
const processStep = async () => {
const currentStep = getCurrentStep();
// conditionのチェック
const shouldExecute = await currentStep.condition();
if (!shouldExecute) {
// 条件を満たさない場合は次のstepへskip
nextStep();
return;
}
// stepを実行
currentStep.action.fire?.();
// functionタイプは自動で次のstepに進む
if (currentStep.type === 'function') {
nextStep();
}
// modalタイプはユーザーのアクションによってnextStep()が呼ばれるまで待機
};
return {
startFunnel,
nextStep,
}
useFunnelのreturn関数
1. startFunnel(type)
特定のタイプのファネルを実行します。ファネルを開始したい特定のタイミングで、この関数を呼び出すだけです。
useEffect(() => {
if (unit.id !== prevUnit.id) { // 新しい授業に入ったとき
startFunnel('START_UNIT'); // 授業開始ファネルを実行
}
}, [unit.id]);
useEffect(() => {
if (progress === 100) { // 進捗率100%到達時
startFunnel('END_COURSE'); // 修了ファネルを実行
}
}, [progress]);
2. nextStep()
現在のファネルの次のstepを実行します。モーダルコンポーネントでは、この関数を呼び出すだけです。
const handleClose = () => {
closeModal();
nextStep(); // 次のstepを実行
};
Contextによるグローバルな利用
useFunnel Hookを複数のコンポーネントで使えるように、Contextで提供します。
const FunnelContext = createContext<FunnelContextType | null>(null);
export const useFunnelContext = () => {
const context = useContext(FunnelContext);
if (!context) {
throw new Error('useFunnelContext must be used within a FunnelProvider');
}
return context;
};
export const FunnelProvider = ({ children }: { children: ReactNode }) => {
const funnelHook = useFunnel();
return <FunnelContext.Provider value={funnelHook}>{children}</FunnelContext.Provider>;
};
モーダルコンポーネントの単純化
これで、モーダルコンポーネントは次のようにシンプルになりました。
AS-IS: 複雑な分岐ロジック
const ReviewModal = () => {
const handleClose = () => {
closeReviewModal(); // 受講レビューモーダルを閉じる
if (isFromComplete) { // 講座の修了によって受講レビューモーダルが表示された場合
setIsFromCompleteModal(false); // 修了関連のグローバルステートを更新
findAndNavigateToNextCourse(); // 修了後に次の講座を探すロジックを実行
}
};
};
TO-BE: シンプルな構造
const ReviewModal = () => {
const handleClose = () => {
closeReviewModal(); // 受講レビューモーダルを閉じる
nextStep(); // 次のstepを実行
};
};
追加機能
コアロジック以外の付加機能も簡単に紹介します。
1. prepareConditionによるAPIの待機
条件(condition)を判定する前にdelayを入れたり、APIのレスポンスを待ったりしたい場合は prepareCondition を使います。
{
type: 'modal',
name: '開始メッセージ',
prepareCondition: async () => {
await fetchMyVoucher(); // APIのレスポンスを待機
},
condition: () => myVoucher?.isBegin,
action: {
fire: openStartMessageModal,
},
}
2. ネストしたファネル
ファネルAのstepの中で、ファネルBをネストして実行できます。
このとき、ファネルBの中ですぐに実行すべきstepと除外するstepを宣言できます。
例えば、ファネルAが a1-a2-a3、ファネルBが b1-b2-b3 のstepで進む場合に、a2のstepでファネルBをネストして実行するなら、次のようにstepを定義します。
{
type: 'funnel',
name: 'a2',
action: {
nextFunnel: {
type: 'B',
context: {
immediateSteps: ['b1'], // すぐに実行するstep
excludeSteps: ['b3'], // 除外するstep
},
},
},
}
この場合、stepは a1-a2-b1-a3-b2 の順で実行されます。
ネストしたファネルの例
導入の結果
解決された問題
1. モーダル間の疎結合
各モーダルは、もう次のモーダルを知る必要がありません。nextStep() を呼び出すだけです。
2. 条件ロジックの一元化
すべての実行条件がファネルの定義部分に集まっているので、ポリシーを変更する際は1か所を修正するだけで済みます。
3. 順序変更のしやすさ
ファネルの配列の順序を入れ替えるだけで、モーダルの実行順序が変わります。
4. モーダル表示順序の把握
ファネルの定義コードを見るだけで、特定のタイミングでどのモーダルがどの順序で実行されるのかがひと目で分かります。
一般的なファネルの概念との違い
一般的なファネルパターンではユーザーが各段階で離脱することがありますが、今回実装したファネルは順次実行を保証する構造です。ユーザーがモーダルを閉じても nextStep() が実行されて次の段階に進み、type: 'function' の場合は自動的に次のstepが実行されます。
これは受講画面の特性上、特定のタイミングで実行されるべきロジックを漏れなく順番に処理するためです。useStepper という名前のほうが正確かもしれませんが、コミュニティで一般的に使われている useFunnel を採用しました。
おわりに
受講画面の複雑なモーダルの流れをファネルパターンでリファクタリングする中で、宣言的なコードの力を改めて実感しました。
これまで各モーダルコンポーネントに散らばっていた複雑なビジネスロジックがファネルの定義部分に集まったことで、コードの可読性と保守性が向上しました。特に、新しい要件が追加されるたびに複数のコンポーネントを修正しなければならなかった苦労から解放されました。
もちろん、stepを配列の形でつないだファネルが完璧な構造とは限りません。特定のstepに移動したり戻ったりするには、Linked list構造のほうが適しているかもしれません。また、バグが発生しても、別途デバッグ用のコードを書かなければ、どのstepで起きたのかを把握するのは困難です。
それでも、コードを宣言的に書き換えながら、既存のコンポーネントコードの修正と学習コストを最小限に抑えられたので、今回のリファクタリングは良い判断だったと考えています。
サンプルコードは省略が多いので、分かりにくい点や気になる点があれば、いつでもお気軽にお知らせください。読んでいただき、ありがとうございました。
※この記事は2025年10月27日に執筆されたものです。