😃

React + Inertia.js で複雑な階層構造サイドバーの学習済み状態管理を実装する

に公開

はじめに

現在、私が個人開発している学習プラットフォームで、ユーザーの学習進捗を視覚的に表示するサイドバーを実装しています。

本記事では、React + Inertia.js + Laravel環境で、まあまあ、複雑な要件を持つサイドバーの学習済み状態管理を実装する方法を解説します。

要件:

  • 3階層の入れ子構造(カテゴリ > サービス > 詳細ページ)
  • 個別ページの学習済み状態管理
  • サービス全体の学習完了判定(全てのページが完了した場合のみチェック表示)
  • カテゴリ全体の学習完了判定

問題の背景

当初、単純な個別ページの学習済み判定のみを実装していましたが、以下の問題が発生しました:

発生した問題

  • EC2の全6ページが学習済みなのに、EC2にチェックマークが表示されない
  • S3の8ページ中1ページが未学習なのに、S3にチェックマークが表示される

これは、階層構造における学習済み判定ロジックが複雑になっていたためです。

全体のアーキテクチャ

階層構造

データベース設計

-- 学習済み状態を管理するテーブル
CREATE TABLE learned_items (
    id BIGINT PRIMARY KEY,
    user_id BIGINT NOT NULL,
    item_key VARCHAR(255) NOT NULL, -- 'ec2_overview', 's3_storage' など
    is_learned BOOLEAN DEFAULT FALSE,
    created_at TIMESTAMP,
    updated_at TIMESTAMP,
    UNIQUE(user_id, item_key)
);

実装のポイント

1. item_key生成ロジック

URLパスから学習項目のキーを生成する関数:

// hrefからitem_keyを生成する関数
const toItemKey = (href: string) => {
    const parts = href.split('/').filter(Boolean);

    // URLの構造を分析
    // /dashboard/basics/cloud_onpremise → basics_cloud_onpremise
    // /dashboard/main-services/ec2/ec2_overview → ec2_overview

    if (parts.length >= 4 && parts[1] === 'main-services') {
        // 主要なサービスの場合(例:/dashboard/main-services/ec2/ec2_overview)
        return parts[3]; // ec2_overview
    } else if (parts.length >= 3 && parts[1] === 'basics') {
        // クラウドの基本の場合(例:/dashboard/basics/cloud_onpremise)
        return `${parts[1]}_${parts[2]}`; // basics_cloud_onpremise
    }

    return '';
};

2. サービス別の学習完了判定

各サービスで必要な全ページが学習済みかをチェックする関数:

// EC2の子ページがすべて学習済みかチェックする関数
const isEc2AllLearned = () => {
    const ec2ChildKeys = ['ec2_overview', 'ec2_instance_family', 'ec2_storage', 'ec2_keypair', 'ec2_placementgroup', 'ec2_purchase_options'];
    const result = ec2ChildKeys.every((key) => learnedItems.includes(key));
    return result;
};

// S3の子ページがすべて学習済みかチェックする関数
const isS3AllLearned = () => {
    const s3ChildKeys = [
        's3_overview',
        's3_storage',
        's3_data_encryption',
        's3_data_protection',
        's3_object_lock',
        's3_life_cycle',
        's3_data_retrieval_access_optimization',
        's3_storage_lens',
    ];
    const result = s3ChildKeys.every((key) => learnedItems.includes(key));
    return result;
};

everyメソッドを使用することで、配列のすべての要素が条件を満たしているかを簡潔にチェックできます。

3. 階層別の判定ロジック

親要素レベルの判定

// 親要素(カテゴリレベル)の学習済み判定
let isLearned = learnedItems.includes(itemKey);
if (item.title === 'クラウドの基本') {
    isLearned = isBasicsAllLearned();
} else if (item.title === 'EC2') {
    isLearned = isEc2AllLearned();
} else if (item.title === 'S3') {
    isLearned = isS3AllLearned();
}

子要素レベルの判定

重要なポイント: 子要素レベルでも同じ判定ロジックを適用する必要があります。

// 子要素の学習済み判定
{item.children.map((child) => {
    const childKey = toItemKey(child.href);
    let isChildLearned = learnedItems.includes(childKey);

    // EC2とS3の場合は特別な判定を使用
    if (child.title === 'EC2') {
        isChildLearned = isEc2AllLearned();
    } else if (child.title === 'S3') {
        isChildLearned = isS3AllLearned();
    }

    return (
        <SidebarMenuSubItem key={child.href}>
            <SidebarMenuSubButton asChild>
                <Link href={child.href} prefetch>
                    {isChildLearned && <Check className="mr-1 h-5 w-5 font-bold text-green-600" />}
                    <span>{child.title}</span>
                </Link>
            </SidebarMenuSubButton>
        </SidebarMenuSubItem>
    );
})}

よくある落とし穴と解決策

問題1: 階層レベルでの判定不整合

問題: 親要素で学習完了判定を実装したが、子要素レベルでは個別のitem_keyのみでチェックしていた。

解決策: 子要素レベルでも同じ判定関数を呼び出すようにする。

問題2: サイドバー設定と判定関数の不一致

問題: サイドバーには6つのEC2ページがあるのに、判定関数では2つしかチェックしていなかった。

// ❌ 間違った実装
const isEc2AllLearned = () => {
    const ec2ChildKeys = ['ec2_overview', 'ec2_instance_family']; // 2つだけ
    return ec2ChildKeys.every((key) => learnedItems.includes(key));
};
// ✅ 正しい実装
const isEc2AllLearned = () => {
    const ec2ChildKeys = ['ec2_overview', 'ec2_instance_family', 'ec2_storage', 'ec2_keypair', 'ec2_placementgroup', 'ec2_purchase_options']; // 実際のページ数と一致
    return ec2ChildKeys.every((key) => learnedItems.includes(key));
};

問題3: コードの変更が反映されない

原因: 開発サーバーのキャッシュ問題

解決策:

  1. ハードリロード(Ctrl+Shift+R
  2. 開発サーバーの再起動
  3. ブラウザの開発者ツールでキャッシュ無効化

デバッグ手法

複雑な階層構造では、詳細なデバッグログが重要です:

// デバッグ情報の表示
console.log('=== サイドバー デバッグ情報 ===');
console.log('学習済み項目一覧:', learnedItems);

// 各サービスの詳細チェック
const checkResults = s3ChildKeys.map((key) => ({
    key,
    isLearned: learnedItems.includes(key),
}));
console.log('各項目チェック結果:', checkResults);

パフォーマンス考慮事項

メモ化による最適化

学習済み判定は頻繁に呼ばれるため、メモ化を検討:

import { useMemo } from 'react';

export function NavMain({ items = [] }: { items: NavItem[] }) {
    const { learned_items } = usePage<{ learned_items: string[] }>().props;

    // 学習済み判定をメモ化
    const isEc2AllLearned = useMemo(() => {
        const ec2ChildKeys = ['ec2_overview', 'ec2_instance_family', ...];
        return ec2ChildKeys.every((key) => learned_items.includes(key));
    }, [learned_items]);

    const isS3AllLearned = useMemo(() => {
        const s3ChildKeys = ['s3_overview', 's3_storage', ...];
        return s3ChildKeys.every((key) => learned_items.includes(key));
    }, [learned_items]);

    // ...
}

今後の拡張性

新しいサービスを追加する際の手順:

  1. サイドバー設定に追加app-sidebar.tsx
  2. 判定関数を作成nav-main.tsx
  3. 親要素・子要素の両方で判定ロジックを追加
  4. ルート設定web.php
  5. コントローラーメソッドShowPagesController.php

まとめ

複雑な階層構造を持つサイドバーの学習済み状態管理では、以下のポイントが重要です:

重要なポイント

  • 階層レベルごとに適切な判定ロジックを実装
  • 親要素と子要素で一貫した判定を行う
  • サイドバー設定と判定関数の整合性を保つ
  • 詳細なデバッグログでトラブルシューティング
  • パフォーマンスを考慮したメモ化の活用

このような実装により、ユーザーの学習進捗を正確かつ直感的に表示できるサイドバーを構築できます。

参考資料

Discussion