refui
Use when working with rEFui (refui) applications where you cannot rely on reading the library source. Covers the retained-mode + signals mental model, DOM/HTML/Reflow renderers, JSX setup (classic pra
SKILL.mdskills/refui/SKILL.md
GitHubrEFui
Overview
Apply rEFui’s retained-mode + signals model correctly, choose the right JSX mode and renderer, and fix reactivity or lifecycle issues without importing assumptions from other UI frameworks. This file is the operative guide: do not require repository documentation, a linked local checkout, MCP documentation, or the bundled reference files to complete ordinary rEFui work.
Core Pitfalls (skim)
- JSX is evaluated once;
{signal.value}in JSX is static. Use{signal}or a derived$(() => ...). - In-place array/object mutation requires
sig.trigger()(or replace with a new value). - A reactive scope that writes a signal it also reads can schedule itself repeatedly. Audit this as a possible feedback loop, not an automatic error: guarded or convergent updates may be intentional.
- Context values are stable; provide signals in context if consumers must react.
- Passing a signal/computed directly to rEFui control-flow is correct:
<If condition={sig}>and<If condition={computed}>are intended usage. The pitfall is conditional.valuereads inside JS/derived code that skip later dependencies. - Prefer lazy
Ifbranches when setup and cleanup should follow branch visibility:<If condition={flag}>{() => <Page />}</If>. Eager children are valid when their earlier evaluation and parent ownership are intentional. Ifconsumes its first two children as true/false branches. To show several siblings in one branch, return one Fragment from one function; do not pass those siblings as separate children.
General guide
Mental model (retained mode)
- Component bodies are setup: they run once; they do not “re-render”.
- JSX is evaluated once; signals update the already-built UI incrementally.
- If something “doesn’t update”, you almost always read
.valuetoo early (non-reactively) or mutated in place withouttrigger().
Signals & reactivity
- State:
const count = signal(0) - Reactive JSX:
{count}(not{count.value}) - Derived:
const label = $(() =>Count: ${count.value})and then{label} - In-place mutation: call
sig.trigger()after mutating arrays/objects.
import { signal, $ } from 'refui'
const Counter = () => {
const count = signal(0)
return (
<button on:click={() => count.set(count.value + 1)}>
{$(() => `Count: ${count.value}`)}
</button>
)
}
Effects & cleanup
- Reactive effect:
watch(() => { ...reads signals... }) - Setup/cleanup:
useEffect(() => { ...; return () => cleanup }) - Teardown-only:
onDispose(() => cleanup) EffectScopeis the ownership basis for components, effects, and disposer scopes. Work created inside a live scope is automatically released with that owner.connect(signals, callback)andsignal.connect(callback)subscribe only to the explicitly supplied signals. Reads inside their callbacks are intentionally untracked; createwatch()explicitly when a callback needs discovered dependencies.useActionlisteners are event handlers, not reactive effects. Signal reads and writes inside a listener do not subscribe the listener; listener registration is still disposed with its outer scope.- Review writes to a scope's own signal dependencies for repeated scheduling and termination. A guard, stable value, or convergent update can make this intentional. Warn when the intent is unclear; do not block or rewrite an understood design, or keep repeating an acknowledged warning.
peek/untrackreads and deferred event callbacks have different tracking semantics. - Scheduling: if you need “after updates applied”,
await nextTick().
Control flow components
- Conditional UI:
<If condition={cond}>{() => <Then />}{() => <Else />}</If> - Branch functions defer evaluation into the selected branch's scope. Direct children are evaluated before
If; their setup can belong to the enclosing owner and outlive branch visibility. Choose the form that matches the intended lifetime, accounting for the renderer and JSX/HMR mode. - Two branch children mean true and false, not two true-branch siblings. Use
<If condition={cond}>{() => <><First /><Second /></>}</If>when both belong to the true branch. - Lazy branches are a recommendation, not a hard requirement. Preserve intentional eager construction; discuss the ownership tradeoff only when it matters to the observed behavior.
- Lists:
<For entries={items} track="id">{({ item, index }) => ...}</For>; its child is an item method, not a component boundary. - Inline dynamic subtree with lifecycle:
<Fn ctx={something}>{(ctx) => ...}</Fn> Forhas nofallback; for empty states, wrap with<If>.- If the condition already exists as a signal/computed, pass it directly. Do not “fix”
<If condition={someSignal}>into extra.valueplumbing.
Memoization and retained subtrees
memo(fn)caches only the immediate result of the firstfn(...)call in its captured owner scope.- Components are two-stage:
Page(props)returns a render function, then that render function receives the concrete renderer and produces host nodes. Thereforememo(Page)caches the page setup result, not its concrete rendered node. - If a
memo(Page)value is removed fromDynamic, its mount-specific render scope is disposed. Re-selecting it uses the same cached setup result but creates a fresh mounted subtree. A setup-created keyedFormust rebuild its row cache for that new mount. useMemo(fn)is the factory form for defining the helper outside a component and obtaining a correctly scopedmemo(fn)inside each component instance.keepAlive(Page)caches both the setup result and the first concrete renderer result. Removing it fromDynamicdetaches the node while its effects, subscriptions, and keyedForcache remain live; re-selecting it reattaches the exact same node.useKeepAlive(Page)is the factory form for defining a keep-alive helper outside components. Invoke the returned factory once inside each intended owner; every invocation creates an independently owned retained component template rather than sharing a concrete node globally.- Create
keepAliveinside the scope that should own the retained subtree. Disposing that owner must dispose the retained subtree and every nested effect or row scope. - A kept-alive result becomes renderer-specific on first mount. Do not mount the same retained node concurrently in multiple parents or move it between different renderers.
- Use
memofor one-time computation/setup andkeepAliveonly for intentional keep-alive UI. Neither is a substitute for correct signal tracking.
import { Dynamic, keepAlive, signal } from 'refui'
const current = signal(null)
const App = () => {
const PlayerPage = keepAlive(Player)
current.value = PlayerPage
return <Dynamic is={current} />
}
import { Dynamic, signal, useKeepAlive } from 'refui'
const preparePlayerPage = useKeepAlive(Player)
const App = () => {
const current = signal(null)
const PlayerPage = preparePlayerPage()
current.value = PlayerPage
return <Dynamic is={current} />
}
import { signal, $, If, For } from 'refui'
const App = () => {
const items = signal([{ id: 1, name: 'A' }])
return (
<If condition={$(() => items.value.length)}>
{() => <For entries={items} track="id">{({ item }) => <div>{item.name}</div>}</For>}
{() => <div>Empty</div>}
</If>
)
}
Async UI
<Async future={promise} fallback={...} catch={...}>for a single promise boundary. Its resolved child receives{ result }.<Suspense>groups multiple async descendants under one fallback.lazy(() => import(...))caches component-module resolution; pass a symbol name for named exports and pair it with an async fallback boundary.Transitionretains the current result while coordinating pending, leaving, entering, and entered state for the next result.- Promise components are explicit delayed boundaries and retain their component scope until resolved or disposed.
- Dependencies are collected only during synchronous effect execution. Reads after
awaitare not tracked unless code is deliberately restored into a captured/frozen context. - Dispose asynchronous work: abort fetches or guard late completion so a resolved promise cannot mutate an already-disposed owner.
Context
Use context for shared subtree values. If consumers must react to changes, provide a signal as the context value.
import { signal, $, createContext, useContext } from 'refui'
const Theme = createContext(signal('light'), 'Theme')
const Button = () => {
const theme = useContext(Theme)
return <button class:dark={$(() => theme.value === 'dark')}>OK</button>
}
Non-Reflow/custom renderer: wrap Provider children in a function so they inherit context: <Theme value={x}>{() => <Button />}</Theme>.
JSX modes and renderers
- Automatic JSX uses
jsx: 'automatic'plusjsxImportSource: 'refui'. Components normally return JSX directly; the automatic runtime creates renderer-agnostic Reflow functions that a concrete renderer resolves later. - Classic JSX uses
jsxFactory: 'R.c'andjsxFragment: 'R.f'(or file pragmas). Components normally return(R) => JSX, making concrete renderer selection explicit. - DOM renderer (interactive browser): create
createDOMRenderer(defaults)once at the entry point, then callrenderer.render(target, App). - HTML renderer (retained SSR/SSG tree): create
createHTMLRenderer(), produce a node, and callserialize(node). The node remains reactive while its owner is live, but each serialized string is only a snapshot. - Reflow is not a host DOM. It stores renderer-agnostic creation functions until DOM, HTML, or a custom renderer resolves them.
- Do not assume DOM APIs in renderer-neutral components. The only generally allowed host behavior is what the selected renderer contract exposes.
- Custom renderers may provide
getParent(node)and own ordinary-node parentage. Without it, the core tracks parentage. A renderer that owns parents must move already-parented ordinary nodes consistently inappendNodeandinsertBefore.
import { createDOMRenderer } from 'refui/dom'
import { defaults } from 'refui/browser'
createDOMRenderer(defaults).render(document.getElementById('app'), App)
DOM directives (browser preset)
- Events:
on:click={fn}(+on-once:*,on-passive:*,on-capture:*) - Classes/styles:
class:active={boolOrSignal},style:color={valueOrSignal} - Attributes vs props:
attr:*(SVG/read-only),prop:*(force property write) - Macros:
m:*for reusable DOM behaviors (renderer-registered handlers)
Default Policy: Use rEFui Built-ins First
When implementing a requirement, prefer rEFui’s built-in primitives (signals/components/extras/renderers) over custom plumbing. Only fall back to a custom implementation when:
- rEFui has no built-in primitive that matches the requirement, and
- the project’s rEFui version lacks an equivalent helper, and
- you can’t express it cleanly as a DOM macro (
m:*) or small reusable component.
Quick Triage (do this first)
- Identify JSX mode in the target repo:
- Automatic runtime: look for
jsx: 'automatic'+jsxImportSource: 'refui'(Vite/esbuild) orjsxImportSource: "refui"(tsconfig/Bun). - Classic transform: look for
jsxFactory: 'R.c'+jsxFragment: 'R.f'(Vite/esbuild) or/** @jsx R.c */file pragmas.
- Automatic runtime: look for
- Identify the host renderer:
- Browser apps:
createDOMRenderer(defaults)fromrefui/dom+refui/browser(orrefui/presets/browserin older repos). - SSR/SSG:
createHTMLRenderer()fromrefui/html, thenserialize(). - Reflow logic-only modules:
refui/reflow(often injected viajsxInject: import { R } from 'refui/reflow'in classic mode).
- Browser apps:
- Confirm the installed rEFui version from
package.jsonor the lockfile. Match the import paths and APIs already used by the project; if an API described here is absent from the installed package, do not invent it.
The optional scripts/refui-audit.mjs <project-root> command reports advisory JSX/If findings and possible signal feedback in recognizable reactive callbacks. Its syntax checks use the target project's or current workspace's installed @swc/core, including its binding metadata. Signal feedback checks cover locally declared signals, inline reactive callbacks, direct .value/get/touch reads, and assignments/set/trigger writes, plus explicit connect sources. Signal aliases, indirect helper calls, named callbacks, and cross-module dependencies still need manual review. When the parser is unavailable, report that coverage gap and use the project's authoritative parser if further investigation is needed. Findings never require a zero-warning gate.
When Usage Is Unclear (optional documentation lookup)
This skill remains sufficient for ordinary rEFui work. When an API, version-specific behavior, or repository-level detail is still ambiguous after checking the installed package version and exports, use external documentation lookup when those tools are available:
- Use Context7 MCP for current library API documentation and examples:
- Resolve the library with
mcp__context7__resolve-library-idandlibraryName: "refui". - Query the specific API or behavior with
mcp__context7__query-docsrather than requesting a broad overview.
- Resolve the library with
- Use DeepWiki MCP for repository-level architecture or implementation questions:
- Call
mcp__deepwiki__read_wiki_structure, thenmcp__deepwiki__ask_questiononSudoMaker/rEFui, such as to find where a behavior is implemented or documented.
- Call
Treat lookup results as supporting evidence, not a substitute for the target project's installed version or a public-API reproduction. If the tools are unavailable, continue from this guide and the installed package; do not require a linked local document.
Feature selection
- State that changes:
signal; derived state:$/computed; reactive work:watch; setup with returned cleanup:useEffect; teardown only:onDispose. - Conditional replacement:
If; keyed lists:For; positional list reuse:UnKeyed; inline replacement scope:Fn; changing component/tag:Dynamic; explicit component instance:createComponentplusRender. - One-time function result:
memo, oruseMemofor a module-level factory; retained concrete subtree across detach/reattach:keepAlive, oruseKeepAlivefor a module-level factory; reusable managed slots:createCache/Cached. - A single promise:
Async; grouped promises:Suspense; lazy module:lazy; old/new handoff:Transition. - Render elsewhere while retaining logical ownership:
createPortal, which returns inlet and outlet components. - Structured text parsing:
Parse; positional lists:UnKeyed; web-component boundary:defineCustomElement. - Wide equality matching such as selected-row state:
onCondition(source)rather than one computed equality per row. - Field-level subscriptions from a large object:
extractorderivedExtract. - Coalesced expensive work:
createDeferorcreateSchedule; use returned cancellation/disposal paths.
Extras and host boundaries
createPortal()fromrefui/extrasreturns[Inlet, Outlet]. Content produced by inlets is rendered at the outlet and remains disposed with its logical owner.Parseis an escape hatch for parsed/structured source. Sanitize untrusted markup before parsing or emitting raw HTML.- HTML-renderer
rawHTMLbypasses escaping and must receive trusted content only. defineCustomElementcreates a custom-element boundary whose attributes map to reactive props and whose connected/disconnected lifecycle owns the rendered component.- Prefer reusable
m:*macros for DOM-only behaviors such as focus traps, click-outside, and scroll locking; the macro must clean up listeners or host resources.
HMR
- Use the
refurbishintegration for the project’s bundler; application components should not hand-writeimport.meta.hotbookkeeping. - Vite configuration uses
import { refurbish } from 'refurbish/vite'andplugins: [refurbish()]. Bun uses therefurbish/bunplugin. - HMR builds retain component construction boundaries that production may optimize away, so do not infer production allocation behavior from development-only wrappers.
- Use
$reforexposefor stable node/component handles. HMR wrapping may change the immediate object returned byrenderer.render()orcreateComponent(). - Framework primitives marked static are invoked directly and are not ordinary user HMR boundaries.
Non-Negotiables (retained mode)
- Do not write React/Vue/Solid/Svelte primitives (
useState, hooks, VDOM assumptions,$:blocks, etc.). Map them to rEFui signals/effects. - Treat component bodies as setup (constructor-ish). JSX is evaluated once; signals drive incremental updates afterward.
- Keep reactive reads reactive:
- ✅ Use a signal directly:
<div>{count}</div> - ✅ Wrap derived expressions:
<div>{$(() =>Count: ${count.value})}</div>or<div>{computed(() => ...)}</div> - ❌ Avoid inline
.valuein JSX:<div>{count.value}</div>(evaluates once, won’t update)
- ✅ Use a signal directly:
- Control-flow note:
- ✅
<If condition={flag}>whenflagis already a signal/computed - ✅
<If condition={$(() => count.value > 0)}>for a derived condition - ❌ Treating
<If condition={flag}>as a reactivity smell by itself
- ✅
- Remember scheduling: signal effects/computed flush at the end of the tick; use
await nextTick()when you must observe derived updates.
Default Patterns (copy these mentally)
- State:
const x = signal(initial) - Derived:
const y = $(() => /* uses x.value */)(orcomputed(() => ...)) - Effects:
watch(fn)for reactive computations;useEffect(setup)for setup+cleanup;onDispose(cleanup)for teardown. - Lists:
- Keyed:
<For entries={items} track="id">{({ item }) => ...}</For> - Unkeyed (perf experiments / reorder heavy):
UnKeyedfromrefui/extras/unkeyed.js - If mutating arrays/objects in place: call
sig.trigger()after mutation.
- Keyed:
- Async:
<Async future={promise} fallback={...} catch={...}>{({ result }) => ...}</Async><Suspense>for grouping async subtreesasynccomponents are supported; pair with fallbacks when needed.
- DOM directives/events (DOM renderer):
- Events:
on:click={...}, plus optionson-once:*,on-passive:*,on-capture:* - Attributes vs props: prefer
attr:for SVG or when a DOM prop is read-only; useprop:to force a property set. - Preset directives (browser preset):
class:x={boolSignal},style:color={valueOrSignal} - Macros:
m:name={value}wherenameis registered on the renderer.
- Events:
- Refs/handles:
$ref={sig}to receive a node/instance insig.value$ref={(node) => ...}callback form- Prefer
exposeprop for imperative child handles (v0.8.0+).
Workflows
Add a feature safely
- Keep renderer creation at the entry point; do not create renderers inside components.
- Localize state: prefer per-component signals over global blobs; use
extract/derivedExtractto reduce fan-out. - For repeated DOM behaviors, register a macro and use it via
m:*rather than duplicating manual DOM code. - For lists, choose keyed
<For>unless you have a measured reason to use unkeyed.
Set up a new project (when asked)
- Ask only: preferred package manager (
npm/pnpm/yarn/bun) and language (JS/TS). Do not ask runtime. - Default to JSX automatic runtime + JavaScript +
refuilatest from npm unless the user specifies otherwise. - For Vite, configure
esbuild: { jsx: 'automatic', jsxImportSource: 'refui' }. For TS/TSX, use"jsx": "react-jsx"and"jsxImportSource": "refui"when TypeScript performs the transform; do not configure two competing JSX transforms. - Use
.jsx/.tsxfor files containing JSX. Create the host renderer once in the entry file and mount the root component there. - A minimal browser entry imports
createDOMRendererfromrefui/dom, importsdefaultsfromrefui/browser, creates the renderer, and callsrenderer.render(document.getElementById('app'), App). - Add
refurbishonly when HMR is wanted and configure its bundler plugin as described above. - Preserve an existing project’s JSX mode, renderer, import paths, and package manager during unrelated work.
Validation
- Reproduce lifecycle bugs through public APIs before changing application or framework code.
- When debugging repeated updates, review scopes that write their own signal dependencies; the optional audit can help locate direct cases. Findings are warnings, not proof of a loop or a requirement to change the code. Preserve intentional, understood feedback and eager
Ifchildren. - When changing
Ifbranches, distinguish an intentional true/false pair from siblings intended for one branch. The syntax alone cannot infer that product intent. - Validate a production cold launch as well as development/HMR. HMR component wrappers can mask eager setup that fails in a compiled package.
- For scheduled reactivity, await
nextTick()before asserting downstream effects or rendered output. - Verify unmount and final-owner disposal separately for
memo,keepAlive,useKeepAlive,Dynamic, async boundaries, lists, listeners, and external resources. - Renderer-neutral tests must not fake browser-only
DocumentFragmentsemantics. Test host-specific behavior against the actual host contract, and test HTML behavior with the HTML renderer. - Do not patch a test harness or target implementation merely to make an assertion pass; correct the production ownership or behavior that the reproduction exposed.