0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Apex LWC 上級レッスン①

Last updated at Posted at 2025-11-17

Apex LWC 上級レッスン①

中級(LWC側のUI・イベント制御)で“フロントの土台”を作っているので、これにApexで裏側のロジック・データ管理・非同期処理と結びつける構成にする予定です。
そして今回は、改修の流れを掴む為の記述にしたいと思ってます。
なので作成して改修、作成して改修を繰り返すような内容にする予定です。

中級までの記述

中級編までの記述内容を下記に示す。
上級ではこれにApexの記述を追加します。なのでまずは修正前の状態を確認します。

中級ファイル構成(ディレクトリ構造)
/force-app
 └─ /main
     └─ /default
         └─ /lwc
              ├─ todo_todoApp
              │    ├─ todo_todoApp.html
              │    ├─ todo_todoApp.js
              │    ├─ todo_todoApp.js-meta.xml
              └─ todo_todoItem
                   ├─ todo_todoItem.html
                   ├─ todo_todoItem.js
                   ├─ todo_todoItem.js-meta.xml

コンポーネント名 役割
todoApp - 入力フォーム、追加ボタン
- Todoリストの管理(配列保持)
- 子コンポーネント呼び出し
- 条件分岐によるリスト表示/非表示
- 子からのイベント受け取り(削除・編集)
todoItem - 個別Todoの表示
- チェックボックスで完了/未完了切替
- 削除・編集ボタン押下で親にイベント発火

💡中級までの記述のポイント

画面側の記述(元々の中級の記述)を全て手入力で入力してから
今回のApexの記述をしていく形になる

画面側で中級編ではしっかり記述が無かった所の説明をしていく

・まずこれは「今あるリストを取得して表示」ではなく、「ユーザーが画面上でどんどん追加してリストを作っていく」仕組み。“クライアント側の動的リスト作成アプリ”です。なのでApexの記述無しでも動くよ!って感じです。
 
・親コンポーネントの分岐処理部分 < template if:true={hasTodos} >
この記述での参照先のhasTodosの定義が下記内容でゲッターで記述している。これはtodoListの中身によって表示非表示が変わる為、単純な定義ではリアルタイムで変更されていく値を取得できないので、このような定義をしています。

js側でのhasTodosの定義部分
get hasTodos() {
    return this.todoList.length > 0;
}

 
・画面側での子呼び出し前のリスト表示用の記述

HTML側でのfor文
< template for:each={todoList} for:item="todo" for:index="i" >
属性 必須 意味
for:each 必須 繰り返す元になる配列を指定(例:todoList
for:item 必須 各ループで取り出す1件分のデータに付ける名前(例:todo
for:index 任意 各要素が何番目か(インデックス番号)受け取る変数名(例:i

中級 コード内容

todoAppのHTML
<template>
    <!-- 新しい名前入力を受けとる -->
    <lightning-input 
      type="text"
      label="TODO入力"
      value={newName}
      onchange={handleChange}
    ></lightning-input>

    <!-- この追加ボタン押下したらリストに上で入力した名前を新規登録する -->
    <lightning-button 
      variant="base"
      label="追加" 
      onclick={handleClick} 
    ></lightning-button>

    <!-- 条件分岐でtodoListに入力があれば表示する処理 -->
    <template if:true={hasTodos}>
        <template for:each={todoList} for:item="todo" for:index="i">
            <!-- 子コンポーネントの呼び出し -->
            <!-- 表示は子側で制御するので 親 → 子 にデータを「渡す」 -->
            <c-todo-item
                 key={todo.id}
                 index={i}
                 todo={todo}
                 ondeleteitem={handleDelete}
                 onedititem={handleEdit}>
            </c-todo-item>
        </template>
    </template>
    <template if:false={hasTodos}>
        <p>
            TODOがありません
        </p>
    </template>
</template>
todoAppのjs
import { LightningElement } from 'lwc';

export default class Todo_todoApp extends LightningElement {
    newName = '';
    todoList = [];
    // 作成されたデータにid付与する為の記述
    nextId = 1;
    
    // 動的にtodoListの中身を取得する為、ゲッターを使用
    get hasTodos(){
        return this.todoList.length > 0;
    }

    // 入力された内容を取得して格納
    handleChange(event){
        this.newName = event.detail.value;
    }

    // 登録ボタン押下時の処理 ボタン押したことが分かればいいのでeventで取得していない
    handleClick(){
        if(this.newName){

            // 今のリストに追加する処理
            this.todoList = [...this.todoList,{ id: this.nextId, Name: this.newName }];

            // 次追加される時の為に、idをプライチにして入力箇所を白紙にする
            this.nextId++;
            this.newName = '';
        }
    }

    handleDelete(event){
        const deleteId = event.detail;
        this.todoList = this.todoList.filter(deleteId !== this.todoList.id);
    }
    handleEdit(event){
        const editId = event.detail;
        // ここからの編集イベントの中身を記述する必要がある
    }
}
todoItemのHTML
<template>
    <p>
        <!-- 1. 買い物 2. 掃除 こんな感じで表示させたいのでこの記述 -->
        { index + 1 }.{ todo.name }
        <lightning-button 
          variant="base"
          label="削除" 
          onclick={deleteTodo} 
        ></lightning-button>
        <lightning-button 
          variant="base"
          label="編集" 
          onclick={editTodo} 
        ></lightning-button>
    </p>
</template>
todoItemのjs
import { LightningElement, api } from 'lwc';

export default class Todo_todoItem extends LightningElement {
    @api todo;
    @api index;

    // 削除ボタンを押下時のtodoのidを取得する記述
    deleteTodo(){
        this.dispatchEvent(new CustomEvent( 'deleteitem',{
            detail : this.todo.id
        }));
    }

    // 編集ボタンを押下時のtodoのidを取得する記述
    editTodo(){
        this.dispatchEvent(new CustomEvent( 'edititem', {
            detail : this.todo.id
        }));
    }
}

上級編 改修⓵

上記の中級編の内容にApexの記述等、機能追加します。

上級編での追加機能

  1. DB連携
    ・ 追加・削除・編集が Salesforce の Todo__c に反映される
    getTodos() で初期取得
     
  2. 編集モードの追加
    ・子コンポーネント内で編集中フラグ管理
    ・ 編集内容を親に通知して Apex 更新
     
  3. 完了状態変更機能の追加
    ・ チェックボックスで完了/未完了を更新
    ・ 親に通知 → Apex 更新
     
  4. 非同期処理
    ・ Apex 内の Queueable で完了Todo集計

上級ファイル構成(ディレクトリ構造)

/force-app
 └─ /main
     └─ /default
         ├─ /classes
         │    └─ TodoController.cls
         │    └─ TodoController.cls-meta.xml
         └─ /lwc
              ├─ todo_todoApp
              │    ├─ todo_todoApp.html
              │    ├─ todo_todoApp.js
              │    ├─ todo_todoApp.js-meta.xml
              └─ todo_todoItem
                   ├─ todo_todoItem.html
                   ├─ todo_todoItem.js
                   ├─ todo_todoItem.js-meta.xml


下記内容がApexの記述を追加しデータをDBへ登録、編集、削除を行う記述を追加したもの

Apex クラス(TodoController.cls)

public with sharing class TodoController {

    // 最新情報のtodoを一覧表示する為のデータ取得
    @AuraEnabled
    public static List<Todo__c> getTodos(){
        return [
            SELECT Id, Name, Done__c
            FROM Todo__c
            ORDER BY CreatedDate ASC
        ];
    }

    // 入力した名前を受け取って登録
    @AuraEnabled
    public static Todo__c insertTodo( String name ){
        // todoオブジェクトの変数に引数の内容を整形して格納
        Todo__c todo = new Todo__c( Name = name , Done__c = false );
        try {
            insert todo;
            return todo;
        } catch (DmlException e) {
            throw new AuraHandledException( 'todoの追加登録に失敗しました:' + e.getMessage());
        }
    }

    // 削除処理
    @AuraEnabled
    public static Todo__c deleteTodo( Id todoId ){
        // Apexでは=だけ(===みたいな記述はしない)
        // コロン : を使うことで Apex の変数 todoId の値を SOQL 内に埋め込める
        Todo__c todo = [SELECT Id FROM Todo__c WHERE Id = :todoId LIMIT 1];
        try {
            delete todo;
        } catch (DmlException e) {
            throw new AuraHandledException( 'todoの削除に失敗しました:' + e.getMessage());
        }
    }

    // 更新処理
    @AuraEnabled
    // ここでの引数は値を受け取る為の一時的な変数宣言でこれはApex内でしか使えない( Id todoId , String name , Boolean done )
    public static Todo__c updateTodo( Id todoId , String name , Boolean done ){
        // 受け取った引数のデータをDBから1件だけ取得する
        Todo__c todo = [SELECT Id, Name, Done__c FROM Todo__c WHERE Id = :todoId LIMIT 1];
        // 取得した内容に書き換える その後のtryでDBを更新する
        todo.Name = name;
        todo.Done__c = done;
        try {
            update todo;
            return todo;
        } catch (DmlException e) {
            throw new AuraHandledException( 'todoの更新に失敗しました:' + e.getMessage());
        }
    }

    // この非同期処理を行う理由としては、もし何万件もあると負荷がかかるから
    // batchCompletedTodos()でCompletedTodoBatch()を予約して、実際の処理内容はCompletedTodoBatch()の中に記述
    @AuraEnabled
    public static void batchCompletedTodos(){
        System.enqueueJob(new CompletedTodoBatch());
    }

    // 件数格納の静的変数定義
    public static Integer completedCount = 0;

    // Queueableバッチクラスの記述
    public class CompletedTodoBatch implements Queueable {
        public void execute(QueueableContext context){
            List<Todo__c> competedTodos = [SELECT Id FROM Todo__c WHERE Done__c = true];
            TodoController.completedCount = competedTodos.size();
            System.debug('完了済みTodo件数: ' + competedTodos.size());
        }
    }

    // jsで件数表示に使用する為の記述
    @AuraEnabled(cacheable=false)
    public static Integer getCompletedCount(){
        return completedCount;
    }
}

親コンポーネントHTML(todoApp.html)

<template>
    <!-- 新しい名前入力を受けとる -->
    <lightning-input 
      type="text"
      label="TODO入力"
      value={newName}
      onchange={handleChange}
    ></lightning-input>

    <!-- この追加ボタン押下したらリストに上で入力した名前を新規登録する -->
    <lightning-button 
      variant="base"
      label="追加" 
      onclick={handleClick} 
    ></lightning-button>

    <!-- 条件分岐でtodoListに入力があれば表示する処理 -->
    <template if:true={hasTodos}>
        <template for:each={todoList} for:item="todo" for:index="i">
            <!-- 子コンポーネントの呼び出し -->
            <!-- 表示は子側で制御するので 親 → 子 にデータを「渡す」 -->
            <c-todo-item
                 key={todo.id}
                 index={i}
                 todo={todo}
                 ondeleteitem={handleDelete}
                 onedititem={handleEdit}>
            </c-todo-item>
        </template>
    </template>
    <template if:false={hasTodos}>
        <p>
            TODOがありません
        </p>
    </template>

    <!-- 完了件数の集計の表示 -->
    <template>
        <lightning-button 
          variant="base"
          label="完了済み件数の集計" 
          onclick={handleCountBatch} 
        ></lightning-button>

        <template if:true={completedCount}>
            <p>完了済みTODO件数:{completedCount}件</p>
        </template>

        <template if:false={completedCount}>
            <p>完了件数無し</p>
        </template>

        <template if:true={completederrorMessage}>
        <p style="color:red;">{completederrorMessage}</p>
        </template>
    </template>
</template>

親コンポーネント(todoApp.js)

import { LightningElement } from 'lwc';
import getTodos from '@salesforce/apex/TodoController.getTodos';
import insertTodo from '@salesforce/apex/TodoController.insertTodo';
import deleteTodo from '@salesforce/apex/TodoController.deleteTodo';
import updateTodo from '@salesforce/apex/TodoController.updateTodo';
import batchCompletedTodos from '@salesforce/apex/TodoController.batchCompletedTodos';
import getCompletedCount from '@salesforce/apex/TodoController.getCompletedCount';


export default class Todo_todoApp extends LightningElement {
    newName = '';
    todoList = [];
    // 作成されたデータにid付与する為の記述
    nextId = 1;
    
    // 動的にtodoListの中身を取得する為、ゲッターを使用
    get hasTodos(){
        return this.todoList.length > 0;
    }

    // 入力された内容を取得して格納
    handleChange(event){
        this.newName = event.detail.value;
    }

    // 登録ボタン押下時の処理 ボタン押したことが分かればいいのでeventで取得していない
    handleClick(){
        // 名前の入力があるか確認する処理 なければ処理の中断
        if( !this.newName ) return;
        // ApexでのDML処理、画面側での追加処理の記述
        insertTodo({ name : this.newName })
            .then( result => {
            // 今のリストに追加する処理
            this.todoList = [...this.todoList,{ id: result.id, name: result.Name, done: result.Done__c }];
            // 次追加される時の為に入力箇所を白紙にする Apex登録でidが生成されるので名前を空白にするだけ
            this.newName = '';
            })
            .catch( error => {
                console.log('todo追加エラー' + error);
            });
    }

    // 削除処理
    handleDelete(event){
        // 削除対象のIDを取得してApex側で削除処理
        const deleteId = event.detail;
        deleteTodo({ Id: deleteId })
            .then( () => {
                this.todoList = this.todoList.filter(todo => deleteId !== todo.id );
            })
            .catch( error => {
                console.log('todo削除エラー' + error);
            });
    }

    // 更新処理
    handleEdit(event){
        const editData = event.detail;
        // ApexのDML処理に値を渡して処理を流す、その結果を画面にも反映させる必要がある
        updateTodo({ todoId: editData.id , name: editData.name , done: editData.done })
            .then( result => {
                this.todoList = this.todoList.map( todo => 
                    result.id === todo.id 
                        ? { Id: result.Id , name: result.Name , done: result.Done__c }
                        : todo
                );
            })
            .catch( error => {
                console.log('todo変更エラー' + error);
            });
    }

    // connectedCallback関数はDOMが読み込まれた時に自動実行される関数なのでloadTodos()を呼出す
    connectedCallback(){
        this.loadTodos()
    }

    // 最新情報のtodoを画面にリスト表示
    loadTodos(){
        getTodos()
            .then( result => {
                const list = result;
                // Apexからのデータを整形する記述
                this.todoList = list.map( todo => ({
                    id: todo.id,
                    name: todo.name,
                    done: todo.Done__c // ← ここで JS 側のプロパティ名を done に初定義してる
                }));
            })
            .catch( error => {
                console.log('todo取得エラー:' + error);
            });
    }

    // カウントなので数値が入るが、エラーを拾う為にnullで初期値を設定している
    completedCount = null;
    // エラー出力文を格納予定の変数
    completederrorMessage = '';

    // 集計ボタン押下時の処理を記述
    handleCountBatch(){
        // 非同期処理を開始する
        batchCompletedTodos()
            .then( () => {
                // 非同期処理が完了したら件数取得処理を呼出して数値を更新 クラス内で自分のメソッドを呼び出すためthisが必要です
                this.loadCompletedCount()
            })
            .catch( error => {
                this.completederrorMessage = 'エラーが発生しました:' + error.body.message;
            });
    }

    // 件数の取得処理
    loadCompletedCount(){
        getCompletedCount()
            .then( result => {
                // if文でnullかどうかの確認を行う nullでは無いなら件数の更新を行う処理を流す
                if( result === null ){
                    this.completedCount = null;
                } else {
                    this.completedCount = result;
                }
            })
            .catch( error => {
                this.completederrorMessage = '件数取得結果エラー:' + error.body.message;
            });
    }
}

ここまでの親コンポーネント記述ポイントまとめ

map()関数について
配列の要素をひとつずつ処理して、「新しい配列」を作り出す高階関数。

// Apexが返すレコード(例)
{ Id: 'a01...', Name: '買い物', Done__c: true }
// LWCで使いやすい形に変換する
{ id: 'a01...', name: '買い物', done: true }

__c や _ があるとテンプレートや変数名の規約から外れるので変更するのが推奨される。
ただ、あくまでmap()は変換をしており、すでに取ってきたデータを整形しているだけです。最新情報を取得しているのはgetTodos() の呼び出し部分で取得しています。

forEachとの違い 【map()関数の補足】
forEachとmapはよく混合されるが、用途が違う。
forEachはあくまで処理を行うだけなので、返り値が無いのと、上記のように配列を整形することはできない。もしやるなら、違うリストを用意してそのリストに格納するようにしたら一応できる。って感じ。

connectedCallback関数はDOMが読み込まれた時に自動実行される関数
コールバック関数の一種だが画面の最初に読み込まれる=コールバック関数ではない
画面の最初に読み込まれる=connectedCallback関数である という考え方でOK
ただ正確にはコンポーネントが DOM に接続されたタイミングで呼ばれるのがconnectedCallback関数であり初回ロードだけでなく、コンポーネントが再度 DOM に接続されたときも呼ばれます。
なので常に最新情報を表示し続けられるってこと。


子コンポーネントHTML(todoItem.html)

<template>
    <p>
        <!-- 編集モードを追加して編集している時は他の表示がされないようにする -->
        { index + 1 }.
        <!-- 編集モードの際の編集画面の記述 -->
        <template if:true={isEditing}>
            <lightning-input 
              type="text"
              value={editName}
              onchange={handleEditChange}>
            </lightning-input>
            <lightning-button 
              variant="base"
              label="保存" 
              onclick={saveEdit}>
            </lightning-button>
        </template>

        <!-- 編集モードではない時のリスト表示の画面の記述 -->
        <template if:false={isEditing}>
            <lightning-input 
              type="checkbox"
              checked={todo.done}
              onchange={toggleDone}
            ></lightning-input>
            {todo.name}
            <lightning-button 
              variant="base"
              label="編集" 
              onclick={editTodo} 
            ></lightning-button>
            <lightning-button 
              variant="base"
              label="削除" 
              onclick={deleteTodo} 
            ></lightning-button>
        </template>
    </p>
</template>

子コンポーネントJS(todoItem.js)

import { LightningElement, api } from 'lwc';

export default class Todo_todoItem extends LightningElement {
    @api todo;
    @api index;

    // 削除ボタンを押下時のtodoのidを取得する記述 親に送る
    deleteTodo(){
        this.dispatchEvent(new CustomEvent( 'deleteitem',{
            detail : this.todo.Id
        }));
    }

    // 編集モードかどうかを決めるフラグの定義 trueが編集モード
    isEditing = false;
    editName = '';

    // 編集モードの時のフラグを変更と、表示する名前をtodoのnameから取得して初期値をセット
    editTodo(){
        this.isEditing = true;
        this.editName = this.todo.name;
    }

    // 編集した内容を格納
    handleEditChange(event){
        this.editName = event.detail.value;
    }

    // 編集して保存ボタン押下の際に親にデータを送信する 送信したら編集モードのフラグを戻す
    saveEdit(){
        this.dispatchEvent( new CustomEvent( 'edititem' ,{
            detail : { id:this.todo.id , name:this.editName , done:this.todo.done }
        }));
        this.isEditing = false;
    }

    // 完了フラグの確認をする処理で、変更があれば内容を親に送信 saveEdit関数と同じカスタム名で
    toggleDone(event){
        const done = event.target.checked;
        this.dispatchEvent( new CustomEvent( 'edititem' ,{
            detail : { id : this.todo.id , name : this.todo.name, done }
        }));
    }
}

🧩子コンポーネントをもし改善、強化する場合の機能候補🧩


🧩 1. CustomEvent のバブル設定を検討(任意)

現在のように子→親の1階層構造なら不要ですが、もし今後中間コンポーネントが入る構成にする場合

this.dispatchEvent(new CustomEvent('edititem', {
    detail: { id: this.todo.id, name: this.todo.name, done },
    bubbles: true,
    composed: true
}));

とすることで、上位階層(祖先コンポーネント)にも届くようにできます。
現時点では 不要 ですが、拡張性を考えるなら検討の余地ありです。
後に記述を行います。


🧩 2. 編集保存時のバリデーション(任意)
saveEdit() 内で、例えば空文字を送らないようにチェックを入れておくと安全です:

saveEdit() {
    if (!this.editName.trim()) {
        return;
    }
    this.dispatchEvent(new CustomEvent('edititem', {
        detail: { id: this.todo.id, name: this.editName, done: this.todo.done }
    }));
    this.isEditing = false;
}

UI 側で無効入力を防げるようになります。
ただ今回はバリデーションは実装する予定は無し。


🧩 3. UI のアクセシビリティ(任意)
に label 属性を追加すると、ユーザー支援技術(スクリーンリーダーなど)への対応が強化されます。実際の案件等では適用させる必要が必要な場合あり。今回は不要。

<lightning-input 
    type="text"
    label="TODO編集"
    value={editName}
    onchange={handleEditChange}>
</lightning-input>

🧩 4. < p > タグの使い方(軽微)
1行単位でまとまっているのは良いですが、
< p > の代わりに < div class="todo-item" > のような要素にしておくとスタイル適用やレイアウト調整がしやすくなります。
見た目を変更する場合はこちらの方が良いが今回はそこまでは実装しないので不要。


 

💡上級編のQueueableの記述ポイント

💡 なぜ「件数を変数に保持するのか?」

public static Integer completedCount = 0;

これを定義している理由は──

Queueable クラス(非同期処理)は、実行がすぐ終わるわけではなく「後で実行される」ためです。

つまり

  • batchCompletedTodos() が呼ばれると、Queueableが予約されるだけ
  • その時点ではまだ結果(件数)がない
  • Queueable が後から実行されて初めて件数がわかる

そこで、件数を一時的に保持する場所が必要になります。
静的変数 completedCount がその“置き場所”です。


💡 Queueable クラスには @AuraEnabled を付けられない理由

Queueable クラス自体は @AuraEnabled にできません。

理由はシンプルです。@AuraEnabled は「LWCやAuraコンポーネントから呼べるようにするためのアノテーション」ですが、Queueableは直接呼ぶ対象ではないため、Apex内部専用のクラス扱いです。つまり

LWC → Apexメソッド(@AuraEnabled) → Queueableクラス

という順番で呼び出すのがルールです。
なので手順が一つ増えるが、一度メソッドに格納してからLWCで使えるようにする必要がある


💡 Queueable クラスは LWC から呼べない理由

Queueable クラスは「バッチジョブを定義するクラス」であり、呼び出し自体は Salesforce の システムジョブキュー が行います。

なので、LWCから直接呼び出すことはできません。代わりに

@AuraEnabled
public static void batchCompletedTodos() {
    System.enqueueJob(new CompletedTodoBatch());
}

のように、Queueableを登録するだけのメソッドを作り、
そのメソッドに @AuraEnabled を付けることでLWC側から呼び出せるようにします。


上級編に更に+αの記述 改修②

上級編+αの追加機能

1 . 親→子→孫 の3階層コンポーネント
 ・親はデータ処理、子は一覧制御、孫は単一TodoのUI担当
 ・イベント通信は @apiCustomEvent を活用

2 . ページネーション対応
 ・子コンポーネントで「次へ/前へ」ボタンを管理
 ・親はページ番号を受け取り、Apexから対象データのみ取得
 ・初期表示も1ページ目のみロードして負荷を軽減

3 . Future メソッド
 ・集計や通知処理を非同期で実行してUXを向上
 ・Queueable終了後にFutureでログ出力や通知送信
 ・ガバナ制限を回避しやすくなる利点も

4 . トランザクション制御
 ・Savepointで安全にDMLを保護し、失敗時にRollback
 ・例外は AuraHandledException でUIに通知可能
 ・更新・削除時の整合性を確保
 ・実務では標準的な例外設計パターン

5 . Queueable連携
 ・Queueableで完了Todoの集計を非同期実行
 ・完了後にFutureでログ出力やUI更新
 ・2段階非同期構成で実務的なパフォーマンス設計を再現
 ・実務でのバッチ・通知処理に近い構成


上級+αの全体構成

─────────────────────────────

① 親コンポーネント(todo_todoApp.html / todo_todoApp.js
② 子コンポーネント(todo_listView.html / todo_listView.js
③ 孫コンポーネント(todo_item.html / todo_item.js
④ Apexクラス(TodoController.cls

─────────────────────────────

上級+α 親の記述

todo_todoApp.html

<template>
    <!-- 新しい名前入力を受けとる -->
    <lightning-input 
      type="text"
      label="TODO入力"
      value={newName}
      onchange={handleChange}
    ></lightning-input>

    <!-- この追加ボタン押下したらリストに上で入力した名前を新規登録する -->
    <lightning-button 
      variant="base"
      label="追加" 
      onclick={handleClick} 
    ></lightning-button>

    <!-- 一覧表示部分 -->
    <!-- 削除と編集内容を受け取る -->
    <c-todo-list-view
      ondeleteitem={handleDelete}
      onedititem={handleEdit}>
    </c-todo-list-view>

    <!-- 完了件数の集計の表示 -->
    <template>
        <lightning-button 
          variant="base"
          label="完了済み件数の集計" 
          onclick={handleCountBatch} 
        ></lightning-button>

        <template if:true={completedCount}>
            <p>完了済みTODO件数:{completedCount}件</p>
        </template>

        <template if:false={completedCount}>
            <p>完了件数無し</p>
        </template>

        <template if:true={completederrorMessage}>
        <p style="color:red;">{completederrorMessage}</p>
        </template>
    </template>
</template>

todo_todoApp.js

import { LightningElement } from 'lwc';
import insertTodo from '@salesforce/apex/TodoController.insertTodo';
import deleteTodo from '@salesforce/apex/TodoController.deleteTodo';
import updateTodo from '@salesforce/apex/TodoController.updateTodo';
import batchCompletedTodos from '@salesforce/apex/TodoController.batchCompletedTodos';
import getCompletedCount from '@salesforce/apex/TodoController.getCompletedCount';


export default class Todo_todoApp extends LightningElement {
    newName = '';
    // todoList = []; ※子側で管理するので不要
    
    // 動的にtodoListの中身を取得する為、ゲッターを使用  ※子側で管理するので不要
    // get hasTodos(){
    //     return this.todoList.length > 0;
    // }

    // 入力された内容を取得して格納
    handleChange(event){
        this.newName = event.detail.value;
    }

    // 登録ボタン押下時の処理 ボタン押したことが分かればいいのでeventで取得していない
    handleClick(){
        // 名前の入力があるか確認する処理 なければ処理の中断
        if( !this.newName ) return;
        // ApexでのDML処理、画面側での追加処理の記述
        insertTodo({ name : this.newName })
            .then( result => {
                // 次追加される時の為に入力箇所を白紙にする Apex登録でidが生成されるので名前を空白にするだけ
                this.newName = '';
                const listView = this.template.querySelector('c-todo-list-view');
                if (listView){
                    // 子コンポーネントの loadPage() メソッドを呼び出して、一覧を最新の状態に再読み込み(再描画)させる
                    listView.loadPage();
                }
            })
            .catch( error => {
                console.log('todo追加エラー' + error);
            });
    }

    // 削除処理
    handleDelete(event){
        // 削除対象のIDを取得してApex側で削除処理
        const deleteId = event.detail;
        deleteTodo({ Id: deleteId })
            .then( () => {
                const listView = this.template.querySelector('c-todo-list-view');
                if(listView){
                    listView.loadPage();
                }
            })
            .catch( error => {
                console.log('todo削除エラー' + error);
            });
    }

    // 更新処理
    handleEdit(event){
        const editData = event.detail;
        // ApexのDML処理に値を渡して処理を流す、その結果を画面にも反映させる必要がある
        updateTodo({ todoId: editData.id , name: editData.name , done: editData.done })
            .then( result => {
                const listView = this.template.querySelector('c-todo-list-view');
                if(listView){
                    listView.loadPage();
                }
            })
            .catch( error => {
                console.log('todo変更エラー' + error);
            });
    }

    // ※子側で管理するので不要
    // connectedCallback() {   
    //     this.loadTodos();  
    // }
    // loadTodos() {
    //     getTodos({ pageNumber: 1, pageSize: 5 })
    //         .then(result => {
    //             this.todoList = result;
    //         })
    //         .catch(error => {
    //             console.log('todo取得エラー:' + error);
    //         });
    // }

    // カウントなので数値が入るが、エラーを拾う為にnullで初期値を設定している
    completedCount = null;
    // エラー出力文を格納予定の変数
    completederrorMessage = '';

    // 集計ボタン押下時の処理を記述
    handleCountBatch(){
        // 非同期処理を開始する
        batchCompletedTodos()
            .then( () => {
                // 非同期処理が完了したら件数取得処理を呼出して数値を更新 クラス内で自分のメソッドを呼び出すためthisが必要です
                this.loadCompletedCount()
            })
            .catch( error => {
                this.completederrorMessage = 'エラーが発生しました:' + error.body.message;
            });
    }

    // 件数の取得処理
    loadCompletedCount(){
        getCompletedCount()
            .then( result => {
                // nullかどうかの確認を行う nullでは無いなら件数の更新を行う処理を流す
                this.completedCount = result ?? null;
            })
            .catch( error => {
                this.completederrorMessage = '件数取得結果エラー:' + error.body.message;
            });
    }
}

 

🧩上級+α内 親js記述ポイント

🧩this.template.querySelector('c-todo-list-view') とは?

  • this は現在の親コンポーネントのインスタンス(JavaScript クラス)を指します。
  • template は、そのコンポーネントの HTMLのテンプレート領域(実際に描画されているDOMツリー)を指します。
  • querySelector('c-todo-list-view') は、テンプレート内で最初に見つかった <c-todo-list-view> というタグ(=子コンポーネント)を取得します。
     
const listView = this.template.querySelector('c-todo-list-view');

つまり上記記述は「親コンポーネントの画面の中にある、最初の <c-todo-list-view> 子コンポーネントのDOMノード(コンポーネントインスタンス)を取得」
という意味です。


🧩 listView.loadPage(); について

const listView = this.template.querySelector('c-todo-list-view');
if (listView) {
    listView.loadPage(); // ★ 子コンポーネントのpublicメソッドを呼ぶ
}

listViewには子コンポーネントのインスタントが入っているので、つまりtodo_listView.jsの中のメソッドを参照してloadPageメソッドを呼ぶ記述をしています。
 
今回、子コンポーネント todo_listView.js@apiを付けたloadPage()が用意されています。

@api
loadPage() {
  // Apex呼び出してページのTODO一覧を再取得して描画する処理
}

@api デコレーターが付いているメソッドは、親コンポーネントから外部公開(public)されたメソッドという意味です。
つまり親コンポーネントは子コンポーネントのこのメソッドを呼び出すことができます。

したがって、「子コンポーネントの loadPage() メソッドを呼び出して、一覧を最新の状態に再読み込み(再描画)させる」という処理です。

ちなみにif (listView) がは無くてもいいですが、保険として記述しているのが主な理由です。


🧩 上級まであったfilter()の削除理由
上級までは下記記述でtodoListの表示を制御していましたが、上級+αでは廃止しています。

.then( () => {
    this.todoList = this.todoList.filter(todo => deleteId !== todo.id );
})

上級+αではApexで取得している処理を毎回呼び出して更新するようにしているので、filterでの制御は不要となり、廃止しています。


🧩 this.completedCount = result ?? nullの記述について

元のコード
.then(result => {
    // if文でnullかどうかの確認を行う
    if (result === null) {
        this.completedCount = null;
    } else {
        this.completedCount = result;
    }
});

やっていることは単純で、

「もし result が null なら null、そうでなければ result を代入」

という処理です。つまり条件によって代入する値が変わるだけ。

変更後のコード
.then(result => {
    this.completedCount = result ?? null;
});

やっていることは全く同じ意味で、違うのは??Null合体演算子)を使っていること
 
Null合体演算子(??)とは?
JavaScriptの比較的新しい構文

A が null または undefined なら、B を使う。それ以外なら A を使う記述
A ?? B

result ?? null は「nullチェック専用の1行書き換え」。
if文と同じ動作だけど、よりモダンで可読性が高い書き方です。


 

上級+α 子の記述

todo_listView.html

<template>
  <template if:true={pagedTodos.length}>
    <!-- リストを一覧表示 -->
    <template for:each={pagedTodos} for:item="todo" for:index="i">
      <!-- 子コンポーネントの呼び出し -->
      <!-- 表示は子側で制御するので 親 → 子 にデータを「渡す」 -->
      <c-todo-item
        key={todo.id}
        index={i}
        todo={todo}
        ondeleteitem={handleDelete}
        onedititem={handleEdit}>
      </c-todo-item>
    </template> 
  </template>

  <template if:false={pagedTodos.length}>
    <p>TODOがありません</p>
  </template>

  <!-- ページ移動ボタン -->
  <div class="pagination">
    <lightning-button 
      variant="base"
      label="前へ" 
      onclick={handlePrev} 
      disabled={isFirstPage}
    ></lightning-button>
    <span>ページ{pageNumber}/{totalPages}</span>
    <lightning-button 
      variant="base"
      label="次へ" 
      onclick={handleNext} 
      disabled={isLastPage}
    ></lightning-button>
  </div>
</template>

todo_listView.js

import { LightningElement , api} from 'lwc';
import getTodos from '@salesforce/apex/TodoController.getTodos';

export default class Todo_listView extends LightningElement {

    // 現在のぺージ数の初期値 
    pageNumber = 1;
    // 1ページに表示させるデータ数 初期値
    pageSize = 5;
    // Apexからの表示データ格納変数
    pagedTodos = [];
    // Apexからの件数格納変数
    totalRecords = 0;
    // 必要なページ数を格納する変数 初期値
    totalPages = 1;

    // ページ移動ボタンのdisabledの設定
    // 現在のページが最初のページかどうかの確認し最初のページなら前へのボタンを押せないようfalseを返す
    get isFirstPage(){
        return this.pageNumber === 1;
    }

    // 現在ページが最終ページかどうかの確認をしてfalseかtrueで返す
    get isLastPage(){
        return this.pageNumber === this.totalPages;
    }

    // 画面初期表示の処理 一覧表示の関数を実施
    connectedCallback(){
        this.loadPage();
    }

    // 一覧表示するデータ取得
    @api
    loadPage(){
        // データ取得Apex呼び出しでデータ取得
        getTodos({ pageNumber: this.pageNumber, pageSize: this.pageSize })
            .then( result => {
                this.pagedTodos = result.records;
                this.totalRecords = result.totalCount;
                this.totalPages = Math.ceil(this.totalRecords / this.pageSize);
            })
            .catch( error => {
                console.log('ページ取得エラーです:' , error)
            });
    }

    // 前のページ移動の処理
    handlePrev(){
        // 2ページ目以降かどうか確認
        if(this.pageNumber > 1){
            //デクリメント演算子でページ数を一つ減らしてApex呼び出ししてデータ取得
            this.pageNumber--;
            this.loadPage();
        }
    }

    // 次のページ移動の処理
    handleNext(){
        // 現在のページが最終ページかどうか確認
        if(this.totalPages > this.pageNumber){
            //デクリメント演算子でページ数を一つ増やしてApex呼び出ししてデータ取得
            this.pageNumber++;
            this.loadPage();
        }
    }

    // ListViewでの削除Idを取得して親コンポーネントに送る処理
    handleDelete(event){
        this.dispatchEvent(new CustomEvent ('deleteitem' , {
            detail : event.detail
        }));
    }

    // ListView編集内容のID,Name,doneを親コンポーネントに送る処理
    handleEdit(event){
        this.dispatchEvent(new CustomEvent ('edititem', {
            detail : event.detail
        }));
    }
}

 

🧩上級+α内 子js記述ポイント

🧩 this.totalPages = Math.ceil(this.totalRecords / this.pageSize);
これはページネーション(ページ分割)を実装する為に必要な数値を出す計算です。

this.totalPages = Math.ceil(this.totalRecords / this.pageSize);
  • this.totalRecords:データの総件数(例:全部で100件)
  • this.pageSize:1ページに表示する件数(例:1ページに10件表示)
  • Math.ceil():小数点以下を切り上げる関数

この記述は総件数 ÷ 1ページあたりの件数 を計算して小数点以下切り上げで丸めて
「全部で何ページになるか?」を計算しています。
つまり、何ページ必要かを計算している記述になります。

具体例
総件数が100件で、1ページに10件表示なら
 100 ÷ 10 = 10 → totalPages = 10


上級+α 孫の記述

todo_item.html / todo_item.js

<template>
    <p>
        <!-- 編集モードを追加して編集している時は他の表示がされないようにする -->
        { index + 1 }.
        <!-- 編集モードの際の編集画面の記述 -->
        <template if:true={isEditing}>
            <lightning-input 
              type="text"
              value={editName}
              onchange={handleEditChange}>
            </lightning-input>
            <lightning-button 
              variant="base"
              label="保存" 
              onclick={saveEdit}>
            </lightning-button>
        </template>

        <!-- 編集モードではない時のリスト表示の画面の記述 -->
        <template if:false={isEditing}>
            <lightning-input 
              type="checkbox"
              checked={todo.done}
              onchange={toggleDone}
            ></lightning-input>
            {todo.name}
            <lightning-button 
              variant="base"
              label="編集" 
              onclick={editTodo} 
            ></lightning-button>
            <lightning-button 
              variant="base"
              label="削除" 
              onclick={deleteTodo} 
            ></lightning-button>
        </template>
    </p>
</template>
import { LightningElement, api } from 'lwc';

export default class Todo_todoItem extends LightningElement {
    @api todo;
    @api index;

    // 削除ボタンを押下時のtodoのidを取得する記述 親に送る
    deleteTodo(){
        this.dispatchEvent(new CustomEvent( 'deleteitem',{
            detail : this.todo.Id
        }));
    }

    // 編集モードかどうかを決めるフラグの定義 trueが編集モード
    isEditing = false;
    editName = '';

    // 編集モードの時のフラグを変更と、表示する名前をtodoのnameから取得して初期値をセット
    editTodo(){
        this.isEditing = true;
        this.editName = this.todo.name;
    }

    // 編集した内容を格納
    handleEditChange(event){
        this.editName = event.detail.value;
    }

    // 編集して保存ボタン押下の際に親にデータを送信する 送信したら編集モードのフラグを戻す
    saveEdit(){
        this.dispatchEvent( new CustomEvent( 'edititem' ,{
            detail : { id:this.todo.id , name:this.editName , done:this.todo.done }
        }));
        this.isEditing = false;
    }

    // 完了フラグの確認をする処理で、変更があれば内容を親に送信 saveEdit関数と同じカスタム名で
    toggleDone(event){
        const done = event.target.checked;
        this.dispatchEvent( new CustomEvent( 'edititem' ,{
            detail : { id : this.todo.id , name : this.todo.name, done }
        }));
    }
}

上級+α Apex記述

TodoController.cls

public with sharing class TodoController {

    // 最新情報のtodoを一覧表示する為のデータ取得
    // ただページネーション機能を付与するので表示するレコードデータとレコード件数の二つが必要になる。なのでMapでの記述となる。
    // ページネーションの為に表示レコードとページ数の為の引数が必要となる
    @AuraEnabled(cacheable=false)
    public static Map<String,Object> getTodos( Integer pageNumber, Integer pageSize ){
        // 取得するデータの開始位置を決める為の記述
        Integer offsetRows = ( pageNumber - 1 ) * pageSize;
        // 表示するレコードデータを取得 offsetRowsでページ数に応じたデータを取得する
        List<Todo__c> record = [
            SELECT Id, Name, Done__c
            FROM Todo__c
            ORDER BY CreatedDate ASC
            LIMIT :pageSize OFFSET :offsetRows
        ];
        // Todo__cの全件数を取得する記述
        Integer totalCount = [SELECT COUNT() FROM Todo__c];
        // 取得した値をキーと共にMapに格納して返り値として設定
        return new Map<String,Object>{
            'record' => records ,
            'totalCount' => totalCount
        };
    }

    // 入力した名前を受け取って登録
    @AuraEnabled
    public static Todo__c insertTodo( String name ){
        // todoオブジェクトの変数に引数の内容を整形して格納
        Todo__c todo = new Todo__c( Name = name , Done__c = false );
        try {
            insert todo;
            return todo;
        } catch (DmlException e) {
            throw new AuraHandledException( 'todoの追加登録に失敗しました:' + e.getMessage());
        }
    }

    // 削除処理
    @AuraEnabled
    public static void deleteTodo( Id todoId ){
        // Apexでは=だけ(===みたいな記述はしない)
        // コロン : を使うことで Apex の変数 todoId の値を SOQL 内に埋め込める
        Todo__c todo = [SELECT Id FROM Todo__c WHERE Id = :todoId LIMIT 1];
        try {
            delete todo;
        } catch (DmlException e) {
            throw new AuraHandledException( 'todoの削除に失敗しました:' + e.getMessage());
        }
    }

    // 更新処理
    @AuraEnabled
    // ここでの引数は値を受け取る為の一時的な変数宣言でこれはApex内でしか使えない( Id todoId , String name , Boolean done )
    public static Todo__c updateTodo( Id todoId , String name , Boolean done ){
        // 受け取った引数のデータをDBから1件だけ取得する
        Todo__c todo = [SELECT Id, Name, Done__c FROM Todo__c WHERE Id = :todoId LIMIT 1];
        // 取得した内容に書き換える その後のtryでDBを更新する
        todo.Name = name;
        todo.Done__c = done;
        try {
            update todo;
            return todo;
        } catch (DmlException e) {
            throw new AuraHandledException( 'todoの更新に失敗しました:' + e.getMessage());
        }
    }

    // この非同期処理を行う理由としては、もし何万件もあると負荷がかかるから
    // batchCompletedTodos()でCompletedTodoBatch()を予約して、実際の処理内容はCompletedTodoBatch()の中に記述
    @AuraEnabled
    public static void batchCompletedTodos(){
        System.enqueueJob(new CompletedTodoBatch());
        sendBatchCompletedNotification();
    }

    // 非同期処理で完了メッセージを送信する
    @future
    public static void sendBatchCompletedNotification(){
        System.debug('非同期処理バッチ完了通知を送信しました');
    }

    // 件数格納の静的変数定義
    public static Integer completedCount = 0;

    // Queueableバッチクラスの記述
    public class CompletedTodoBatch implements Queueable {
        public void execute(QueueableContext context){
            Savepoint sp = Database.setSavepoint();
            try{
                List<Todo__c> competedTodos = [SELECT Id FROM Todo__c WHERE Done__c = true];
                TodoController.completedCount = competedTodos.size();
                System.debug('完了済みTodo件数: ' + competedTodos.size());
            }
            catch (Exception e){
                Database.rollback(sp);
                System.debug('バッチ処理失敗' + e.getMessage());
            }
        }
    }

    // jsで件数表示に使用する為の記述
    @AuraEnabled(cacheable=false)
    public static Integer getCompletedCount(){
        return completedCount;
    }
}

🧩上級+α Apex記述ポイント

🧩 ① Integer offsetRows = (pageNumber - 1) * pageSize;
1ページに表示する件数(pageSize)=5件
表示したいページ番号(pageNumber)=3ページ目 とすると、

offsetRows = (3 - 1) * 5 = 10

→ 「10行目からデータを取り始める」という意味になります。つまり、
1ページ目 → 0行目から
2ページ目 → 5行目から
3ページ目 → 10行目から
という風にページごとの開始位置を計算しているわけです。


🧩 ② LIMIT :pageSize OFFSET :offsetRows
これは SOQL(SalesforceのSQLのような言語) の構文です。
意味
LIMIT :pageSize → 「取得する件数を pageSize 件までに制限」
OFFSET :offsetRows → 「offsetRows 行目から取得を開始」
つまり、
「pageNumberページ目に表示する分だけデータを取り出す」
という動きを実現する構文です。


SELECT Id, Name FROM Todo__c LIMIT 5 OFFSET 10
→ Todo__c のレコードを
「10件目から5件分だけ取得する」という意味になります。


🧩 ③ COUNT() について

Integer totalCount = [SELECT COUNT() FROM Todo__c];
return new Map<String, Object>{
    'records' => records,
    'totalCount' => totalCount
};

🔹 COUNT() とは
COUNT() は 集計関数 の一つ。
レコードの中身は取らずに レコードの数だけ が結果として返ってくる。
件数だけを知りたいならこの書き方でOK
他に条件を付けることも可能(例:WHERE Done__c = true)

 
つまりSOQLで「件数だけを取得する」構文で、Todo__c の全件数を数えて変数 totalCount に入れています。


🧩 ④ Mapの初期化構文の省略記法

省略記法
return new Map<String, Object>{
    'records' => records,
    'totalCount' => totalCount
};

この記述はApex独自の構文で下記内容を省略して書ける記法です。
これで返り値を設定していて、{records=(中身のリスト), totalCount=5} こんなイメージ

比較用:省略しない記述の場合
Map<String, Object> resultMap = new Map<String, Object>();
resultMap.put('records', records);
resultMap.put('totalCount', totalCount);
return resultMap;

 
🔹returnで返り値を設定する
この返り値はページネーション機能の為、表示データとページ数を返す必要があり
Map のキー(左側 'records' や 'totalCount')は、「どのデータがどれか」を区別するためのラベル名なので、Stringです。
Map の値(右側 List型(例:List)や 整数型(Integer))となっており型が違うのでApexではいろんな型をまとめて扱いたいとき、万能の型である Object を使います。
なので

return new Map<String, Object> { 'キー1' => 1 , 'キー2' => 2 };

という書き方になります。

 
🔹 { }内の記述について
値にそれぞれ取得した件数とデータを格納している。

    'records' => records,
    'totalCount' => totalCount

今回の記述ではキーと値を同じにしてますが、異なる名称でも問題無いです。
書く順番も順不同で記述ではrecordsを先に書いてますが、後に書いても問題無いです。
今回はこの二つのみの格納ですが、Mapは何個でも登録できます。制限はほぼ無いです。
'pageNumber' => pageNumberのような記述を追加してページ番号を格納することも可能。
名前付きタンスのようなもので、引き出しにラベルを貼っていて自由に出し入れ出来ます。


🧩 ⑤getTodosがListでなくMapに変更されている理由
ページネーションを導入すると、返す情報が増えます

ページネーションでは、画面側で以下の情報が必要になります:

必要な情報 使い道
① 表示対象のレコード一覧 (records) 実際に画面に表示
② 全件数 (totalCount) ページ数を計算(例:全50件 → 10件/ページ → 5ページ)
つまり…
これら 2つの異なる型(ListとInteger) を
1つのメソッドでまとめて返したいんです。

そこで使うのが Map

Mapなら、**キー(文字列)と値(何でも入るObject型)**をペアで持てるため、
違う型のデータをまとめて返すことができます。


🧩 ⑥非同期処理について

種類 宣言方法 代表例 特徴
Queueable System.enqueueJob() CompletedTodoBatch 非同期バッチ実行。ジョブキューで順番に処理される
Future @future sendBatchCompletedNotification 非同期実行。別トランザクションで後から実行
Batch Apex Database.executeBatch() 大量データ処理用 複数トランザクションで実行される
Schedulable System.schedule() 定期実行 指定時刻で起動する

 
まず@future メソッド
  @futureはApexの非同期処理を実現するためのキーワード(アノテーション)
  このアノテーションを付けると別の実行コンテキストで処理されることを意味します
 
implements Queueableについて
  これは 非同期処理 を実行するためのクラス
  大量データをバックグラウンドで処理したいときに使う


🧩 ⑦トランザクション制御について

public void execute(QueueableContext context) {
    Savepoint sp = Database.setSavepoint();
    // ここからがトランザクション制御の記述となる
    try {
        List<Todo__c> completedTodos = [SELECT Id FROM Todo__c WHERE Done__c = true];
        TodoController.completedCount = completedTodos.size();
        System.debug('完了済みTodo件数: ' + completedTodos.size());
    } catch (Exception e) {
        Database.rollback(sp);
        System.debug('バッチ処理失敗: ' + e.getMessage());
    }
}

トランザクションとは、処理をまとめて成功か失敗か制御するような記述の事をいいます。
Apexではエラーが出ると全ての記述が巻き戻されるようになっているので、ここでエラーが出ると全て巻き戻ってしまいます。なのでトランザクション制御を行ってこの中で巻き戻しを留めるようにしています。

そこで、こうします:

1,トランザクションの途中で「セーブポイント」を設定
  Savepoint sp = Database.setSavepoint();

2,その後の処理でエラーが発生したら、Database.rollback(sp);で「セーブした地点まで戻す」ことができる
  Database.rollback(sp);

ただ、処理はこの中の処理は全て巻き戻るので、途中でエラーが出るとそれまでの処理は全てなかったことになります。今回の記述ではDatabase.rollback(sp);を記述していますが、この記述が無ければ途中までの処理を残すような処理内容にすることもできます。


 

基本的な記述の振り返り、解説

💡 @AuraEnabled(cacheable=false) の意味
   この記述は、Apexメソッドを Lightningコンポーネント(LWC / Aura)から
   呼び出す際のキャッシュ動作を制御する記述です。
   具体的には、「サーバー側から毎回最新データを取得する」ように指定します。
 

  • まずキャッシュとは
     キャッシュとは、一度取得したデータをクライアント側(ブラウザやLightning Data Service)に一時的に保存して再利用する仕組みです。つまり、「前回と同じデータを再利用する」かどうかを決めます。これにより、サーバーへのアクセス回数を減らし、画面の表示を速くすることができます。
属性
@AuraEnabled LWCから呼べるようにする
cacheable=true Todo一覧を最初に表示するだけの時に使用
cacheable=false Todoを追加・削除・更新したあとに、一覧を再取得する時に使用
  • 初期値について
     cacheableのデフォルト(初期値)はfalseなので、わざわざ書かなくても動作上は同じです。ただ、今後の改修やコードを読む人に向けて意図を明確にする為の記述でもある。

 
まとめ
件数が変化する可能性があるような記述は、キャッシュは不適切です。なので

@AuraEnabled(cacheable=false)

と明記して、常に最新の値を取るようにしているのを分かりやすくしています。
そしてcacheable=trueにすると、@wire でのみ使用可能です。
@wire で呼び出すメソッドには必ず cacheable=true が必要です。


💡 public static Integer getCompletedCount() の記述について

public static Integer getCompletedCount()メソッド宣言
分解すると

部分 意味
public どこからでもアクセス可能
static クラスインスタンスを作らずに呼び出せる
Integer 戻り値の型
getCompletedCount() メソッド名

このメソッドには @AuraEnabled が付いているので、
LWC側から import getCompletedCount from '@salesforce/apex/TodoController.getCompletedCount';
として直接呼び出しが可能になります。


 

上級はここまで

ここまでが練習用で作成したTodo管理内容になりますが、更に業務時の内容に合わせる、推奨される記述に変更する場合の内容を下記に記述していきます。具体的な内容としてはApexの内容をクラス分けしていき、単一責任による記述をする為の記述です。
ここからはApexのみの修正内容です。ただ練習としてはここまでで十分かと思います。
ここからはあくまで最適な記述をする為のものです。
 


番外編 改修③

番外編Apex記述の最適化

クラス構成とそれぞれの役割
TodoController.cls
 └─ UIとのやり取りをする窓口、バッチ開始、件数取得

CompletedTodoBatch.cls
 └─ Queueable①:件数集計、DB保存、次のQueueableへチェーン

CompletedTodoPostProcessQueueable.cls
 └─ Queueable②:後処理(futureを呼ぶなど)

TodoFutureHandler.cls
 └─ future(ログ保存、通知など)

最適化の必要がある部分
・静的変数の代わりにDBや別の永続化手段で管理することを推奨
・必ず引数を渡して非同期処理を呼び出すようにする。戻り値は使えません
・後続処理はチェーンQueueableで実装し、futureはログや軽い通知などの処理に限定する
・成功通知・失敗通知などのログはオブジェクトに保存するのが実務的
・将来「別の場所からも完了件数を更新したい」場合の再利用性を高める為
・ガバナ制限の把握、対策・テストが現在の内容だとしずらい
・処理を細分化し、トランザクション単位で管理しやすい構造にする必要がある
 
件数取得等の処理を今回はTodoController.clsに記述しているが、軽い処理なので一緒に書いています。最適化としては重い処理が多い場合非同期化するべきです。

重い処理かどうか判断する基準としては例えば下記のようなものです。
(例)
Todo__cを 1000 件以上まとめて処理する
・一括Done更新など、複数のレコードをまとめて更新する処理
・外部サービス(API)呼び出し
・エラー時の再実行ロジックなどの時間のかかる retries, 再試行処理


TodoController.cls(UIとやり取りする窓口) — 行ごとの注釈

public with sharing class TodoController {

    // 最新情報のtodoを一覧表示する為のデータ取得
    // ただページネーション機能を付与するので表示するレコードデータとレコード件数の二つが必要になる。なのでMapでの記述となる。
    // ページネーションの為に表示レコードとページ数の、二つの引数が必要となる
    @AuraEnabled(cacheable=false)
    public static Map<String,Object> getTodos( Integer pageNumber, Integer pageSize ){
        // 取得するデータの開始位置を決める為の記述
        Integer offsetRows = ( pageNumber - 1 ) * pageSize;
        // 表示するレコードデータを取得 offsetRowsでページ数に応じたデータを取得する
        List<Todo__c> record = [
            SELECT Id, Name, Done__c
            FROM Todo__c
            ORDER BY CreatedDate ASC
            LIMIT :pageSize OFFSET :offsetRows
        ];
        // Todo__cの全件数を取得する記述
        Integer totalCount = [SELECT COUNT() FROM Todo__c];
        // 取得した値をキーと共にMapに格納して返り値として設定
        return new Map<String,Object>{
            'record' => records ,
            'totalCount' => totalCount
        };
    }

    // 入力した名前を受け取って登録
    @AuraEnabled
    public static Todo__c insertTodo( String name ){
        // todoオブジェクトの変数に引数の内容を整形して格納
        Todo__c todo = new Todo__c( Name = name , Done__c = false );
        try {
            insert todo;
            return todo;
        } catch (DmlException e) {
            throw new AuraHandledException( 'todoの追加登録に失敗しました:' + e.getMessage());
        }
    }

    // 削除処理
    @AuraEnabled
    public static void deleteTodo( Id todoId ){
        // Apexでは=だけ(===みたいな記述はしない)
        // コロン : を使うことで Apex の変数 todoId の値を SOQL 内に埋め込める
        Todo__c todo = [SELECT Id FROM Todo__c WHERE Id = :todoId LIMIT 1];
        try {
            delete todo;
        } catch (DmlException e) {
            throw new AuraHandledException( 'todoの削除に失敗しました:' + e.getMessage());
        }
    }

    // 更新処理
    @AuraEnabled
    // ここでの引数は値を受け取る為の一時的な変数宣言でこれはApex内でしか使えない( Id todoId , String name , Boolean done )
    public static Todo__c updateTodo( Id todoId , String name , Boolean done ){
        // 受け取った引数のデータをDBから1件だけ取得する
        Todo__c todo = [SELECT Id, Name, Done__c FROM Todo__c WHERE Id = :todoId LIMIT 1];
        // 取得した内容に書き換える その後のtryでDBを更新する
        todo.Name = name;
        todo.Done__c = done;
        try {
            update todo;
            return todo;
        } catch (DmlException e) {
            throw new AuraHandledException( 'todoの更新に失敗しました:' + e.getMessage());
        }
    }

    // 最初に入口メソッドで非同期処理を起動する処理
    @AuraEnabled
    public static void startCompletedTodoBatch(){
        // Queueable処理を呼出す
        System.debug(new CompletedTodoBatch());
    }

    // 完了済みのTodoの件数を取得して件数を返す
    @AuraEnabled(cacheable=false)
    public static Integer getCompletedCount(){

        // 件数が格納されているデータのIdと件数データを取得
        List<CompletedTodo__c> listRec = [
            SELECT Id, Count__c
            FROM CompletedTodo__c
            LIMIT 1
        ];

        // 取得したデータに値が何もなければ0を返す エラー防止
        if(listRec.isEmpty()){
            return 0;
        }

        // 返り値を設定して件数を返り値に設定する
        return listRec[0].Count__c;
    }
}

⭐ with Sharingについて
with Sharingとは何か
 Salesforce の「権限・共有ルール」に従って、データの制御を行う設定です
 ユーザによって権限が違うのでwith Sharingを指定している場合は、そのユーザにあった表示をするようにできます。なのでUI(LWC・ボタン)から呼ばれるControllerは必ずwith sharingにする必要があります
 指定しない場合は、初期値がwithout sharingとして扱われるので忘れないように
 Service(Controller から呼ばれるもの)はどちらでも良い


CompletedTodoBatch.cls(Queueable:集計 + DB保存 + チェーン) — 行ごとの注釈

// Serviceなのでwith Sharingかどうかの明記はしていない
public class CompletedTodoBatch implements Queueable {

    // このクラス内でのみ使用するのでprivateのメソッドになります
    // CompletedTodo__cは完了したtodoの件数が何個かを格納するオブジェクトでデータは一つしかないです
    private Id getRecordId(){
        List<CompletedTodo__c> lst = [
            SELECT Id
            FROM CompletedTodo__c
            LIMIT 1
        ];
        // CompletedTodo__cにデータ入ってるかどうかの確認してあれば取得したIdを返す
        return lst.isEmpty() ? null : lst[0].Id;
    }

    // Queueableを使うなら、executeメソッドを書く必要があるので必ずこの記述になる
    public void execute(QueueableContext context){
    
        // トランザクション制御のためのセーブポイントを作成
        Savepoint sp = Database.setSavepoint();

        // バッチ処理で「完了したTodoの件数」をLWCや他のApexで使えるようにしたい
        // CompletedTodo__c というカスタムオブジェクト(レコードは一件だけでIDとCount_cの件数を格納するフィールドだけ)
        // 最新の完了件数をこのカスタムオブジェクトに格納、数値の更新をしたい為の記述を行う
        try{
            // 完了済みのTodoが何個か取得
            Integer coutnNum = [
                SELECT COUNT()
                FROM Todo__c
                WHERE Done__c = true
            ];
            // 完了件数(CompletedTodo__c)データベースへの適用
            upsert new CompletedTodo__c(
                Id = getRecordId(),
                Count__c = coutnNum
            );
            // 件数を引数に当ててQueueableを非同期処理で呼び出し
            // 件数更新後の通知やログ保存処理は全てCompletedTodoPostProcessQueueableに記述する
            System.enqueueJob(new CompletedTodoPostProcessQueueable(coutnNum));
        }
        catch(Exception e){
            // 失敗したらセーブポイントまで戻して変更を取り消す記述
            Database.rollback(sp);
            // エラー内容を非同期ログ(future)に送る
            TodoFutureHandler.sendAsyncLog('Batch失敗: ' + e.getMessage());
        }
    }
}

⭐ public void execute(QueueableContext context) とは何?
➡ Queueable インターフェースを実装すると“必ず実装が必要なメソッド”です。
 これはQueueable処理を開始すると必ず呼ばれるメソッドで、中身に処理を書きます。

🔹 もっと簡単に言うと
Queueable という “契約” を Apex に対してしているので、
「Queueable を使うなら、execute メソッドを書きなさい」
と Salesforce が決めている。

🔹 書式を分解すると
public void execute(QueueableContext context)
✔ public
外部から呼び出せるようにするアクセス修飾子。
✔ void
何も返さないメソッド。
✔ execute
Queueable の決まりで、この名前じゃないといけない。
✔ (QueueableContext context)
Queueable 専用の情報が入っている引数。
※ ステータス取得などに使える。


⭐ TodoFutureHandler.sendAsyncLog();について
TodoFutureHandler.sendAsyncLog('メッセージ');
この記述は毎回同じでメッセージ内容が変わるだけ
futureメソッドは呼び出し方が固定だから毎回同じで問題無しです。


CompletedTodoPostProcessQueueable.cls(Queueable:後処理) — 行ごとの注釈

// Queueable定義
public class CompletedTodoPostProcessQueueable implements Queueable {

    // フィールド定義
    private Integer resultCount;

    // コンストラクタでフィールドに引数で受け取った件数を格納する
    public CompletedTodoPostProcessQueueable(Integer count) {
        this.resultCount = count;
    }

    // 完了件数更新後の後処理をこの中に記述していきます
    public void execute(QueueableContext context){
        // TodoFutureHandlerでの@futureメソッドで表示するメッセージを入力
        TodoFutureHandler.sendAsyncLog('完了Todo件数: ' + resultCount);
    }
}

⭐ わざわざ後処理を違うトランザクションに分ける理由

処理の分割と役割分担
  最初のバッチ(CompletedTodoBatch)は「完了件数を集計して保存」だけに集中。
  次のQueueable(CompletedTodoPostProcessQueueable)は
   「集計結果をもとに追加の後処理(通知送信やログ出力など)」を行う。
  → それぞれ役割が分かれて見通しが良い。
Apexのガバナ制限の回避
  SalesforceのApexは、一回のトランザクションで実行できる処理量
   (DML回数やSOQL数)が決まっている。
  処理を分割して別トランザクションにすることで、制限の影響を減らせる。
失敗時の切り分けがしやすい
  もし後処理でエラーが起きても、前半の集計処理は問題なく完了しているので
  原因調査が楽。


TodoFutureHandler.cls@future:非同期・引数ありの実務的例) — 行ごとの注釈

public class TodoFutureHandler {
    @future
    public static void TodoFutureHandler(String message) {
        // 開発者向けのログ出力で内容を出力する
        System.debug('出力ログ:' + message);

        // ログ用のオブジェクトに新規保存する処理
        Log__c log = new Log__c(
            Message__c = message,
            // 現在の日時(yyyy-MM-dd HH:mm:ss)を返すメソッド
            CreatedTime__c = System.now()
        );
        insert log;
    }
}

@futureについて
Queueable → データ処理が中心
@future → 通知、ログ保存、外部API送信 など「副作用処理」

上記のように、業務では役割で分けることが多い。
ただ処理が軽いならQueueableにまとめて記述しても問題無いです。DB更新の後に記述すれば良いです。
ただ、最適化を求めるのであれば責務(役割)を分ける為に、通知、ログ保存など後の処理は@futureに分けて記述するべきです。


⭐public static void sendAsyncLog(String message)の記述について
public
  このメソッドは他のクラスや外部から呼び出せるように公開しています。
  例えばTodoFutureHandler.sendAsyncLog(...)のように呼べるためにpublicにしています。

static
  @future メソッドは 必ず static でなければいけない。
  なぜなら非同期処理として別トランザクションで実行されるため、インスタンス(オブジェクト)を必要としない形で呼ばれる必要があるからです。

void
  @futureメソッドは戻り値を返せません。
  戻り値があるとコンパイルエラーになります。
  非同期で処理を呼び出すだけで、呼び出し元は結果を受け取らず、ただ「処理を予約」するだけだからです。

引数:(String message)
  表示させたい文言を引数で受け取り、処理を流す
  @futureメソッドは 必ず引数を受け取らなければならない

これらは@futureメソッドを書く時の必須ルールになります。


最終的なファイル構成(ディレクトリ構造)

/force-app
 └─ /main
     └─ /default
         ├─ /classes
         │    ├─ TodoController.cls
         │    ├─ TodoController.cls-meta.xml
         │    ├─ CompletedTodoBatch.cls
         │    ├─ CompletedTodoBatch.cls-meta.xml
         │    ├─ CompletedTodoPostProcessQueueable.cls
         │    ├─ CompletedTodoPostProcessQueueable.cls-meta.xml
         │    ├─ TodoFutureHandler.cls
         │    ├─ TodoFutureHandler.cls-meta.xml
         └─ /lwc
              ├─ todo_todoApp
              │    ├─ todo_todoApp.html
              │    ├─ todo_todoApp.js
              │    ├─ todo_todoApp.js-meta.xml
              ├─ todo_listView
              │    ├─ todo_listView.html
              │    ├─ todo_listView.js
              │    ├─ todo_listView.js-meta.xml
              └─ todo_todoItem
                   ├─ todo_todoItem.html
                   ├─ todo_todoItem.js
                   ├─ todo_todoItem.js-meta.xml

🔧 改修の際の手順、ポイントまとめ

今回のように改修や機能追加の際の手順や気を付けるべきポイントをざっくりと記述

  • まずは影響範囲を選定すること
     どの記述を、どのファイルを変更するかを明確にする。
     
  • 現状の動作を確認
     改修前の段階で動作している部分が改修後に動かなくなるようなことが起きた際に、すぐに分かるようにしておく。
     
  • 改修は「上流 → 下流」
     現場では基本的に「LWC → Apex」の順に親(親LWC)から修正します。
     
  • 一度に複数箇所を直さない
     どこが原因かわからなくなるので少しずつ改修して確認してを繰り返すようになる。
     
  • 修正内容を後から把握できるようにする
     Gitなどでバージョン管理できるなら行う。バックアップも残せると安心。
     修正箇所が分かるようにコメントアウトを残すと後で苦労しない。
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?