Skip to content

Components & JSX ​

Components run once ​

A component is a plain function that returns DOM nodes. It runs once, when you call it. There is no re-render: reactivity comes from the state you use in JSX, which keeps that exact text or attribute in sync.

jsx
import { state } from "qwrk";

function Counter() {
  const count = state(0);

  return (
    <p>
      count: {count}, doubled: {count.value * 2}
    </p>
  );
}

After count.value = 5, the paragraph shows count: 5, doubled: 10. {count} passes the state itself, which always stays live. With the compiler, any expression that reads .value, like {count.value * 2}, stays live too. Without it, such an expression is evaluated once: it would keep showing 0, so use a derive() there.

Props and children ​

Components receive their props as one object, with nested JSX in children:

jsx
function Card({ title, children }) {
  return (
    <section>
      <h2>{title}</h2>
      {children}
    </section>
  );
}

<Card title="Hello">
  <p>Body text</p>
</Card>;

Fragments ​

<>...</> groups elements without a wrapper. A fragment is a native DocumentFragment: appending it moves its children into the parent.

jsx
function App() {
  return (
    <>
      <h1>Title</h1>
      <p>Text</p>
    </>
  );
}

document.getElementById("root").append(<App />);

Events ​

Any on* prop whose value is a function becomes an event listener. The event name is lowercased, so onClick listens for click and onDblClick for dblclick.

jsx
<button onClick={(event) => console.log(event)}>Click me</button>

The compiler delegates bubbling events such as click and input to one listener on the document. See Events.

Attributes ​

JSXDOM
className="a"class="a"
htmlFor="id"for="id"
disabled={true}disabled=""
hidden={false}, null, undefinedattribute removed
data-id={7}data-id="7"
style="color: red"style="color: red"

style also takes an object with camelCase, kebab-case or custom property keys. Numbers get px where CSS needs a unit:

jsx
<div style={{ backgroundColor: "red", width: 16, opacity: 0.5, "--gap": "4px" }} />
// style="background-color: red; width: 16px; opacity: 0.5; --gap: 4px;"

Pass a state to keep an attribute in sync:

jsx
const disabled = state(true);

<button disabled={disabled}>Save</button>;

disabled.value = false; // removes the disabled attribute

Form inputs ​

value, checked and selected are set as properties, so they keep working after the user edits the field:

jsx
const text = state("");

<input value={text} onInput={(e) => (text.value = e.currentTarget.value)} />;

text.value = ""; // clears the input, even after typing

<select value={choice}> selects the matching <option>.

SVG ​

SVG tags such as <svg>, <path> and <circle> are created as SVG elements, so inline icons work:

jsx
<svg viewBox="0 0 24 24" width="24" height="24">
  <circle cx="12" cy="12" r="10" fill="currentColor" />
</svg>

<a>, <title>, <style> and <script> exist in both HTML and SVG, and are always created as HTML, even inside <svg>.

Children ​

  • Strings and numbers render as text, including 0.
  • false, true, null and undefined render nothing.
  • Arrays are flattened, so a plain array's items.map(...) works.
  • A state renders its value and updates in place. It can hold text, a number, an element, a fragment or an array of them:
jsx
const view = state(<p>Loading...</p>);

<div>{view}</div>;

view.value = <strong>Done</strong>; // swaps the element
view.value = [<p>One</p>, <p>Two</p>]; // swaps in both
view.value = null; // clears it

For lists that follow a state, use .map(). For conditions, the compiler makes {show.value && <p />} reactive automatically; without it, use derive().

Limitations ​

Qwrk keeps its core small, so some things are deliberately not there yet:

  • Plain expressions are evaluated once without the compiler. {show.value && <p />} and {todos.value.map(...)} don't update when the state changes. With the compiler they are reactive; without it, wrap conditions in derive(), and render lists with todos.map(...).
  • Effects created outside a derive outlive their component. effect() and .effect() created in a component that no derive renders keep running until you stop them. Inside a derive, or inside another effect's callback, they stop when that one runs again.

Memory ​

There's no unmount step. What a derive renders, including the DOM bindings of a list row, is disposed when the derive runs again or when its list drops it, and disposal drops every subscription at once. For example, every <li> removed from a list stops updating, even when it shows a state the whole app shares.

Detached trees are not tracked: dropping nodes without going through a derive or a list leaves their subscriptions until the states they read are themselves freed, so prefer derives and lists for anything that comes and goes.

A node you keep a reference to, such as an element stored in a state, stays alive and keeps updating, so you can put it back in the page later.

effect() and .effect() created outside a derive are the exception: they live until you stop them, and they keep the states they read alive. Those created while a derive runs, such as in a list item, stop when it runs again. Those created inside another effect's callback stop when it runs again.