Skip to content

When to Use reactiveScope ​

What reactiveScope does ​

reactiveScope creates a reactive ownership scope. Effects and computed values created inside it are owned by that root and can all be disposed together by calling dispose().

ts
const dispose = reactiveScope(dispose => {
  const count = signal(0)
  effect(() => console.log(count()))
  return dispose
})

dispose()  // stops the effect, cleans up

You usually don't need it inside components ​

Stewie's renderer already executes every component function inside a reactive root. Signals, computed values, and effects created directly in a component body are owned by the component and disposed automatically when it unmounts.

tsx
// ✅ fine — no reactiveScope needed
function Counter() {
  const count = signal(0)
  const doubled = computed(() => count() * 2)

  return (
    <button onClick={() => count.update(n => n + 1)}>
      {doubled}
    </button>
  )
}

Wrapping component-local state in reactiveScope adds ceremony without benefit:

tsx
// ⚠️ unnecessary inside a component
function Counter() {
  let count!: ReturnType<typeof signal<number>>
  reactiveScope(() => { count = signal(0) })

  return <button onClick={() => count.update(n => n + 1)}>{count}</button>
}

The let x!: ...; reactiveScope(() => { x = ... }) pattern also requires TypeScript's definite assignment assertion (!) because the variable is technically unassigned before the callback runs, which is an avoidable awkwardness.

When reactiveScope is the right choice ​

Outside components ​

When setting up reactive state in non-component code — utilities, app initialization, tests — there's no renderer-provided root. reactiveScope is required:

ts
// app entry point
reactiveScope(() => {
  const router = createRouter()
  mount(<App />, document.getElementById('app')!)
})
ts
// utility with its own lifecycle
export function createSession() {
  return reactiveScope(dispose => {
    const user = signal<User | null>(null)
    effect(() => { /* sync to localStorage */ })
    return { user, dispose }
  })
}

An independently disposable nested scope ​

When you deliberately need a sub-scope that can be torn down independently of the component that created it:

ts
function setupPanel() {
  return reactiveScope(dispose => {
    const open = signal(true)
    effect(() => { /* panel-specific side effect */ })
    return { open, dispose }
  })
}

This is legitimate because the intent is an independent lifecycle — not just local state.

In tests ​

Vitest doesn't provide a reactive root. reactiveScope is necessary whenever you create signals, computed values, or effects in test code:

ts
it('computed updates when signal changes', () => {
  reactiveScope(() => {
    const x = signal(1)
    const doubled = computed(() => x() * 2)
    expect(doubled()).toBe(2)
    x.set(5)
    expect(doubled()).toBe(10)
  })
})

The rule of thumb ​

Use reactiveScope for manually managed reactive scopes: app entry points, utilities with their own lifecycle, and tests. Inside normal component functions, create signals and effects directly.

Avoid calling reactiveScope multiple times inside a single component for ordinary state — multiple nested roots are usually a sign that the component is trying to manage lifetimes that the renderer already handles.