docs / Runtime

effect

Run a side effect when the cells it reads change — and nothing else. Client-only, ordered after render, cleaned up for you.

effect(fn) runs fn once, then re-runs it when any cell it READS changes, and not otherwise. It is the only place side effects belong: the body is not re-executed on unrelated renders, so you never fetch twice or log in a loop by accident.

app/search/page.vsk
									
										component Search() {
  const &[query] = track('')
  const &[results] = track<string[]>([])

  effect(() => {
    const q = peek(query)
    if (q === '') {
      results = []
      return
    }
    fetch('/api/search?q=' + encodeURIComponent(q))
      .then((r) => r.json())
      .then((data) => { results = data.items })
  })

  <input value={query} onInput={(e) => query = e.target.value} />
  <ul>
    for (const item of results) {
      <li>{item}</li>
    }
  </ul>
}
									
								

That peek is deliberate. Reading query with get inside the effect would make the effect depend on it — correct here, but worth knowing which read you are making. Use the plain binding when you DO want to subscribe, peek when you want the value without it.

Effects are client-only

An effect body does NOT run during server rendering. On the server a component renders once, synchronously, to a string — there is no live graph to update and nothing to re-run, so effect bodies are skipped and the component renders with whatever the cells hold at that moment.

This matters for data. Because the effect never runs on the server, the request it makes never happens during SSR, so its result cannot be in the HTML. Anything the FIRST paint needs has to come from useFetch or createResource, which do run on the server. If you put a fetch in an effect, the page arrives empty and fills in on the client.

For work that genuinely belongs to the browser — a timer, a pointer listener, localStorage, the canvas API — being client-only is exactly right. Use {#client} if you need browser APIs during setup itself; reach for on_destroy when you only need teardown.

Ordering is guaranteed

An effect is a block in the same tree as your render blocks, and a flush walks them in a fixed order: pre-effects, then render blocks, then effects. So by the time your effect runs, the DOM it is about to measure has already been updated. You never have to defer work to a microtask to be sure the markup exists.

app/measure/page.vsk
									
										component Measure() {
  const &[label] = track('a fairly long label')
  const &[width] = track(0)
  let box: HTMLElement | null = null

  effect(() => {
    const text = peek(label)
    if (box === null) box = document.querySelector('[data-measure]')
    width = box.getBoundingClientRect().width
  })

  <div data-measure>{label}</div>
  <span>Measured {width}px</span>
}
									
								

Cleanup is the return value

Return a function from the effect body and the runtime calls it before the next run and again when the block is destroyed. You do not compare dependencies to decide whether to tear down — the block re-runs wholesale, so its teardown always runs first. This is the part that makes an effect a complete lifecycle primitive rather than a bare 'when this changes' hook.

app/clock/page.vsk
									
										component Clock() {
  const &[now] = track(new Date())

  effect(() => {
    const handle = setInterval(() => { now = new Date() }, 1000)

    // Runs before each re-run, and when the component is destroyed.
    return () => clearInterval(handle)
  })

  <time>{now.toISOString()}</time>
}
									
								

For teardown that belongs to the component rather than to any particular effect run, on_destroy is the clearer spelling.

app/clock/page.vsk
									
										component Clock() {
  const &[now] = track(new Date())

  on_destroy(() => clearInterval(handle))

  let handle: ReturnType<typeof setInterval> | null = null

  effect(() => {
    handle = setInterval(() => { now = new Date() }, 1000)
  })

  <time>{now.toISOString()}</time>
}
									
								

How this compares

Vesk borrows the good parts of a signal system — you never write a dependency array, and you never leave one behind when you add a new state read. Where it goes further is in what surrounds that.

React useEffect Vesk effect
Dependencies A dependency array you maintain by hand, and a lint rule to police it Whatever the body reads. Add a read, and it is a dependency — no array, no lint, nothing to forget.
Teardown You diff the array yourself and decide whether to clean up Return a function. It runs before every re-run and on destroy. Nothing to decide.
Ordering vs. the DOM Effects are a separate pass; reading freshly rendered DOM needs care Render blocks run before effect blocks in the same flush, so the DOM is already current.
Server rendering Does not run on the server Does not run on the server — and useFetch / createResource are the SSR path, so the first paint does not wait on the client.
Re-runs Re-runs whenever the component re-renders and deps compare unequal Re-runs only when a cell it read changes. An unrelated write elsewhere in the tree cannot reach it.

That last row is the one that shows up in profiles. Because a write only walks the cells downstream of it, a keystroke in one input cannot re-run an effect on the other side of the page. In a VDOM system the component re-renders and your effect re-compares a list of dependencies to find that out.

What not to use it for

  • Fetching data the first paint needs — use useFetch or createResource. They run on the server; effects do not.
  • Deriving a value — use derived. An effect that computes and writes a cell renders twice, and a derived that writes throws.
  • Event handling — use onClick. An effect has no idea WHEN the user meant to act.
  • Reading state once — use peek/untrack, so the code does not subscribe by accident.

Writing a cell the same effect also reads is allowed and does not hang: the effect re-runs once per flush until the condition settles. It is still almost always a logic error — an effect is for reacting to state, not for driving it.