{"record":{"id":"8b54441c5c125d77","repo":"clockworklabs/SpacetimeDB","slug":"unsubscribe-has-already-been-called","errorCode":null,"errorMessage":"Unsubscribe has already been called","messagePattern":"Unsubscribe has already been called","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"crates/bindings-typescript/src/sdk/subscription_builder_impl.ts","lineNumber":220,"sourceCode":"          onError(ctx, error);\n        }\n      }\n    );\n    this.#querySetId = this.db.registerSubscription(\n      this,\n      this.#emitter,\n      querySql\n    );\n  }\n\n  /**\n   * Consumes self and issues an `Unsubscribe` message,\n   * removing this query from the client's set of subscribed queries.\n   * It is only valid to call this method if `is_active()` is `true`.\n   */\n  unsubscribe(): void {\n    if (this.#unsubscribeCalled) {\n      throw new Error('Unsubscribe has already been called');\n    }\n    this.#unsubscribeCalled = true;\n    this.db.unregisterSubscription(this.#querySetId);\n    this.#emitter.on(\n      'end',\n      (_ctx: SubscriptionEventContextInterface<RemoteModule>) => {\n        this.#endedState = true;\n        this.#activeState = false;\n      }\n    );\n  }\n\n  /**\n   * Unsubscribes and also registers a callback to run upon success.\n   * I.e. when an `UnsubscribeApplied` message is received.\n   *\n   * If `Unsubscribe` returns an error,\n   * or if the `on_error` callback(s) are invoked before this subscription would end normally,","sourceCodeStart":202,"sourceCodeEnd":238,"githubUrl":"https://github.com/clockworklabs/SpacetimeDB/blob/6dee26c6efc2856793e12b148a59742964f5d783/crates/bindings-typescript/src/sdk/subscription_builder_impl.ts#L202-L238","documentation":"SubscriptionHandleImpl.unsubscribe consumes the handle: it flips #unsubscribeCalled, unregisters the query set, and listens for the end event. A second call on the same handle throws because the Unsubscribe message was already issued. Note the handle's active state only flips to false when the end event arrives, so isActive() alone does not prove unsubscribe was not already called.","triggerScenarios":"Calling unsubscribe() twice on the same SubscriptionHandleImpl; calling unsubscribeThen() after unsubscribe() on the same handle; React StrictMode dev double-mount running an effect cleanup that unsubscribes twice.","commonSituations":"React 18 StrictMode double-invoking cleanup in development; unsubscribe triggered both by component unmount and by an explicit user action; retry/teardown logic that re-runs the same cleanup path.","solutions":["Unsubscribe exactly once per handle from a single owner; null the handle ref after calling unsubscribe so cleanup becomes a no-op","Keep your own alreadyUnsubscribed flag; isActive() is not sufficient because it stays true until the end event fires","In React, unsubscribe only in the effect cleanup, not also in an explicit handler sharing the handle"],"exampleFix":"// before\nuseEffect(() => {\n  const sub = ...;\n  return () => sub.unsubscribe(); // StrictMode runs this twice -> throws\n}, []);\n\n// after\nconst subRef = useRef<SubscriptionHandleImpl<any> | null>(null);\nuseEffect(() => {\n  subRef.current = ...;\n  return () => {\n    subRef.current?.unsubscribe();\n    subRef.current = null;\n  };\n}, []);","handlingStrategy":"validation","validationCode":"// unsubscribe() consumes the handle immediately; isActive() stays true until the\n// 'end' event, so track consumption yourself.\nif (subRef.current) {\n  subRef.current.unsubscribe();\n  subRef.current = null; // second cleanup is now a no-op\n}","typeGuard":null,"tryCatchPattern":null,"preventionTips":["Call unsubscribe exactly once per handle; null the ref afterwards","Do not rely on isActive() to detect a prior unsubscribe call","Give each subscription one owner (effect cleanup or controller), never both"],"tags":["subscription","lifecycle","react","typescript"],"backgroundTag":"invalid-state-transition","analyzedSha":"6dee26c6efc2856793e12b148a59742964f5d783","analyzedAt":"2026-08-20T06:08:37.179Z","contentChangedAt":"2026-08-20T06:08:37.179Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}