Imported from SudoMaker/rEFui (
skills/refui/SKILL.md). Install upstream withnpx skills add SudoMaker/rEFui --skill refui. Copyright stays with the author.
rEFui
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). - Effects re-run when any read signal changes; avoid writing to those signals without guards.
- 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.
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.- Scheduling: if you need “after updates applied”,
await nextTick().
Control flow components
- Conditional UI:
<If condition={cond}>{() => <Then />}{() => <Else />}</If> - 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 command can scan JSX mode and common .value mistakes, but the workflow must not depend on that script.
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.
- 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.