【必読】なぜ今、Aura ではなく LWR を選ぶべきなのか?

【DIY】LWRサイトのLightning Datatableに「レコードリンク付き」カスタム列を実装する方法

この記事はバージョン Winter ’26 において執筆しています。
現在の動作と異なる場合がありますので、ご認識おきください。

パフォーマンスが高く、柔軟なデザインが可能なLWRですが、開発者として最初に突き当たる壁があります。

それは、「標準のレコード関連コンポーネントが少ない」 という点です。

特に「レコードの一覧を表示して、名前をクリックしたら詳細ページに飛びたい」という、Auraサイトでは当たり前だった要件も、LWRの「Build Your Own」テンプレートでは自前で実装する必要があります。

今回は、Salesforce公式開発者ガイド「Create Custom Record Components」を参考に、Lightning Datatableを拡張して、レコード詳細へのリンク機能を持つカスタム列を作成する方法 を解説します。

DXforce Point for Developers

今回の記事で作成したコンポーネントの全ソースコードをGitHubで公開しています。 ぜひ Clone して、あなたの組織で動かしてみてください。

実装のゴール

  • Lightning Datatable を使用してきれいな一覧を表示する。
  • NavigationMixin を使い、LWRサイト内で高速な画面遷移を行う(リロードなし)。
  • エクスペリエンスビルダーの設定だけで「商談」「ケース」など表示対象を切り替えられる汎用設計にする。

実装の全体像

実装は以下の3つのステップで行います。

  1. リンク表示用コンポーネント (recordLink): 実際にリンクを表示し、クリックイベントを処理する部品。
  2. カスタムデータテーブル (myCustomDatatable): 標準のDatatableを拡張し、カスタム型を定義するコンポーネント。
  3. 実装ページ (recordListGeneric): 作成したテーブルを使用する親コンポーネント。

リンク表示用コンポーネントの作成

まずは、データテーブルのセルの中に表示する「リンク部品」を作成します。 標準の <a> タグの挙動をジャックし、NavigationMixin でSalesforce内部のルーティングへ流します。

recordLink.html

force-app/main/default/lwc/recordLink/recordLink.html

<template>
    <a href="#" onclick={handleClick} title={label}>
        {label}
    </a>
</template>

recordLink.js

force-app/main/default/lwc/recordLink/recordLink.js

import { LightningElement, api } from 'lwc';
import { NavigationMixin } from 'lightning/navigation';

/**
 * データテーブルのセル内で使用するリンクコンポーネント
 * NavigationMixinを使用してレコードページへ遷移します。
 */
export default class RecordLink extends NavigationMixin(LightningElement) {
    // 親コンポーネント(データテーブル)から渡される値
    @api recordId;      // 遷移先レコードID
    @api objectApiName; // 遷移先オブジェクトAPI名
    @api label;         // 表示ラベル

    /**
     * リンククリック時のハンドラ
     * 標準のレコード詳細ページへ遷移します。
     * @param {Event} event - クリックイベント
     */
    handleClick(event) {
        // デフォルトのアンカーリンク動作(ページリロード)を無効化
        event.preventDefault();
        event.stopPropagation();

        this[NavigationMixin.Navigate]({
            type: 'standard__recordPage',
            attributes: {
                recordId: this.recordId,
                objectApiName: this.objectApiName,
                actionName: 'view'
            }
        });
    }
}

カスタムデータテーブルの作成

次に、標準の LightningDatatable を継承して、新しい型 recordLink を使えるようにします。

myCustomDatatable.html

force-app/main/default/lwc/myCustomDatatable/myCustomDatatable.html

デフォルトのままでOK。

myCustomDatatable.js

force-app/main/default/lwc/myCustomDatatable/myCustomDatatable.js

import LightningDatatable from 'lightning/datatable';
import recordLinkTpl from './recordLinkTemplate.html';

/**
 * 標準のLightningDatatableを拡張し、カスタム型を定義します。
 */
export default class MyCustomDatatable extends LightningDatatable {
    static customTypes = {
        // 'recordLink' という新しい型を定義
        recordLink: {
            template: recordLinkTpl,
            // ここで定義したプロパティが、HTML側の typeAttributes に渡されます
            typeAttributes: ['id', 'objectApiName', 'label']
        }
    };
}

recordLinkTemplate.html

force-app/main/default/lwc/myCustomDatatable/recordLinkTemplate.html

※これはJSからインポートするためのテンプレートファイルです。
コンポーネントのデフォルトHTMLとは別に新しいファイルを作成して記述してください。

ここが最大のハマりポイントです。 HTMLテンプレート側で変数を受け取る際、必ず typeAttributes という名前でアクセスする必要があります。

<template>
    <c-record-link 
        record-id={typeAttributes.id}
        object-api-name={typeAttributes.objectApiName}
        label={typeAttributes.label}
        data-navigation="enable">
    </c-record-link>
</template>

実装例(親コンポーネント)

最後に、このカスタムデータテーブルを実際に使用する方法です。 列定義(columns)の type に、先ほど定義した recordLink を指定します。

recordListGeneric.html

force-app/main/default/lwc/recordListGeneric/recordListGeneric.html

<template>
    <lightning-card title={listTitle} icon-name={iconName}>
        <div class="slds-p-horizontal_medium">
            <template lwc:if={hasData}>
                <c-my-custom-datatable
                    key-field="Id"
                    data={tableData}
                    columns={columns}
                    hide-checkbox-column="true">
                </c-my-custom-datatable>
            </template>

            <template lwc:else>
                <div class="slds-p-around_medium slds-text-align_center slds-text-color_weak">
                    表示するデータがありません。
                </div>
            </template>

            <template lwc:if={error}>
                <div class="slds-p-around_medium slds-text-color_error">
                    データの取得中にエラーが発生しました。
                </div>
            </template>
        </div>
    </lightning-card>
</template>

recordListGeneric.js

force-app/main/default/lwc/recordListGeneric/recordListGeneric.js

import { LightningElement, api, wire } from 'lwc';
import { getRelatedListRecords } from 'lightning/uiRelatedListApi';

// 列定義(今回は商談用に固定していますが、ここも動的化可能です)
const COLUMNS = [
    {
        label: '案件名',
        fieldName: 'Name',
        type: 'recordLink', // 定義したカスタム型を使用
        typeAttributes: {   // ※この typeAttributes というキー名は固定(予約語)です
            id: { fieldName: 'Id' },
            objectApiName: 'Opportunity',
            label: { fieldName: 'Name' }
        }
    },
    { label: 'フェーズ', fieldName: 'StageName', type: 'text' },
    { label: '完了予定日', fieldName: 'CloseDate', type: 'date' }
];

export default class RecordListGeneric extends LightningElement {
    @api recordId;
    @api objectApiName;
    @api relatedListApiName; // エクスペリエンスビルダーから設定可能
    @api listTitle;
    @api iconName;

    columns = COLUMNS;
    tableData = [];
    error;

    @wire(getRelatedListRecords, {
        parentRecordId: '$recordId',
        relatedListId: '$relatedListApiName', 
        fields: ['Opportunity.Id', 'Opportunity.Name', 'Opportunity.StageName', 'Opportunity.CloseDate']
    })
    wiredRecords({ error, data }) {
        if (data) {
            // UI APIの複雑な構造を、Datatable用にフラット化します
            this.tableData = data.records.map(record => {
                return {
                    Id: record.id,
                    Name: record.fields.Name.value,
                    StageName: record.fields.StageName.value,
                    CloseDate: record.fields.CloseDate.value
                };
            });
            this.error = undefined;
        } else if (error) {
            this.error = error;
            this.tableData = [];
        }
    }

    get hasData() {
        return this.tableData && this.tableData.length > 0;
    }
}

recordListGeneric.js-meta.xml

force-app/main/default/lwc/recordListGeneric/recordListGeneric.js-meta.xml

<?xml version="1.0" encoding="UTF-8"?>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata">
    <apiVersion>65.0</apiVersion>
    <isExposed>true</isExposed>
    <targets>
        <target>lightningCommunity__Page</target>
        <target>lightningCommunity__Default</target> 
    </targets>
    <targetConfigs>
        <targetConfig targets="lightningCommunity__Default">
            <property 
                name="recordId" 
                type="String" 
                default="{!recordId}" 
                label="レコードID"
                description="レコード詳細ページからIDを自動取得します" />
            
            <property 
                name="objectApiName" 
                type="String" 
                label="親オブジェクトAPI名" 
                description="配置されたページのオブジェクト名" />

            <property 
                name="relatedListApiName" 
                type="String" 
                label="関連リストAPI名" 
                default="Opportunities"
                description="取得する関連リストのAPI参照名(例: Opportunities, Contacts)" />

            <property 
                name="listTitle" 
                type="String" 
                label="リストタイトル" 
                default="関連商談"
                description="コンポーネントのヘッダーに表示するタイトル" />

            <property 
                name="iconName" 
                type="String" 
                label="アイコン名" 
                default="standard:opportunity"
                description="ヘッダーに表示するアイコン(例: standard:opportunity)" />
        </targetConfig>
    </targetConfigs>
</LightningComponentBundle>
DXforce Point for Developers

LWRサイトでの recordId 取得について
Lightning Experienceとは異なり、LWRサイトでは @api recordId を定義しただけでは値が取得できません。js-meta.xml に default=”{!recordId}” を設定し、エクスペリエンスビルダー側で明示的に値をバインドさせる必要があります。本記事の js-meta.xml はこの設定を含んでいるため、配置するだけで動作します。

完成イメージ

完成したコンポーネントを配置したらこのようになります。

  • 取引先ページに関連リストを表示
  • リンククリックで商談ページへ

実装のポイント解説

今回のコードには、LWRサイト開発ならではの重要なテクニックがいくつか含まれています。それぞれ詳しく解説します。

LWRサイトにおける recordId の取得(超重要!)

Lightning Experience(管理画面)での開発に慣れていると、@api recordId を宣言しておけば勝手に値が入ってくると思いがちですが、LWRサイトでは自動で値は渡されません

LWRサイトでコンポーネントに動的な値を渡すには、「明示的なバインディング」が必要です。 今回、js-meta.xml に以下のような設定を行いました。

<property 
    name="recordId" 
    type="String" 
    default="{!recordId}" 
    description="レコード詳細ページからIDを自動取得します" />

この default="{!recordId}" という記述が肝です。これにより、エクスペリエンスビルダーが持つ「現在のページのレコードID」というコンテキスト情報が、LWCのプロパティに注入されます。これを忘れると、JS側で recordIdundefined のままになり、データが取得できません。

コンポーネントの「再利用性」を高める設計

今回はコードの中に「Opportunities(商談)」という文字列をハードコードするのを避け、プロパティ経由で渡す設計にしました。

  • relatedListApiName: 取得したい関連リストのAPI名
  • listTitle: ヘッダーに表示するタイトル
  • iconName: 表示するアイコン

これにより、エクスペリエンスビルダー上の設定画面で「関連リスト名」を書き換えるだけで、コードを修正することなくターゲットを変更できる「半汎用コンポーネント」として機能します。(※完全に汎用化するには、列定義 COLUMNS も動的に生成する必要がありますが、今回は商談用として定義しています)

UI APIデータの「フラット化」処理

getRelatedListRecords ワイヤアダプタが返すデータ構造は、少し複雑です。 例えば、商談名を取得する場合、record.fields.Name.value のように階層深くに値があります。

一方、lightning-datatable{ Name: '案件A' } のようなフラットなオブジェクト配列を期待します。そのため、JS内の .map() 関数で、APIからのレスポンスをデータテーブル用に整形(マッピング)しています。

この処理を行うことで、カスタム型 recordLink にデータを渡す際も、シンプルに fieldName を指定するだけで済むようになります。

標準 url 型ではなくカスタム型を使う理由

標準のデータテーブルにも url 型は存在しますが、これを使うと単純な <a href="..."> タグが生成されます。 LWRサイトのようなSPA(シングルページアプリケーション)において、標準リンクで遷移するページ全体の再読み込み(リロード)が発生し、パフォーマンスが低下してしまいます。

今回作成したカスタム型 recordLink は、内部で NavigationMixin を使用しています。これにより、Salesforceのルーティングシステムに乗っ取った、高速でスムーズな画面遷移を実現しています。

【要注意】公式ドキュメントの「value」とDatatableの「typeAttributes」の違い

ここで、Salesforce公式ドキュメントを読み込んでいる熱心な開発者ほどハマりやすい「落とし穴」について解説します。

今回の実装にあたり、公式ガイド「Create Custom Record Components」を参照された方もいるかもしれません。このページでは、以下のように value を使ってデータを受け取る例が紹介されています。

<template>
    <c-record-link record-id={value.id} ... ></c-record-link>
</template>

しかし、この記述を今回のカスタムデータテーブル(Lightning Datatable)内でそのまま使うと動きません。

なぜ動かないのか?

理由は、「コンポーネントが置かれている文脈(Context)」が異なるからです。

  1. 公式ドキュメントの例:
    • 文脈: LWRサイトのページに直置きする「レコード詳細コンポーネント」。
    • データの渡し方: LWRフレームワークが、レコード情報をまるごと value というプロパティに渡してくれます。
  2. 今回の実装(Datatable Custom Types):
    • 文脈: lightning-datatable の内部で生成される「テーブルの1セル」。
    • データの渡し方: データテーブルの仕様により、列定義(columns)で typeAttributes にマッピングした値は、typeAttributes というオブジェクト に格納されて渡されます。

正しい実装コード比較

データテーブルのカスタム型定義(HTMLテンプレート)では、以下のように使い分ける必要があります。

項目記述例解説
❌ 間違い{value.id}value には fieldName で指定した項目の値(例: “商談A” という文字列)しか入っておらず、id プロパティは存在しません。
✅ 正解{typeAttributes.id}JS側の typeAttributes: { id: ... } で定義した値は、すべてこのオブジェクト経由で取得します。

ですので、カスタムデータテーブル用のテンプレート(recordLinkTpl.html)は必ず以下のように記述してください。

<template>
    <c-record-link 
        record-id={typeAttributes.id} 
        object-api-name={typeAttributes.objectApiName} 
        label={typeAttributes.label}>
    </c-record-link>
</template>

「公式通りに書いたのに動かない!」という時は、この value vs typeAttributes の違いを疑ってみてください。

typeAttributes はフレームワークの「予約語」である

なお、typeAttributes というキーワードは私が適当に付けた変数名ではありません。lightning-datatable の仕様で決められた予約語(Reserved Word)です。

  • JS側: 列定義をする際、属性を渡すキー名は必ず typeAttributes でなければなりません。
  • HTML側: カスタムテンプレート内で値を受け取る際も、必ず typeAttributes.xxx と記述する必要があります。

ここを attributesprops など別の名前に変えてしまうと、値が渡らなくなるので注意してください。

まとめ

LWRサイトでの開発は、「標準コンポーネントがないなら作ればいい」というDIY精神が求められますが、その分、LightningDatatable のような強力なベースコンポーネントが用意されています。

今回紹介した Custom Types(カスタムデータ型) のテクニックを使えば、リンクだけでなく、進捗バーや画像表示、アクションボタンなど、あらゆるUIをテーブル内に組み込むことが可能です。

ぜひ、皆さんのプロジェクトでも試してみてください。

参考URL

Create Custom Record Components

lightning-datatable | Lightning Component Reference

DXforceの管理人

福島 瑛二

2013年にJavaエンジニアとしてのキャリアをスタート。2019年にSalesforceと出会い、Salesforceエンジニアの道へ。

デザインや UI/UX の観点からもシステムを捉え、ユーザーにとって心地よい体験を実装することにやりがいを感じています。

CRM(顧客データ)や Data Cloud と連携した高度なサイトを目に見える形で表現できる Experience Cloud に大きな可能性を見出しており、バックエンドのデータ構造とフロントエンドの表現力を極めることがこれからの Salesforce エンジニアに求められるスキルだと確信しています。

Trailblazer: efukushima

福島 瑛二をフォローする

読者の声

タイトルとURLをコピーしました