Spoosh
Plugin DevelopmentAdvanced

Lifecycle Hooks

Managing state and resources across component lifecycles

Lifecycle hooks let your plugins react to component mount, update, and unmount events. They're essential for managing resources like timers, event listeners, and tracking per-hook state.

Why you need lifecycle hooks:

  • Clean up resources when components unmount (timers, listeners, polling)
  • Track which hook instances are currently active
  • React to option changes (refetch when filters change)
  • Initialize state when a hook first mounts

Lifecycle Overview

interface PluginLifecycle {
  onMount?: (context: PluginContext) => void | Promise<void>;
  onUpdate?: (
    context: PluginContext,
    previousContext: PluginContext
  ) => void | Promise<void>;
  onUnmount?: (context: PluginContext) => void | Promise<void>;
}

When Each Hook is Called

HookReactAngular
onMountComponent mountsInject executes
onUpdateOptions/query changeOptions/query change
onUnmountComponent unmountsComponent destroyed

State Tracking Patterns

Plugins often need to track state per hook instance. Use instanceId for this.

Pattern: Set<string> for Boolean Tracking

Use a Set when you need to track "is this hook doing X?":

const initialDataAppliedFor = new Set<string>();

return {
  name: "spoosh:initialData",
  operations: ["read", "pages"],

  middleware: async (context, next) => {
    if (!context.instanceId) {
      return next();
    }

    if (initialDataAppliedFor.has(context.instanceId)) {
      return next();
    }

    const pluginOptions = context.pluginOptions as
      | InitialDataReadOptions
      | undefined;

    if (pluginOptions?.initialData === undefined) {
      return next();
    }

    initialDataAppliedFor.add(context.instanceId);

    context.stateManager.setCache(context.queryKey, {
      state: {
        data: pluginOptions.initialData,
        error: undefined,
        timestamp: Date.now(),
      },
      tags: context.tags,
    });

    return next();
  },

  lifecycle: {
    onUnmount(context) {
      if (context.instanceId) {
        initialDataAppliedFor.delete(context.instanceId);
      }
    },
  },
};

When to use: Tracking "has this happened?" flags like:

  • Has initial data been applied?
  • Is this hook currently polling?
  • Has this hook been initialized?

Pattern: Map<string, T> for Associated Data

Use a Map when you need to store data associated with each hook:

const timeouts = new Map<string, ReturnType<typeof setTimeout>>();

const clearPolling = (queryKey: string) => {
  const timeout = timeouts.get(queryKey);

  if (timeout) {
    clearTimeout(timeout);
    timeouts.delete(queryKey);
  }
};

return {
  name: "spoosh:polling",
  operations: ["read", "pages"],

  afterResponse(context, response) {
    const pollingInterval = (
      context.pluginOptions as PollingReadOptions | undefined
    )?.pollingInterval;

    if (!pollingInterval) return;

    clearPolling(context.queryKey);

    const timeout = setTimeout(() => {
      timeouts.delete(context.queryKey);

      context.eventEmitter.emit("refetch", {
        queryKey: context.queryKey,
        reason: "polling",
      });
    }, pollingInterval);

    timeouts.set(context.queryKey, timeout);
  },

  lifecycle: {
    onUnmount(context) {
      clearPolling(context.queryKey);
    },
  },
};

When to use: Storing resources like:

  • Timers (setTimeout/setInterval)
  • Event listeners cleanup functions
  • Subscriptions

CRITICAL: Why instanceId, Not queryKey

Problem: Multiple hooks can share the same queryKey but have different lifecycles.

function UserProfile({ userId }: { userId: string }) {
  const { data } = useRead((api) => api("users/:id").GET({ params: { id: userId } }));
  return <div>{data?.name}</div>;
}

function UserAvatar({ userId }: { userId: string }) {
  const { data } = useRead((api) => api("users/:id").GET({ params: { id: userId } }));
  return <img src={data?.avatar} />;
}

// Same queryKey, different instanceIds

Wrong approach using queryKey:

const intervals = new Map<string, NodeJS.Timer>();

lifecycle: {
  onMount(context) {
    const interval = setInterval(() => { /* ... */ }, 1000);
    intervals.set(context.queryKey, interval);
  },

  onUnmount(context) {
    const interval = intervals.get(context.queryKey);
    if (interval) {
      clearInterval(interval);
      intervals.delete(context.queryKey);
    }
  },
}

// Timeline:
// 1. UserProfile mounts → set interval #1 for queryKey
// 2. UserAvatar mounts → overwrites with interval #2 for queryKey
// 3. UserProfile unmounts → clears interval #2 (UserAvatar's interval!) ❌
// 4. UserAvatar still mounted but no interval running ❌

Correct approach using instanceId:

const intervals = new Map<string, NodeJS.Timer>();

lifecycle: {
  onMount(context) {
    if (!context.instanceId) return;

    const interval = setInterval(() => { /* ... */ }, 1000);
    intervals.set(context.instanceId, interval);
  },

  onUnmount(context) {
    if (!context.instanceId) return;

    const interval = intervals.get(context.instanceId);
    if (interval) {
      clearInterval(interval);
      intervals.delete(context.instanceId);
    }
  },
}

// Timeline:
// 1. UserProfile mounts → set interval #1 for instanceId-1
// 2. UserAvatar mounts → set interval #2 for instanceId-2
// 3. UserProfile unmounts → clears interval #1 only ✅
// 4. UserAvatar still has interval #2 running ✅

When to Use instanceId vs queryKey

OperationUse
Cache operationsqueryKey - All hooks share cache
Per-hook trackinginstanceId - Independent per hook
Event listenersinstanceId - Each hook has own listeners
Timers/IntervalsinstanceId - Independent per hook
SubscriptionsinstanceId - Independent per hook
Shared countersqueryKey - Count all hooks for this query

Cleanup Pattern

Rule: If you set up a resource in onMount, always clean it up in onUnmount.

Example: Event Listeners

type CleanupFn = () => void;

type HookListenerEntry = {
  queryKey: string;
  focusCleanup?: CleanupFn;
  reconnectCleanup?: CleanupFn;
};

const listenersByHook = new Map<string, HookListenerEntry>();

const setupFocusListener = (
  instanceId: string,
  queryKey: string,
  eventEmitter: EventEmitter
) => {
  const visibilityHandler = () => {
    if (document.visibilityState === "visible") {
      eventEmitter.emit("refetch", {
        queryKey,
        reason: "focus",
      });
    }
  };

  document.addEventListener("visibilitychange", visibilityHandler);

  const entry = listenersByHook.get(instanceId) ?? { queryKey };
  entry.focusCleanup = () => {
    document.removeEventListener("visibilitychange", visibilityHandler);
  };
  listenersByHook.set(instanceId, entry);
};

const cleanupHook = (instanceId: string) => {
  const entry = listenersByHook.get(instanceId);

  if (entry) {
    entry.focusCleanup?.();
    entry.reconnectCleanup?.();
    listenersByHook.delete(instanceId);
  }
};

lifecycle: {
  onMount(context) {
    if (!context.instanceId) return;

    const pluginOptions = context.pluginOptions as RefetchReadOptions | undefined;
    const shouldRefetchOnFocus = pluginOptions?.refetchOnFocus ?? true;

    if (shouldRefetchOnFocus) {
      setupFocusListener(context.instanceId, context.queryKey, context.eventEmitter);
    }
  },

  onUnmount(context) {
    if (context.instanceId) {
      cleanupHook(context.instanceId);
    }
  },
}

Pattern breakdown:

  1. Store cleanup function when setting up listeners
  2. Call cleanup function in onUnmount
  3. Handle multiple listeners with an object/map
  4. Always check instanceId

Example: Timer Cleanup

const timers = new Map<string, NodeJS.Timer>();

lifecycle: {
  onMount(context) {
    if (!context.instanceId) return;

    const timer = setInterval(() => {
      console.log("Polling", context.queryKey);
    }, 5000);

    timers.set(context.instanceId, timer);
  },

  onUnmount(context) {
    if (!context.instanceId) return;

    const timer = timers.get(context.instanceId);

    if (timer) {
      clearInterval(timer);
      timers.delete(context.instanceId);
    }
  },
}

Handling Option Changes

Use onUpdate to react to option changes.

Basic Pattern

lifecycle: {
  onUpdate(context, previousContext) {
    if (previousContext.queryKey !== context.queryKey) {
      console.log("Query changed");
    }

    const prevOptions = previousContext.pluginOptions as MyOptions | undefined;
    const currOptions = context.pluginOptions as MyOptions | undefined;

    if (prevOptions?.interval !== currOptions?.interval) {
      console.log("Interval changed");
    }
  },
}

Example: Polling Interval Change

lifecycle: {
  onUpdate(context, previousContext) {
    if (previousContext.queryKey !== context.queryKey) {
      clearPolling(previousContext.queryKey);
    }

    const pollingInterval = (context.pluginOptions as PollingReadOptions | undefined)?.pollingInterval;

    if (!pollingInterval) {
      clearPolling(context.queryKey);
      return;
    }

    const currentTimeout = timeouts.get(context.queryKey);

    if (!currentTimeout) {
      scheduleNextPoll(context);
    }
  },
}

Common Mistakes

❌ Tracking by queryKey Instead of instanceId

// WRONG
const listeners = new Map<string, () => void>();

lifecycle: {
  onMount(context) {
    const cleanup = setupListener(context.queryKey);
    listeners.set(context.queryKey, cleanup);
  },
}

Problem: Multiple hooks with the same queryKey interfere with each other.

❌ Not Checking for instanceId

// WRONG
lifecycle: {
  onMount(context) {
    trackedInstances.add(context.instanceId);
  },
}

Solution: Always guard

// CORRECT
lifecycle: {
  onMount(context) {
    if (context.instanceId) {
      trackedInstances.add(context.instanceId);
    }
  },
}

❌ Forgetting to Clean Up

// WRONG
lifecycle: {
  onMount(context) {
    setInterval(() => { /* ... */ }, 1000);
  },
}

Solution: Always clean up

// CORRECT
const timers = new Map<string, NodeJS.Timer>();

lifecycle: {
  onMount(context) {
    if (!context.instanceId) return;

    const timer = setInterval(() => { /* ... */ }, 1000);
    timers.set(context.instanceId, timer);
  },

  onUnmount(context) {
    if (!context.instanceId) return;

    const timer = timers.get(context.instanceId);

    if (timer) {
      clearInterval(timer);
      timers.delete(context.instanceId);
    }
  },
}

Best Practices

1. Always Check instanceId

lifecycle: {
  onMount(context) {
    if (!context.instanceId) return;

    // ... rest of logic
  },
}

2. Use Appropriate Data Structure

  • Set<string> for boolean flags
  • Map<string, T> for associated data

3. Store Cleanup Functions

type CleanupFn = () => void;

const cleanups = new Map<string, CleanupFn>();

lifecycle: {
  onMount(context) {
    if (!context.instanceId) return;

    const cleanup = setupResource();
    cleanups.set(context.instanceId, cleanup);
  },

  onUnmount(context) {
    if (!context.instanceId) return;

    const cleanup = cleanups.get(context.instanceId);
    cleanup?.();
    cleanups.delete(context.instanceId);
  },
}

4. Handle Query Changes

lifecycle: {
  onUpdate(context, previousContext) {
    if (!context.instanceId) return;

    if (previousContext.queryKey !== context.queryKey) {
      cleanupOld(previousContext.queryKey);
      setupNew(context.queryKey);
    }
  },
}

On this page