iT邦幫忙

2026 iThome 鐵人賽

DAY 10
2
Modern Web

Angular 22 Signal 進化論系列 第 10 篇

Day 10:linkedSignal 讓相依狀態也能被修改

  • 分享至 

  • xImage
  •  

昨天我們透過 httpResource() 搭配 chain() 處理兩個非同步資料之間的依賴。下游 Request 需要使用上游資料時,會等上游完成後再繼續;當 Request 的來源改變,Resource 的狀態也會跟著更新。

Signal 也會遇到類似情況。有些狀態會依賴其他 Signal,但本身又需要被修改,甚至在來源改變後參考前一次的值。這篇就來看看 linkedSignal() 可以怎麼處理這類狀態。

當 Signal 狀態既要自動更新,也要手動修改

有些狀態會跟著其他 Signal 改變,但同時也需要讓使用者自行修改。

例如畫面上有公告分類與公告清單。切換分類後,清單會重新篩選,並預設選擇該分類的第一筆公告;使用者也可以再選擇其他公告。

假設目前有以下資料:

  • 全部:系統維護通知、週年慶活動、版本更新說明、會員日優惠
  • 系統:系統維護通知、版本更新說明
  • 活動:週年慶活動、會員日優惠

先使用 computed() 實作:

import { Component, computed, signal } from '@angular/core';

interface Message {
  id: number;
  category: string;
  title: string;
}

const MESSAGES: Message[] = [
  { id: 1, category: '系統', title: '系統維護通知' },
  { id: 2, category: '活動', title: '週年慶活動' },
  { id: 3, category: '系統', title: '版本更新說明' },
  { id: 4, category: '活動', title: '會員日優惠' },
];

@Component({
  selector: 'app-root',
  template: `
    <button (click)="category.set('全部')">全部</button>
    <button (click)="category.set('系統')">系統</button>
    <button (click)="category.set('活動')">活動</button>

    <ul>
      @for (message of messages(); track message.id) {
        <li [class.active]="message.id === selectedId()">
          {{ message.title }}
        </li>
      }
    </ul>
  `,
  styles: `
    li {
      padding: 8px 12px;
      margin-bottom: 4px;
      border-radius: 6px;
    }

    li.active {
      background: #e0e7ff;
      font-weight: 600;
    }
  `,
})
export class AppComponent {
  category = signal('全部');

  messages = computed(() =>
    this.category() === '全部'
      ? MESSAGES
      : MESSAGES.filter(
          (message) => message.category === this.category()
        )
  );

  selectedId = computed(
    () => this.messages()[0].id
  );
}

messages 會根據 category 篩選,而 selectedId 會取得目前清單的第一筆。

如果還需要讓使用者自行選擇公告,selectedId 就會有兩種更新來源:

Signal 要依賴生成本身也要能夠修改情境

分類改變時,可以透過 computed() 重新計算 selectedId;但使用者選擇其他公告時,就需要直接修改它:

this.selectedId.set(3);

computed() 產生的是唯讀 Signal,不能使用 set()。

這裡的 selectedId 除了會跟著 messages 改變,也需要保存使用者目前的選擇。這種有依賴關係,又需要自行修改的狀態,就可以使用 linkedSignal()。

使用 linkedSignal 建立可修改的相依狀態

把 selectedId 改成 linkedSignal(),並在使用者選擇公告時透過 set() 更新:

import { Component, computed, linkedSignal, signal } from '@angular/core';

interface Message {
  id: number;
  category: string;
  title: string;
}

const MESSAGES: Message[] = [
  { id: 1, category: '系統', title: '系統維護通知' },
  { id: 2, category: '活動', title: '週年慶活動' },
  { id: 3, category: '系統', title: '版本更新說明' },
  { id: 4, category: '活動', title: '會員日優惠' },
];

@Component({
  selector: 'app-root',
  template: `
    <button (click)="category.set('全部')">全部</button>
    <button (click)="category.set('系統')">系統</button>
    <button (click)="category.set('活動')">活動</button>

    <ul>
      @for (message of messages(); track message.id) {
        <li
          [class.active]="message.id === selectedId()"
          (click)="selectedId.set(message.id)"
        >
          {{ message.title }}
        </li>
      }
    </ul>
  `,
  styles: `
    li {
      padding: 8px 12px;
      margin-bottom: 4px;
      border-radius: 6px;
      cursor: pointer;
    }

    li.active {
      background: #e0e7ff;
      font-weight: 600;
    }
  `,
})
export class AppComponent {
  category = signal('全部');

  messages = computed(() =>
    this.category() === '全部'
      ? MESSAGES
      : MESSAGES.filter(
          (message) => message.category === this.category()
        )
  );

  selectedId = linkedSignal(
    () => this.messages()[0].id
  );
}

切換分類時,selectedId 會自動更新為新清單的第一筆;使用者也可以手動選擇其他公告。

computed() 適合唯讀的衍生狀態,而 linkedSignal() 適合有依賴關係、同時又需要修改的狀態。

computed Signal 跟 linkedSignal

新請求完成前保留上一筆資料

linkedSignal() 也可以用來處理 Resource 的狀態。

有些情境在查詢條件改變後,不希望畫面立刻清空,而是先保留上一筆結果,等新的 Request 完成後再替換。

一般的 httpResource() 在 Request 來源改變後會重新發出請求並進入 loading,這時原本的 value() 會變成 undefined。

userId = signal(1);

userResource = httpResource<User>(
  () => `/api/users/${this.userId()}`
);

當 userId 改變時:

this.userId.set(2);

如果希望新的 Request 執行期間仍保留上一筆資料,可以搭配 Resource 的 snapshot 與 linkedSignal()。

  • snapshot:取得目前的 Resource 狀態
  • previous.value:取得 linkedSignal() 前一次保存的 Snapshot

把 snapshot 作為 linkedSignal() 的來源後,就能在 Resource 進入 loading 時沿用上一筆 value,等新的 Response 回來再替換。

httpResource + linkedSignal 用法

使用 snapshot 搭配 linkedSignal

httpResource() 本身就提供 snapshot,因此可以直接把它作為 linkedSignal() 的來源。

先建立一個 withPreviousValue():

import {
  linkedSignal,
  resourceFromSnapshots,
  Resource,
  ResourceSnapshot,
} from '@angular/core';

function withPreviousValue<T>(
  input: Resource<T>
): Resource<T> {
  const snapshot = linkedSignal<
    ResourceSnapshot<T>,
    ResourceSnapshot<T>
  >({
    source: input.snapshot,

    computation: (current, previous) => {
      if (
        current.status === 'loading' &&
        previous &&
        previous.value.status !== 'error'
      ) {
        return {
          status: 'loading' as const,
          value: previous.value.value,
        };
      }

      return current;
    },
  });

  return resourceFromSnapshots(snapshot);
}

這裡把原本 Resource 的 snapshot 當成來源:

source: input.snapshot

當新的 Request 開始後,current 會進入 loading,而 previous.value 保留前一次的 Snapshot。

因此可以在 loading 期間沿用上一筆資料:

return {
  status: 'loading' as const,
  value: previous.value.value,
};

此時 Resource 仍然是 loading,但 value 會保留上一筆資料:

status = loading
value  = User 1

新的 Request 完成後,current 會帶著新的資料進來,直接回傳即可:

return current;

linkedSignal() 這裡產生的是 Signal<ResourceSnapshot<T>>。

最後再透過 resourceFromSnapshots() 根據這個 Snapshot Signal 建立 Resource<T>,讓外部繼續使用 value()、status()、isLoading() 等 Resource API:

return resourceFromSnapshots(snapshot);

使用時包住原本的 httpResource():

userId = signal(1);

userResource = withPreviousValue(
  httpResource<User>(
    () => `/api/users/${this.userId()}`
  )
);

這時新的 Request 還在執行時,Resource 可能同時處於:

isLoading() = true
hasValue()  = true

也就是狀態已經進入 loading,但 value() 仍然保留上一筆資料。

如果希望 Loading 期間繼續顯示上一筆內容,可以直接使用:

@if (userResource.hasValue()) {
  <p>{{ userResource.value().name }}</p>
}

等新的結果回來後,value() 再更新成新的資料。

withPreviousValue 用法

延遲顯示 Loading

保留上一筆資料後,還可以再調整 Loading 顯示的時機。

有些 Request 很快就會完成,如果 isLoading() 一變成 true 就立刻顯示 Skeleton,畫面容易出現短暫閃爍。

這部分不需要改變 httpResource(),可以另外用 RxJS 延遲 Loading 狀態。

先把 isLoading() 轉成 Observable,在進入 Loading 後等待 300ms,再轉回 Signal:

import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { map, of, switchMap, timer } from 'rxjs';

showLoading = toSignal(
  toObservable(this.userResource.isLoading).pipe(
    switchMap((isLoading) =>
      isLoading
        ? timer(300).pipe(
            map(() => true)
          )
        : of(false)
    )
  ),
  {
    initialValue: false,
  }
);

如果 Request 在 300ms 內完成,isLoading 會先變回 false,switchMap() 會取消前面的 timer(),Skeleton 就不會出現。

這裡因為 withPreviousValue() 會讓 hasValue() 在 Loading 期間維持 true,Template 需要讓 showLoading() 優先:

@if (showLoading()) {
  <app-skeleton />
} @else if (userResource.hasValue()) {
  <p>{{ userResource.value().name }}</p>
}

前 300ms 會繼續顯示上一筆資料;如果 Request 超過 300ms 還沒完成,才改成 Skeleton。

Request 開始
      ↓
0 ~ 300ms
保留上一筆資料
      ↓
超過 300ms
顯示 Skeleton
      ↓
Request 完成
顯示新資料

兩個需求分別處理不同的事情:

snapshot + linkedSignal
→ 新 Request 完成前保留上一筆資料

RxJS
→ 超過 300ms 才顯示 Skeleton

如果新的 Request 在 300ms 內完成,就直接從舊資料換成新資料,不需要出現 Skeleton。

延遲顯示 Loading 搭配 withPreviousValue 作法

本日結語

linkedSignal() 適合處理會受到其他 Signal 影響,但本身仍需要修改的狀態。

和 computed() 相比,可以從狀態是否需要直接修改來區分:

  • 完全由其他狀態推算,不需要修改:computed()
  • 會受到來源影響,本身也需要修改:linkedSignal()
  • 不需要跟著其他狀態重設:獨立的 signal()

linkedSignal() 也可以取代部分單純為了同步狀態而使用 effect() 的寫法,把依賴關係直接放在狀態宣告中。

到了 Resource,也可以利用同樣的概念。透過 snapshot 與 previous.value,可以在新的 Request 完成前保留上一筆資料;Loading 的顯示時機則另外交給 RxJS 處理。

不過保留上一筆資料並不適合所有情境。如果舊資料容易被誤認為目前資料,例如切換帳號、病人或其他敏感上下文,就不適合直接沿用上一筆內容,或需要搭配明確的 Loading 狀態。

資料來源


上一篇
Day 9:httpResource 實務篇,統一回覆格式與 chain 相依請求
下一篇
Day 11:用 rxResource 串起 Observable 與 Resource
系列文
Angular 22 Signal 進化論 共 12 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言