Watchers and Side Effects
The watcher and side effect APIs are part of Qingkuai's reactivity system. They let you register callbacks at different phases of the update scheduler so that related logic can run when reactive values change. Based on when they are triggered, these APIs can be divided into the following groups:
watch,effect: normal registration. Their order relative to the update scheduler is not guaranteed. Earlier registrations run earlier.syncWatch,syncEffect: triggered immediately after a dependent reactive value changes, before the async update scheduler runs.preWatch,preEffect: triggered before the update scheduler. They are suitable for logic that needs to run after state changes but before scheduled updates.postWatch,postEffect: triggered after scheduled updates are complete. They are suitable when you need to wait until state and DOM updates have settled.
Watchers
In the following example, a watcher is registered for the name variable. When its value changes, the callback runs. The callback receives two arguments: the previous value and the current value. Because the watcher is registered before the template rendering side effect, the DOM accessed in the callback is still in its pre-update state:
- js
- ts
<lang-js>
import { watch } from "qingkuai"
let paragraph
let name = "JavaScript"
watch(
() => name,
(pre, cur) => {
console.log(pre, cur) // JavaScript Qingkuai
console.log(paragraph.textContent) // name is: JavaScript
}
)
</lang-js>
<p &dom={paragraph}>name is: {name}</p>
<button @click={name = "Qingkuai"}>Change Name</button>
<lang-ts>
import { watch } from "qingkuai"
let name = "JavaScript"
let paragraph!: HTMLParagraphElement
watch(
() => name,
(pre, cur) => {
console.log(pre, cur) // JavaScript Qingkuai
console.log(paragraph.textContent) // name is: JavaScript
}
)
</lang-ts>
<p &dom={paragraph}>name is: {name}</p>
<button @click={name = "Qingkuai"}>Change Name</button>
Pre-Watchers
A watcher registered synchronously through watch inside an embedded language tag is registered earlier than the template rendering side effect, so its callback runs before the template updates. If the watcher is registered in async logic, that order is no longer guaranteed. In that case, use preWatch to ensure that the callback runs before the template update:
- js
- ts
<lang-js>
import { preWatch } from "qingkuai"
let paragraph
let name = "JavaScript"
preWatch(
() => name,
(pre, cur) => {
console.log(pre, cur) // JavaScript Qingkuai
console.log(paragraph.textContent) // name is: JavaScript
}
)
</lang-js>
<p &dom={paragraph}>name is: {name}</p>
<button @click={name = "Qingkuai"}>Change Name</button>
<lang-ts>
import { preWatch } from "qingkuai"
let name = "JavaScript"
let paragraph!: HTMLParagraphElement
preWatch(
() => name,
(pre, cur) => {
console.log(pre, cur) // JavaScript Qingkuai
console.log(paragraph.textContent) // name is: JavaScript
}
)
</lang-ts>
<p &dom={paragraph}>name is: {name}</p>
<button @click={name = "Qingkuai"}>Change Name</button>
Post-Watchers
A post-watcher is the opposite of a pre-watcher. It runs after scheduled updates have finished, so it is suitable for logic that needs to wait until the state is stable or the DOM has been updated:
- js
- ts
<lang-js>
import { postWatch } from "qingkuai"
let paragraph
let name = "JavaScript"
postWatch(
() => name,
(pre, cur) => {
console.log(pre, cur) // JavaScript Qingkuai
console.log(paragraph.textContent) // name is: Qingkuai
}
)
</lang-js>
<p &dom={paragraph}>name is: {name}</p>
<button @click={name = "Qingkuai"}>Change Name</button>
<lang-ts>
import { postWatch } from "qingkuai"
let name = "JavaScript"
let paragraph!: HTMLParagraphElement
postWatch(
() => name,
(pre, cur) => {
console.log(pre, cur) // JavaScript Qingkuai
console.log(paragraph.textContent) // name is: Qingkuai
}
)
</lang-ts>
<p &dom={paragraph}>name is: {name}</p>
<button @click={name = "Qingkuai"}>Change Name</button>
Synchronous Watchers
The callbacks of watch, preWatch, and postWatch are all triggered asynchronously. If you need synchronous execution, use syncWatch:
<lang-js>
import { syncWatch } from "qingkuai"
let name = "JavaScript"
function handleChangeName() {
name = "Qingkuai" // logs: JavaScript Qingkuai
}
syncWatch(
() => name,
(pre, cur) => {
console.log(pre, cur)
}
)
</lang-js>
<p>name is: {name}</p>
<button @click={handleChangeName}>Change Name</button>
Convenience Registration
In standard watcher registration, the first argument must be a getter function that returns the value being observed. This is slightly verbose for simple expressions. To address that, the compiler provides a group of convenience registration methods similar in spirit to derivedExp: watchExp, preWatchExp, postWatchExp, and syncWatchExp. The compiler automatically converts the first argument of these methods into a getter function, so you can pass an expression directly:
// Normal watcher registration
watchExp(identifier, (pre, cur) => {
console.log(pre, cur)
})
// Register a pre-watcher
preWatchExp(identifier, (pre, cur) => {
console.log(pre, cur)
})
// Register a post-watcher
postWatchExp(identifier, (pre, cur) => {
console.log(pre, cur)
})
// Register a synchronous watcher
syncWatchExp(identifier, (pre, cur) => {
console.log(pre, cur)
})
Side Effects
Unlike watchers, effect only accepts a callback. Dependency collection and reactive logic are combined into one place: the reactive values accessed while the callback runs are collected automatically as dependencies, and the callback runs again whenever any of them changes. In the following example, the effect callback accesses userId, so every time userId changes, a new request is sent and the user information is updated:
- js
- ts
<lang-js>
import { effect } from "qingkuai"
let userId = 0
let userInfo = null
effect(async () => {
const response = await fetch(`https://example.com/user/info/${userId}`)
userInfo = await response.json()
})
</lang-js>
<qk:spread #if={userInfo}>
<p>User id: {userInfo.id}</p>
<p>User name: {userInfo.name}</p>
</qk:spread>
<lang-ts>
import { effect } from "qingkuai"
interface UserInfo {
id: number
name: string
}
let userId = 0
let userInfo: UserInfo | null = null
effect(async () => {
const response = await fetch(`https://example.com/user/info/${userId}`)
userInfo = await response.json()
})
</lang-ts>
<qk:spread #if={userInfo}>
<p>User id: {userInfo.id}</p>
<p>User name: {userInfo.name}</p>
</qk:spread>
The side effect APIs also provide registration methods for different trigger timings:
preEffect(() => {})
postEffect(() => {})
syncEffect(() => {})
Cleaning Up Watchers and Side Effects
Manual Cleanup
Watcher and side effect registration methods all return a control handle object with the following type:
type EffectHandlers = Record<"stop" | "pause" | "resume", () => void>
These three methods are used to stop, pause, and resume a watcher or side effect:
const effectHandlers = effect(() => {
// effect logic ...
})
effectHandlers.stop() // stop and clean up the side effect
effectHandlers.pause() // pause the side effect
effectHandlers.resume() // resume the paused side effect
const watchHandlers = watchExp(identifier, (pre, cur) => {
// watch logic ...
})
watchHandlers.stop() // stop and clean up the watcher
watchHandlers.pause() // pause the watcher
watchHandlers.resume() // resume the paused watcher
In some cases, a watcher or side effect needs to run cleanup logic before it runs again. For example, if it registers a timer, that timer should be cleared before the next trigger to avoid memory leaks or logic errors. In that case, wrap the cleanup logic in a function and return it from the callback:
- js
- ts
let timer
watchExp(identifier, (pre, cur) => {
timer = setTimeout(() => {
// do something ...
}, 1000)
return () => clearTimeout(timer) // runs before the watcher triggers again
})
let timer: number
watchExp(identifier, (pre, cur) => {
timer = window.setTimeout(() => {
// do something ...
}, 1000)
return () => clearTimeout(timer) // runs before the watcher triggers again
})
Automatic Cleanup
Watchers and side effects registered synchronously inside a component are automatically linked to the component's destruction lifecycle. When the component is destroyed, these registrations are cleaned up by the framework automatically — no need to call stop() manually:
import { effect, watch } from "qingkuai"
// Registered synchronously in a component; auto-cleaned on destruction
effect(() => {
// ...
})
watchExp(someValue, (pre, cur) => {
// ...
})
If a watcher or effect is registered asynchronously, it is no longer managed by the component's destruction lifecycle and must be cleaned up manually by calling stop() on the returned handle inside onBeforeDestroy or onAfterDestroy:
import { effect, onBeforeDestroy } from "qingkuai"
let handle
setTimeout(() => {
// Registered asynchronously, not tracked by component destruction
handle = effect(() => {
// ...
})
}, 1000)
// Manually stop before the component is destroyed
onBeforeDestroy(() => {
handle?.stop()
})
Passive Cleanup
If a watcher or side effect callback collects no reactive dependencies during execution, the runtime emits a warning and destroys that registration automatically. Once destroyed, all resources (including memory) held by the effect are released because it will never be triggered again:
import { effect, watch } from "qingkuai"
effect(() => {
// The callback does not read any reactive value
console.log("No dependencies, will be destroyed after this run")
})
watch(
() => "constant",
(pre, cur) => {
// The getter returns a constant, no reactive link is established
console.log("This watcher will also be destroyed")
}
)
This usually means the callback did not read reactive values, or dependency reads were skipped by control flow:
import { effect } from "qingkuai"
let flag = true
let value = reactive("hello")
effect(() => {
// When flag is true, only returns a constant without reading any reactive value
if (flag) {
console.log("no reactive deps")
return
}
console.log(value) // This line is never reached
})