Effects + targets

Effects are non-visual nodes that react to lifecycle, time, visibility, and external events.

What effects are

Effects are non-visual nodes that react to changes over time or outside the render tree. They live alongside your UI nodes in the IR, but they do not produce DOM.

This makes effects easy to reason about: they are part of the same tree, follow the same lifecycle, and can be mounted, updated, or removed together with the UI that depends on them.

Core effect types

Thorm includes effect builders for the most common reactive patterns. They let you declare background work without leaving the IR tree.

  • onMount(...) runs actions once after the UI mounts; good for bootstrapping data or timers.
  • watch(expr, actions) reacts to expression changes; ideal for derived state, autosave, or debounced work.
  • every(ms, actions) runs actions on a fixed interval; use for polling or counters.
  • after(ms, actions) runs a one-shot delayed action; useful for timeouts or deferred UI updates.
  • onVisible(...) reacts when a target enters the viewport; great for lazy loading or analytics.
  • onLeftViewport(...) reacts when a target leaves the viewport; useful for cleanup or pause logic.

Effects are composable. You can mount multiple effects next to a UI subtree, and each effect can update state or trigger side effects.

Event and stream effects

Beyond lifecycle and timing, Thorm lets you react to DOM events and streamed data via effect triggers.

  • onWindow(event, actions) listens to window events like resize or scroll.
  • onDocument(event, actions) listens at document scope; useful for global key handlers.
  • onSelf(event, actions, options, target) attaches to a specific element; good for form submit or custom input handling.
  • onSse(url, actions, event, parse, ...) listens to Server-Sent Events; ideal for live feeds and streaming updates.
  • effect(triggers, actions, target) is the low-level builder when you need custom trigger combinations.

These effects let you bind UI behavior to the environment: window, document, specific elements, or streaming endpoints.

Common browser events you can attach

You can listen to any standard DOM event by passing its name into onWindow(), onDocument(), or onSelf(). The list below is a starting point, not a complete catalog.

  • Window: resize, scroll, focus, blur, online, offline, popstate, hashchange
  • Document: keydown, keyup, visibilitychange, click
  • Element: input, change, submit, pointerdown, pointerup, mouseenter, mouseleave, animationend

If the browser fires the event, you can likely attach it here. Use targets to scope it and keep listeners predictable.

Option A example: listen to window resize and keep an atom in sync with the current width. This is a minimal, copy-paste friendly setup for your own tests.

PHP

<?php
$winW 
state(0);

$app fragment([
    
el('div', [attrs(['class' => 'container p-3'])], [
        
el('h1', [], [ text('Effect: window resize') ]),
        
el('p', [], [ text(concat('Window width: 'read($winW))) ]),
        
el('p', [cls('text-muted')], [ text('Resize the window to see updates.') ]),
    ]),

    
// EFFECT: listen to window resize and update winW
    
onWindow('resize', [
        
set($winWev('target.innerWidth'), true)
    ])
]);

Another Example: window scroll demo

Targets and scope

Some effects need a target: the window, the document, or a specific element. Targets make effects precise and predictable.

Use the narrowest target you can. It keeps listeners scoped and makes cleanup safe when nodes are removed.

Patterns that scale

  • Use every() to build timers, polling loops, and heartbeat counters.
  • Use watch() to keep derived atoms in sync or trigger background work when state changes.
  • Combine watch() with debounce/throttle for expensive operations (search, analytics, autosave).
  • Use onVisible() with selectorTarget() for lazy loading or prefetch on scroll.

Effects are most powerful when they are isolated and declarative. They should read input expressions and emit actions, without reaching into unrelated state.

Example: interval-driven state

This example uses every() to update an atom once per second. The UI reads $tick and updates automatically.

The important idea is that the timer is declared inside the IR tree. There is no manual setInterval to manage, and cleanup is tied to the node lifecycle.

PHP

<?php
$tick 
state(0);

$app fragment([
    
el('div', [attrs(['class' => 'container p-3'])], [
        
el('h1', [], [ text('Effect: interval + inc') ]),
        
el('p', [], [ text(concat('Tick = 'read($tick))) ]),
        
el('p', [cls('text-muted')], [ text('Should increment once per second.') ]),
    ]),

    
// EFFECT: every 1000ms increment tick
    
every(1000, [ inc($tick1true) ]), // asAction = true
]);

Example: watch with debounce and throttle

This example uses watch() to react to changes in $count and update a derived atom ($label). The watch() call subscribes to an expression (read($count)), and whenever that expression changes it runs a list of actions. Here, the action is set($label, concat('Count: ', read($count)), true).

Debounce and throttle shape how often the effect runs. Debounce waits until changes settle, throttle limits how often updates can happen. In this example the sliders change debounceMs and throttleMs at runtime, so you can feel the difference between β€œwait for quiet” vs β€œlimit frequency.”

The result is clean separation: button clicks only change $count, while watch() handles all derived updates. That keeps event handlers simple and moves reactive behavior into one place.

PHP

<?php
$count      
state(0);
$label      state('idle');
$delay      state(300);
$throttle   state(500);

$app fragment([
    
el('div', [attrs(['class' => 'container p-3'])], [
        
el('h1', [], [ text('Effect: watch + set (debounced)') ]),
        
el('div', [ cls('row') ], [
            
el('label', [ attrs([ 'for' => 'debounce' ]) ], [ 
                
el('a', [
                    
attrs([
                        
'href'  => 'https://developer.mozilla.org/en-US/docs/Glossary/Debounce',
                        
'rel'   => 'nofollow',
                        
'target' => '_blank',
                    ]),
                ], [
text('Debounce')]),
             ]),
            
el('input', [ 
                
attrs([
                    
'id'    => 'debounce'
                    
'type'  => 'range'
                    
'min'   => 0
                    
'max'   => 3000,
                    
'value' => read($delay)
                ]),
                
on('change'set($delayev('target.value'))),
             ], []),
        ]),
        
el('div', [ cls('row') ], [
            
el('label', [ attrs([ 'for' => 'throttle' ]) ], [ 
                
el('a', [
                    
attrs([
                        
'href'  => 'https://developer.mozilla.org/en-US/docs/Glossary/Throttle',
                        
'rel'   => 'nofollow',
                        
'target' => '_blank',
                    ]),
                ], [
text('Throttle')]),
            ]),
            
el('input', [ 
                
attrs([
                    
'id'    => 'throttle'
                    
'type'  => 'range'
                    
'min'   => 0
                    
'max'   => 3000,
                    
'value' => read($throttle)
                ]),
                
on('change'set($throttleev('target.value'))),
             ], []),
        ]),
        
el('p', [], [ text(concat('Count = 'read(\$count))) ]),
        
el('p', [], [ text(concat('Label = 'read(\$label))) ]),
        
el('div', [cls('row gap-2 my-2 d-flex justify-content-center')], [
            
el('button', [
                
cls('btn btn-primary col-4'),
                
on('click'inc($count1)) // normal Listener path (props)
            
], [ text('Inc') ]),
            
el('button', [
                
cls('btn btn-secondary col-4'),
                
on('click'inc($count, -1))
            ], [ 
text('Dec') ]),
        ]),
        
el('p', [cls('text-muted')], [
            
text(concat('Clicking Inc/Dec quickly should update Label after 'read($delay), 'ms debounce.'))
        ]),
    ]),

    
// EFFECT: watch $count; update $label to "Count: <n>" (debounced 300ms)
    
watch(
        
read($count),
        [ 
set($labelconcat('Count: 'read($count)), true) ], // <- asAction = true
        
immediatetrue,
        
debounceMsread($delay),
        
throttleMsread($throttle),
    ),
]);

SSR note

Effects are intended to run on the client after hydration. During SSR, the UI should render based on initial atom values, while effects remain inert.

Because SSR behavior has not been fully tested in this setup, treat effect execution as client-only and verify your pages if you rely on SSR in production.

Practical guidance

  • Keep effects focused: one effect should do one job and update a small number of atoms.
  • Use debounce/throttle to avoid overloading expensive workflows.
  • Prefer selectorTarget() when you can scope an effect to a specific element.

Effects are the bridge between your UI and the outside world. Declare them explicitly and keep them close to the nodes they serve.