Skip to content

Signals

Signals are reactive state containers. When a signal updates, only the parts of the DOM that depend on it re-render. There's no diff, no component re-run, no virtual DOM.

Two flavors

Flexium ships two ways to create signals:

use() — hooks-shaped (value form)

tsx
import { use } from 'flexium/core'

function Counter() {
  const [count, setCount] = use(0)
  return <button onclick={() => setCount(c => c + 1)}>{count}</button>
}
  • count is the current value at the point of render (not a function call).
  • setCount accepts a new value or an updater (prev) => next.
  • Must be called inside a component function (hook rules).

createSignal() — getter form (escape hatch)

tsx
import { createSignal } from 'flexium/core'

const [count, setCount] = createSignal(0)
console.log(count())  // 0   ← getter is a function
setCount(5)
console.log(count())  // 5
  • count is a getter function. Call it to read.
  • Works anywhere — module scope, inside async code, outside components.
  • Per-call cost ~22ns (vs use()'s 550ns runtime / 13ns compile-time).

Use use() for normal components. Drop to createSignal() for hot loops, animation frames, or hundreds of signals per component.

Derived state

The functional form of either creates a derived signal — auto-tracked, lazy, cached:

tsx
const [count, setCount] = use(0)
const [doubled] = use(() => count * 2)        // derives from count
const [isEven]  = use(() => count % 2 === 0)  // derives from count

// or with createSignal
import { createComputed } from 'flexium/core'
const doubledG = createComputed(() => count() * 2)

doubled only re-evaluates when count changes. Equivalent to React's useMemo but with fine-grained tracking — you don't pass a [deps] array, the runtime figures it out.

Effects (side effects)

tsx
import { unsafeEffect } from 'flexium/core'

function Logger() {
  const [count, setCount] = use(0)

  // Runs after every count change. Auto-disposed on unmount.
  unsafeEffect(() => {
    console.log('count is now', count)
  })

  return <button onclick={() => setCount(c => c + 1)}>{count}</button>
}

For non-component contexts, use createEffect(fn) — same behavior, no auto-cleanup (returns dispose function).

Batched updates

Multiple set calls in the same synchronous block coalesce into a single flush:

tsx
function handleClick() {
  setA(1)
  setB(2)
  setC(3)
  // → 1 microtask flush, 1 DOM patch pass for all three
}

For forcing a flush mid-handler:

tsx
import { sync } from 'flexium/core'

function handleClick() {
  setA(1)
  sync()  // flushes pending updates synchronously
  setB(2)  // separate flush
}

Shared state across components

Pass { key } to share a signal across the tree without a Provider:

tsx
function ThemeToggle() {
  const [theme, setTheme] = use<'light' | 'dark'>('light', { key: 'theme' })
  return <button onclick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>{theme}</button>
}

function Header() {
  // Same key — automatically subscribed to the same signal
  const [theme] = use<'light' | 'dark'>('light', { key: 'theme' })
  return <header data-theme={theme}>...</header>
}

When the last component using a key unmounts, the signal is released.

Async resources

use(async fn) creates a Promise-aware signal:

tsx
function UserProfile({ id }) {
  const [user] = use(async () => {
    const res = await fetch(`/api/users/${id}`)
    return res.json()
  })

  // Pause render until promise resolves
  return <h1>{user.name}</h1>
}

// Wrap in Suspense for loading UI
<Suspense fallback={<p>Loading…</p>}>
  <UserProfile id={1} />
</Suspense>

The async function captures signals it reads. When a captured signal changes, the function re-runs.

Tracking rules

  • Reading a signal inside a render function, effect, or derived signal subscribes the consumer.
  • Reading a signal outside any reactive context (e.g., in an event handler) does not subscribe — it's a plain read.
  • setSignal(value) updates the signal and schedules a flush.

Next

Components — JSX, props, children, refs.

Released under the MIT License.