Skip to content

Scope & Lifecycle

A scope is a lifecycle boundary for observable state, effects, and cleanup. Use it when state belongs to one component or one custom hook.

With the Vite or Babel plugin enabled, the "use scope" directive is compiled to useScope(...).

import { observable, observe, onMount, onUnmount } from "@usels/core";
function Counter() {
"use scope";
const count$ = observable(0);
const increment = () => count$.set((count) => count + 1);
observe(() => {
document.title = `Count ${count$.get()}`;
});
onMount(() => {
console.log("mounted");
});
onUnmount(() => {
console.log("unmounted");
});
return <button onClick={increment}>{count$.get()}</button>;
}

The observable and functions are created once for the component mount. Scope effects are disposed when the component unmounts.

When a scoped component needs reactive prop tracking, keep the component props as an identifier and pass that identifier to toObs().

import { observable, observe, toObs } from "@usels/core";
type EditableTitleProps = {
value: string;
onCommit: (value: string) => void;
};
function EditableTitle(props: EditableTitleProps) {
"use scope";
const props$ = toObs(props, { onCommit: "function" });
const draft$ = observable(props.value);
observe(() => {
draft$.set(props$.value.get());
});
const commit = () => {
props$.onCommit.get()(draft$.peek());
};
return (
<input
value={draft$.get()}
onChange={(event) => draft$.set(event.currentTarget.value)}
onBlur={commit}
/>
);
}

Inside the scope body, props.value is a latest-value read that does not track. props$.value.get() is reactive and re-runs the observe() callback when the parent passes a new value.

Use hints for non-plain props such as functions, opaque objects, and React elements. In the example, { onCommit: "function" } keeps the callback as a function value inside the observable props object.

The get() utility from @usels/core normalizes DeepMaybeObservable values, but it is a plain function call. The Babel plugin only detects .get() method calls (MemberExpression) to create reactive Memo boundaries. Using get(props).field in JSX will not trigger autoWrap — no fine-grained reactivity.

toObs() converts props into an Observable object, so you can use .get() method calls that the plugin detects:

// ❌ get() function — plugin cannot detect, no Memo boundary
<h1>{get(props).title}</h1>
// ✅ toObs() + .get() method — plugin auto-wraps
"use scope"
const props$ = toObs(props);
<h1>{props$.title.get()}</h1>

Use useScope directly when you need explicit props handling or when a build pipeline cannot use the directive transform.

import { observable, observe, toObs, useScope } from "@usels/core";
function useThemeSync(props: { theme: string }) {
return useScope((p) => {
const props$ = toObs(p);
observe(() => {
document.documentElement.dataset.theme = props$.theme.get();
});
return {};
}, props);
}

Plain p.theme reads always give the latest value without tracking. Use toObs(p) when the scope needs reactive prop tracking.

Inside a scope, use the scope-aware lifecycle APIs from @usels/core:

NeedAPI
DOM measurement before paintonBeforeMount()
Work after mountonMount()
Cleanup on unmountonUnmount()
Reactive propstoObs()

For reactive side effects inside a scope, see Effects API — the canonical reference for observe(), watch(), and whenever().

Inside a scope, prefer create* primitives over use* hook wrappers — the scope already owns lifecycle, so the hook rules do not apply. See use* vs create* for the full selection rule and examples.