Skip to content

feat: add Hook.useEventListener - #84

Open
OnurGumus wants to merge 1 commit into
fable-hub:mainfrom
OnurGumus:feat/use-event-listener
Open

OnurGumus wants to merge 1 commit into
fable-hub:mainfrom
OnurGumus:feat/use-event-listener

Conversation

@OnurGumus

Copy link
Copy Markdown
Contributor

Adds Hook.useEventListener(target, name, handler). It listens to an event on any EventTarget (document, window, an element) for as long as the component is connected, and stops when it disconnects.

Hook.useEventListener(document, "keydown", fun (ev: KeyboardEvent) ->
    if ev.key = "Escape" then setOpen false)

Without it, every component that listens to document or window writes the same useEffectOnce with an addEventListener and a matching removeEventListener in the disposable. And the obvious version of that has a bug.

Two bugs it avoids

  • Stale handler. Subscribing to handler once and never re-subscribing keeps the closure from the render that connected the component, so a handler that reads state keeps seeing the state from that first render.
  • Churn. Re-subscribing whenever the handler changes fixes that, but costs an add and a remove on the DOM every render, because a lambda written in a template is a new object each time.

So the listener is added once and calls the handler through a ref that every render updates. That gives one subscription for the life of the component, and the handler always sees the latest render.

Tests

One test in HookTest.fs covers both cases. A component counts events into its own state and into a module-level counter it doesn't own. I broke the hook on purpose to check that the test catches each bug:

  • With the ref update removed, it fails with "1" to equal "2": the stale closure.
  • With the removeEventListener taken out, it fails with 3 to equal 2: a listener that outlives its component.

The component is rendered away, not detached from the DOM, because lit disconnects a directive when a render stops including it, not when its DOM is removed.

dotnet build Fable.Lit.sln and npm test pass (38 tests). The example added to docsrc/docs/hook-components.md compiles.

🤖 Generated with Claude Code

Listens to an event on any EventTarget for as long as the component is
connected, and stops when it disconnects.

It avoids two bugs that pull in opposite directions. Subscribing to the
handler itself and never re-subscribing keeps the closure from the render
that connected the component, so a handler that reads state keeps answering
with the state it was bound with. Re-subscribing whenever the handler
changes fixes that, but costs an add and a remove on the DOM every render,
because a lambda written in a template is a new object each time. So the
listener is added once and reads the handler through a ref that every
render refreshes.

The test covers both: with the ref refresh removed it fails with "1" where
"2" was expected (a stale closure), and with the removal taken out of the
disposable it fails with 3 calls where 2 were expected (a leaked listener).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@JordanMarr JordanMarr added the after-2.0 Considered after the 2.0 maintenance release (see #90) label Oct 19, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

after-2.0 Considered after the 2.0 maintenance release (see #90)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants