rxjs-like-a-pro

作者: sanity-io

這項技能幫助你撰寫慣用、可組合且避免常見陷阱的 RxJS 程式碼。核心哲學:將邏輯保留在 observable 鏈中

npx skills add https://github.com/sanity-io/sanity --skill rxjs-like-a-pro

RxJS Like a Pro

This skill helps you write RxJS code that is idiomatic, composable, and free of common pitfalls. The core philosophy: keep logic in the observable chain. Every time you reach for .subscribe(), ask whether the work could instead be expressed as a transformation inside .pipe().

Reference files

For detailed examples and patterns, read the relevant reference file:

  • references/loading-state-patterns.md — Deriving loading/error state in the chain, the withLoadingState custom operator, and using scan to preserve previous results across loading states. Read when working with async data fetching that needs loading indicators.
  • references/massive-observable.md — How to refactor bloated new Observable() constructors into small focused pieces. Read when you see a new Observable callback longer than ~10 lines.
  • references/inner-observable-chains.md — Building rich inner observable sequences with timing, delays, and animation phases. Read when composing multi-step async sequences or replacing setTimeout patterns.
  • references/custom-operators.md — How to write inline and extracted custom operators with OperatorFunction. Read when extracting reusable stream logic.

The #1 Anti-pattern: Premature Subscribe

The most common RxJS mistake is subscribing too early and then doing imperative work inside the callback — tracking state in variables, calling functions with side effects, or worse, subscribing to another observable inside the callback (the "subscribe-in-subscribe" pattern).

Why this matters: when you subscribe early, you lose the power of the reactive chain. You can no longer compose, retry, cancel, debounce, or share that work. You've escaped from the declarative world into imperative spaghetti, and every new requirement (add a retry, add a timeout, combine with another stream) means more manual state management.

// ❌ Bad: subscribe-in-subscribe with manual state tracking
let currentData: Data | null = null
let loading = false

input$.subscribe((value) => {
  loading = true
  fetchData(value).subscribe((data) => {
    currentData = data
    loading = false
  })
})

// ✅ Good: everything is in the chain
const data$ = input$.pipe(switchMap((value) => fetchData(value)))

For loading state, derive it inside the chain using startWith — see references/loading-state-patterns.md.

The Massive new Observable() Antipattern

Another common antipattern is stuffing an entire program into a single new Observable(subscriber => { ... }) constructor — setting up listeners, resolving promises, subscribing to other observables, managing retry state, all in one giant callback. This is imperative code wearing an Observable costume.

The new Observable() constructor should be small and focused — a thin bridge from one non-reactive source into the reactive world. For promise-based sources, use defer(() => promise) instead. Retry logic, error handling, combining sources — all of that belongs in the operator chain.

See references/massive-observable.md for a full before/after example.

Choosing the Right Flattening Operator

OperatorBehaviorUse when
switchMapCancels previous inner when new value arrivesUser input, search-as-you-type, route changes — only the latest matters
mergeMapRuns all inner observables concurrentlyIndependent operations where all results are needed (logging, fire-and-forget)
concatMapQueues inner observables, runs in orderOrder matters and nothing should be dropped (sequential writes, queues)
exhaustMapIgnores new values while inner is runningPreventing duplicate submissions (form submit clicks)

Default to switchMap for most UI/request scenarios.

The inner observable doesn't have to be a single request — it can be an entire timeline of events using concat, merge, timer, delay. See references/inner-observable-chains.md for animation and timing examples.

Error Handling

Put catchError on the inner observable when you want the outer stream to keep running. Put it on the outer stream only when you truly want to replace the entire stream on error:

// ❌ Bad: catchError on outer stream kills it for good
source$.pipe(
  switchMap((value) => fetchData(value)),
  catchError((err) => of(fallback)),
)

// ✅ Good: catchError inside switchMap — outer stream survives
source$.pipe(switchMap((value) => fetchData(value).pipe(catchError((err) => of(fallback)))))

Same principle applies to retry — retry the inner operation, not the entire outer stream:

source$.pipe(
  switchMap((value) =>
    fetchData(value).pipe(
      retry({count: 3, delay: 1000}),
      catchError((err) => of(fallback)),
    ),
  ),
)

Avoiding Memory Leaks

The fewer manual subscriptions, the fewer chances to leak. In order of preference:

  1. Don't subscribe at all — let the framework handle subscription lifecycle where possible
  2. Use operators that complete naturally — first(), take(n), takeUntil(destroy$)
  3. Use takeUntil with a notifier:
const destroy$ = new Subject<void>();
someObservable$.pipe(
  takeUntil(destroy$),
).subscribe(value => /* ... */);

// In teardown: destroy$.next(); destroy$.complete();

takeUntil must be the last operator in the pipe. Operators after it (especially flattening operators) can create inner subscriptions that takeUntil doesn't know about, causing leaks.

  1. Compose into a single subscription — if you have multiple independent streams with side effects, merge them into one and subscribe once.

Hot vs Cold

  • Cold observables (new Observable(...), of(), HTTP requests) create a new execution per subscriber
  • Hot observables (Subject, fromEvent) share a single execution

Share cold observables with shareReplay({ bufferSize: 1, refCount: true }). Always use refCount: true — without it, the source subscription stays alive after all subscribers unsubscribe (memory leak).

Deriving State Reactively

Instead of mutable variables updated from multiple subscriptions, derive state from streams:

// ❌ Bad: mutable state, inconsistent windows
let items: Item[] = []
let filter = ''
items$.subscribe((i) => {
  items = i
  recompute()
})
filter$.subscribe((f) => {
  filter = f
  recompute()
})

// ✅ Good: always consistent
const filteredItems$ = combineLatest([items$, filter$]).pipe(
  map(([items, filter]) => items.filter((item) => item.name.includes(filter))),
)

combineLatest vs withLatestFrom: combineLatest emits when any input emits (all inputs drive output). withLatestFrom emits only when the source emits (one driver, others are context).

startWith: combineLatest won't emit until every input has emitted at least once. Use startWith to provide initial values and unblock the stream.

Subjects: Use Sparingly

Subject, BehaviorSubject, ReplaySubject are escape hatches for bridging imperative and reactive code. Appropriate for event buses and bridging callbacks. Not appropriate as general-purpose state containers — if you're calling .next() in multiple places to keep a Subject in sync, use a derived stream instead.

Custom Operators

Don't be afraid to write them — they're just functions with the signature (source: Observable<A>) => Observable<B>. Extract repeated .pipe() chains into named operators with OperatorFunction<In, Out>. See references/custom-operators.md for inline and extracted examples.

Side Effects Belong in tap, Not in subscribe

A good rule of thumb: .subscribe() should have no arguments. All side effects — logging, updating the DOM, writing to a database, sending analytics — belong in tap inside the chain. The .subscribe() at the end just activates the stream.

// ❌ Bad: side effects crammed into subscribe
source$.pipe(switchMap((value) => fetchData(value))).subscribe(
  (data) => {
    updateUI(data)
    logAnalytics('data_loaded', data)
    cache.set(data)
  },
  (err) => showError(err),
)

// ✅ Good: side effects in tap, subscribe just activates
source$
  .pipe(
    switchMap((value) => fetchData(value)),
    tap((data) => updateUI(data)),
    tap((data) => logAnalytics('data_loaded', data)),
    tap((data) => cache.set(data)),
    tap({error: (err) => showError(err)}),
  )
  .subscribe()

Why this matters: when side effects are in the chain, they're composable. You can add, remove, or reorder them. You can put a filter between them. You can share the stream and have different subscribers without duplicating side-effect logic. When everything is stuffed into .subscribe(), you've lost all of that.

tap also accepts an observer object with lifecycle hooks — particularly useful for debugging:

source$.pipe(
  tap({
    subscribe: () => console.log('subscribed!'),
    next: (value) => console.log('value:', value),
    error: (err) => console.log('error:', err),
    complete: () => console.log('complete'),
    unsubscribe: () => console.log('unsubscribed'),
    finalize: () => console.log('finalized (complete or unsubscribe)'),
  }),
)

The subscribe hook is especially handy for debugging "why isn't my stream emitting?" — it confirms whether anything is actually subscribing.

Avoid Unnecessary Promise Conversion

firstValueFrom/lastValueFrom are appropriate for one-shot interop with promise-based APIs. They're a code smell when used inside subscribe callbacks to avoid learning the reactive approach — that work belongs in the chain with switchMap.

Quick Reference: Common Refactoring Patterns

Anti-patternRefactoring
a$.subscribe(x => b$.subscribe(y => ...))a$.pipe(switchMap(x => b$)) (or mergeMap/concatMap/exhaustMap)
Mutable variable updated in subscribescan() or combineLatest to derive state
setTimeout inside subscribedelay(), timer(), or debounceTime()
if guard in subscribe to skip valuesfilter() before subscribe
try/catch inside subscribecatchError() in the pipe
Manual request cancellation flagsswitchMap (auto-cancels previous)
Multiple subscribes to same cold observableshareReplay({ bufferSize: 1, refCount: true })
.subscribe() just to trigger side effectstap() for side effects, keep the chain going
Massive new Observable() constructorSmall focused constructors + defer() + operator composition
await firstValueFrom() inside subscribeswitchMap — stay in the chain

來自 sanity-io 的更多技能

tdd
sanity-io
以紅-綠-重構循環進行測試驅動開發。當使用者想透過TDD建立功能或修復錯誤、提及「紅-綠-重構」、想要…時使用。
performance-optimization
sanity-io
優化應用程式效能。當存在效能需求、懷疑效能回歸,或核心網頁指標與載入時間…時使用。
content-experimentation-best-practices
sanity-io
結構化指引,涵蓋設計、執行與分析內容實驗以提升轉換率與參與度。內容包括假設框架、指標選擇、樣本數計算,以及A/B測試與多變量實驗中的統計顯著性檢定。提供關於p值、信賴區間、統計檢定力分析及貝氏方法的詳細資源,用於解讀實驗結果。同時包含CMS整合模式,以便在欄位層級管理變體,並連接外部系統。
content-modeling-best-practices
sanity-io
結構化內容建模指南,涵蓋綱要設計、可重用性與多渠道發布。核心原則包括:將內容視為數據而非頁面、維護單一事實來源、為未來渠道設計,以及優化編輯工作流程。提供關於引用與嵌入物件、關注點分離及內容重用模式的決策框架。包含針對扁平、階層與分面分類法的分類學指引。適用於...
portable-text-conversion
sanity-io
將 HTML 和 Markdown 內容轉換為適用於 Sanity 的可攜式文字區塊。用於從舊版 CMS 遷移內容、將 HTML 或 Markdown 匯入 Sanity 等情境。
portable-text-serialization
sanity-io
將 Portable Text 渲染並序列化為 React、Svelte、Vue、Astro、HTML、Markdown 及純文字。適用於在任何前端實作 Portable Text 渲染時使用…
sanity-best-practices
sanity-io
我們要將提供的英文描述翻譯成繁體中文。注意事項:保留產品名稱、協定名稱、URL、數字和技術術語。不要添加聲明、解釋、Markdown、項目符號、鏈接、標籤、前綴或額外評論。只翻譯<text>內的文字,不要包含名稱除非它在源文本中出現。不要包含標籤如「description」、「server name」或「skill name」。 源文本: "Comprehensive best practices and integration guides for Sanity CMS development across frameworks and topics. Covers 10+ framework integrations including Next.js, Nuxt, Astro, Remix, SvelteKit, and Angular with framework-specific patterns and setup guidance Includes topic guides for schema design, GROQ query optimization, Visual Editing, Portable Text, images, TypeGen, localization, and content migrations Provides quick-reference structure for loading only relevant guides based on task type,..." 注意:Sanity CMS 是產品名稱,保留。Next.js, Nuxt, Astro, Remix, SvelteKit
sanity-migration
sanity-io
規劃、執行並審查從其他內容管理系統及內容平台遷移至 Sanity 的作業。適用於從 AEM、Adobe Experience Manager、Contentful、Strapi、Webflow、WordPress、Payload、Drupal、Markdown/MDX/frontmatter 檔案、WXR/XML 匯出、CMS API、資料庫匯出、靜態 HTML 進行遷移或平台轉換,或設計資料擷取、轉換、Portable Text 轉換、資產遷移、重新導向、驗證及切換流程時使用。
data-analysisdatabasedevelopment