Effects and browser code
A component renders on the server, so its script never runs in the browser. To run code in the browser, use a handler, $effect, $client or a #client block. Each one starts at a different time.
$effect
$effect(callback) runs the callback in the browser when a value that it reads changes:
The sample runs when it comes into view.
- An effect never runs when the page loads. Its first run is after the first change of a value it reads. The server already rendered the page with the current values, so there is nothing to catch up on.
- It never runs on the server.
- It can write signals. After a change, effects run after the page has updated, once each.
- In a hidden
#offscreenbody, it does not run. Its cleanup runs when the body hides, and it runs again when the body shows.
For code that must run when the page loads, use $client.
Cleanup
The callback can return a function. It runs before the next run of the effect, and when the component is removed from the page:
The sample runs when it comes into view.
Stopping writes running, so the effect runs again. Its cleanup clears the timer of the last run first.
Effects depend on the functions they call
An effect depends on every value that it reads. That includes values read in the functions that it calls, and in callbacks that it creates, such as the timer above. This is why the timer uses $untrack(seconds++): without it, the effect would depend on seconds and restart the timer every second.
A function that writes a signal it also reads is the common mistake:
<script>
import { $effect, $signal } from 'brumate/signals';
let query = $signal('');
let saves = $signal(0);
function save() {
// Reads and writes `saves`.
saves++;
}
// Depends on `query`, and on `saves` through `save`.
$effect(() => {
void query;
save();
});
</script>
The effect depends on saves, so each save runs the effect again. Call such functions inside $untrack, which reads without depending on anything inside:
The sample runs when it comes into view.
brumate inspect shows what each effect depends on, as reads signal query.
Values that stay across visits
A preference such as the theme, a dismissed banner or the width of a panel belongs in a cookie. The server reads it, so the first render has the right value, and an $effect writes it when the value changes:
<script>
import { getEvent } from 'brumate/server';
import { $effect, $signal } from 'brumate/signals';
let theme = $signal(getEvent().getCookie('theme') ?? 'light');
$effect(() => {
document.cookie = `theme=${theme}; path=/; max-age=31536000; samesite=lax`;
});
</script>
<div class="page" data-theme=theme>
<button onClick=(() => (theme = theme === 'dark' ? 'light' : 'dark'))>Theme</button>
</div>
- The page renders with the stored value, so nothing changes after it loads.
- The effect never runs on load. It runs on the first change.
- Encode a value that can hold
;,,or spaces withencodeURIComponent, and decode it where you read it. - A server function can set the cookie too, with
event.setCookie, when the server should check the value. See Cookies and sessions.
localStorage and sessionStorage work, but the server cannot read them. The page renders the default first, and the browser has to run code on load to read the stored value, so the page changes after it shows, and every page that does it loads code before anyone interacts. Keep them for values that the first render does not show.
Element references
ref=$bind(name) on an element writes the element into the signal name, so browser code can use it:
The sample runs when it comes into view.
refalways takes$bind. A bare name, as inref=input, is a compile error.- The name must be a
$signaldeclared in the same file, or a prop that the component destructures withletand types asBindable. The prop forwards the element to the caller. - A dynamic tag takes
reftoo, such as<{as} ref=$bind(panel)>. The signal gets the element that the tag renders. When the tag names a component, the component gets the binding as itsrefprop, and writes the element if it forwards it. - A component forwards
refby declaring it as aBindableprop and writingref=$bind(ref)on an element. See Forwarding a ref. - On the server, the signal is
undefined. Read it in handlers, effects and$clientcallbacks. - An element that the browser creates later, such as a new item of a
#for, writes the signal when it is created.
$client
$client(callback, trigger) runs the callback in the browser once, when the trigger fires. It does not wait for a change:
The sample runs when it comes into view.
| Trigger | Runs when |
|---|---|
'visible' |
The element that the callback reads through ref enters the viewport, or else the first element of the component. This is the default. |
'idle' |
The browser is idle after the page loads. |
'load' |
The page loads. |
- The callback can return a cleanup function. It runs when the component is removed from the page.
'load'runs the callback as soon as the page loads, before anyone interacts with it. The compiler warns about it: prefer'visible'or'idle'.- Use
$clientfor browser APIs that the server does not have, such aswindow,localStorageor a chart library. - With
'visible', a callback that reads an element of the same markup throughrefwaits for that element, so a chart low on the page renders when the chart comes into view. See Behaviors of an element.
#client blocks
A #client block skips its body on the server. The browser renders it when the trigger fires, with the same triggers as $client:
The sample runs when it comes into view.
Use it for markup that needs the browser, such as a map or a value from localStorage. The page shows nothing in its place until the block renders.
An #else body renders on the server in place of the body, until the body renders:
The sample runs when it comes into view.
- The fallback can keep the space of the body, so the page does not move when the body renders.
- With
'visible', the block renders when the fallback comes into view. - Without scripts, the fallback stays.
Behaviors of an element
A behavior that belongs to an element, such as a click outside, a field that grows with its text, or a chart, is a plain function that takes the element and returns its cleanup. A component gives it the element with ref and runs it with $client:
// behaviors.ts
export function clickOutside(element: HTMLElement, onOutside: () => void): () => void {
const listener = (event: PointerEvent) => {
if (!element.contains(event.target as Node)) {
onOutside();
}
};
document.addEventListener('pointerdown', listener);
return () => document.removeEventListener('pointerdown', listener);
}
<script>
import { $client, $signal } from 'brumate/signals';
import { autosize, clickOutside } from './behaviors.ts';
let open = $signal(true);
let menu = $signal<HTMLElement>();
let field = $signal<HTMLTextAreaElement>();
$client(() => clickOutside(menu!, () => (open = false)));
$client(() => autosize(field!));
</script>
<div class="menu" ref=$bind(menu) hidden=(!open)>…</div>
<textarea ref=$bind(field)></textarea>
- Each
$clientis a client root of its own, so each behavior runs when its element comes into view. - The cleanup runs when the component leaves the page.
- An element of each item of a
#forgets its own behavior from a script in the body of the#for. - The server renders the element, so a behavior only adds to it, and the element works as plain HTML without scripts.
Which one to use
| You want to | Use |
|---|---|
| Respond to something the user does | A handler, such as onClick |
| Run code when a value changes | $effect |
| Run code when the page loads, or when it comes into view | $client |
| Render markup that needs the browser | #client |
| Reach an element from browser code | ref=$bind(name) |
| Add a behavior to an element | ref=$bind(name) and $client |
Handlers are often enough. A value that a handler writes updates the page by itself, so an effect is only needed for work outside the page, such as saving to localStorage or talking to a library.
Errors in the browser
Code that runs in the browser can throw: a handler, the promise that an async handler returns, an effect, or a $client callback. The error goes to the nearest #try around the scope that the code belongs to, which renders its #catch body with the error and reset:
#try {
<button onClick=(async () => { await save(); })>Save</button>
} #catch (error, reset) {
<p>{String(error)}</p>
<button onClick=reset>Try again</button>
}
- Without a
#tryaround it, the page gets abrumate:errorevent onwindow, witherrorand thescopeof the code indetail, and the error is logged. An app can report it from there. onError(error, info)of the loader receives every error first, such as for a reporting service, also the ones that a#trycatches.info.scopeis the ID of the scope of the code.- A memo that throws, or whose promise rejects, holds the error as its value. Each read of it throws, so the error goes to the nearest
#tryaround the code that read it: an insert, a binding, another memo, an effect or a handler. Code that does not read the memo runs as usual, also in the same write. When the memo computes again without a throw, it holds the value again. A memo that nothing reads keeps its error silently. A memo that computes again into a promise keeps its old error while the promise loads. - On the server the same holds: a memo that throws fails where it is read, and the
#catchbody of a#tryaround the read renders. A memo declared outside the#trystill fails where it is declared. - A handler that throws stops there.