Establishes version control for the personal Logseq DB fork before starting the server-client architecture migration and domain features described in docs/agent-guide and the approved implementation plan. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
12 KiB
ADR 0021: RFX-managed Frontend State
Date: 2026-06-03 Status: Accepted
Context
ADR 0019 replaced Rum with HSX and moved the UI runtime onto React 19. The
frontend now has a React-compatible component runtime, but application state is
still managed by frontend.state through a large global atom plus many nested
atoms.
The current state layer has several responsibilities mixed together:
frontend.state/statestores the main application map.- Many values inside that map are themselves atoms, for example editor, selection, RTC, PDF, command palette, scroll, and other high-churn UI refs.
frontend.state/use-subprovides React subscriptions by wrapping the global atom or a nested atom withlogseq.shui.hooks/use-atom.frontend.state/get-state,set-state!, andupdate-state!are used across handlers, components, workers, plugins, mobile code, and Electron integration.frontend.state/pub-event!publishes vectors to acore.asyncchannel stored at:system/events.frontend.handler.eventsconsumes that channel and routes events through adefmulti.- Worker DB queries and component subscriptions use a separate reactive layer in
frontend.db.reactandfrontend.db.hooks. - Several subsystems use Missionary flows or direct atom watches for runtime state that is not just component state.
This makes UI reactivity explicit enough for React hooks, but the ownership is still split between path cursors, nested atoms, a global event channel, and a large set of imperative helpers. A code scan showed the size of the migration surface:
- about 178
state/use-subcall sites undersrc/main/frontend; - about 310
set-state!orupdate-state!call sites undersrc/main/frontend; - about 190
pub-event!call sites undersrc/main/frontend; - about 102
frontend.handler.events/handlemethods.
RFX is a better fit for the next step because it keeps the re-frame style
architecture while using React Context and hooks instead of Reagent. It provides
scoped app instances, use-sub, dispatch, reg-sub, reg-event-db,
reg-event-fx, effects, coeffects, snapshots, and a queue. Logseq has already
forked RFX and added root deps.edn support on logseq/rfx branch
logseq/fork, commit d37aaceb37fcaf969c5cd04646dfdb985d65d3c2.
The migration must not destabilize editor input, block trees, DB worker transactions, RTC, plugin APIs, Electron IPC, mobile native flows, or DataScript query subscriptions.
Decision
Adopt RFX as the frontend application state and event runtime.
Add RFX to the main frontend dependency root from the Logseq fork:
io.github.logseq/rfx
{:git/url "https://github.com/logseq/rfx.git"
:git/sha "d37aaceb37fcaf969c5cd04646dfdb985d65d3c2"}
Create a Logseq-owned RFX context, store, and registry. The context should be
provided near the root render in frontend.core, around page/current-page.
The RFX instance must be initialized during frontend startup before event
handling starts.
Replace frontend.state as the state owner in one coordinated migration.
Existing non-hook public entry points may remain as thin call-through APIs only
when they preserve caller compatibility without keeping state outside RFX:
get-stateset-state!update-state!pub-event!- focused helpers such as
get-current-repo,set-current-repo!,get-config, editor helpers, selection helpers, and RTC helpers
Replace all frontend.state/use-sub and focused hook call sites with
rfx/use-sub or Logseq-owned domain hooks backed by RFX subscriptions. Do not
keep frontend.state/use-sub as a compatibility wrapper. get-state,
set-state!, update-state!, and focused non-hook helpers should read
snapshots or dispatch RFX events instead of mutating the old global atom.
Use one source of truth:
- Frontend application state is plain data in the RFX app db and is updated through RFX events.
- Nested atoms must not remain as the representation for application state. Convert nested atom state to plain values and path updates during the migration.
- Missionary flows and direct atom watches must not remain as state propagation paths for frontend application state. Replace them with RFX subscriptions, events, effects, and coeffects.
- Imperative resources such as DOM nodes, editor handles, timers, external worker objects, Electron handles, and plugin runtime handles stay outside the RFX app db. They may be held in refs only when they are not renderable application state and do not drive UI subscriptions directly.
Migrate every frontend state domain and its event handlers in the same change.
Do not land partial domain migrations. State-only migrations that still rely on
frontend.handler.events/handle for writes are rejected, and event-only
migrations whose render state still lives behind legacy path cursors are also
rejected.
pub-event! may remain as the public promise-returning compatibility API for
callers, but it should dispatch into RFX instead of writing to :system/events.
It must route to explicit rfx/reg-event-db or rfx/reg-event-fx
registrations and resolve or reject the deferred promise from the RFX event
result. Do not keep a bridge that invokes the existing
frontend.handler.events/handle multimethod.
Use RFX to replace flow-based frontend state propagation. DataScript execution and worker-owned invalidation can remain worker responsibilities, but UI-facing query result state and refresh orchestration should be exposed through RFX subscriptions and events instead of Missionary flows or atom watches.
Migration Shape
The migration should be one coordinated replacement of the frontend state and event runtime.
- Add the RFX dependency and create a small
frontend.rfxnamespace that owns the app context, registry, initialization, dispatch helpers, and root provider. - Move the initial plain app state map construction out of the
defonce stateexpression into a reusable function. - Move all frontend application state domains to plain RFX app-db values, including route state, graph/current repo state, search, modals, sidebars, theme flags, auth tokens, import/loading state, editor state, selection, command palette, PDF, RTC UI state, plugin UI state, Electron UI state, and mobile UI state.
- Replace all
frontend.handler.events/handlemethods with explicitrfx/reg-event-dborrfx/reg-event-fxregistrations. Keep event ids domain-scoped and register effects/coeffects for DB worker calls, RTC, plugins, Electron IPC, mobile native calls, and async error capture. - Replace
:system/eventswith RFX dispatch behindpub-event!and remove thecore.asyncevent channel. - Replace Missionary flows and direct atom watches that propagate frontend application state with RFX subscriptions, events, effects, and coeffects.
- Replace all
frontend.state/use-suband focused state hook call sites withrfx/use-subor domain hooks backed byrfx/reg-sub. - Remove nested atoms, path cursors, and legacy hook paths for application state. Keep refs only for imperative resources that do not represent renderable state.
- Update tests in the same change so every moved domain has coverage for state reads, event-driven writes, async effects, and promise resolution.
Each domain should have a clear owner namespace, subscription ids, event ids, effects, coeffects, and test coverage.
Consequences
State ownership becomes explicit. Components subscribe through React-safe RFX hooks, while non-view code can use RFX snapshots and dispatch against the same instance.
Tests and isolated UI surfaces can create scoped RFX contexts instead of
mutating the global frontend.state/state atom. This matches the context model
introduced by the HSX migration.
The event system gains a uniform event/effect/coeffect shape and immediately
replaces the core.async channel plus defmulti dispatch path.
The migration cannot be a mechanical atom swap. Nested atom mutation does not automatically notify RFX subscriptions. State must move to plain values, and writes must move to RFX events.
Compatibility wrappers may remain only as public API aliases that call into RFX. They must not retain separate storage, separate event dispatch, Missionary flow state propagation, or direct atom-watch state propagation.
RFX effects that run async work must preserve existing error reporting behavior,
including :capture-error and promise rejection paths used by callers of
pub-event!.
Rejected Alternatives
Keep frontend.state As-is
This avoids migration cost but leaves Logseq with a custom subscription system after the React 19 and HSX migration. It also keeps event routing separate from state updates and does not give isolated RFX contexts for tests or component development.
Replace Only pub-event!
Moving events to RFX while leaving render state on path cursors would improve event structure, but components would still subscribe through the custom atom layer. It would not solve the state ownership problem.
Migrate Domain-by-Domain
An incremental domain migration would leave two state and event runtimes active
for too long. That would preserve the current split ownership across RFX,
legacy path cursors, Missionary flows, atom watches, the core.async event
channel, and defmulti handlers. The migration should replace all frontend
application state domains and handlers together.
Migrate State Without Event Handlers
Moving state keys to RFX while keeping their writes in
frontend.handler.events/handle would preserve the current split ownership in a
new form. RFX-managed state must be written through RFX events, so state and
event handlers should move together.
Keep frontend.state/use-sub As A Wrapper
Keeping frontend.state/use-sub as a compatibility hook would preserve the old
subscription boundary and make it harder to see which code is using RFX
subscriptions directly. All hook call sites should move to rfx/use-sub or
domain hooks backed by RFX.
Keep Missionary Flows For Frontend State
Keeping Missionary flows or atom watches as frontend state propagation would leave RFX as only a partial store. Worker internals can still use their own runtime mechanisms, but UI-facing state should be expressed through RFX subscriptions and events.
Keep DataScript Query Reactivity Outside RFX
Keeping frontend.db.react and frontend.db.hooks/use-query as a separate
UI-facing subscription layer would leave another flow-like state runtime beside
RFX. Query execution and worker invalidation may stay in the DB worker, but the
frontend-facing subscription and refresh contract should be mediated through
RFX.
Verification
Before accepting the migration, verify:
- root rendering is wrapped in the Logseq RFX context;
- no
frontend.state/use-subcall sites remain; - direct
rfx/use-suband domain hook call sites obey React hook ordering; - RFX-managed keys re-render components after
set-state!,update-state!, and event handlers update them; - all frontend application state domains update through RFX events, with no
remaining legacy
defmethod handlewrite path; - no Missionary flow or direct atom watch remains as a frontend application state propagation path;
- non-managed refs are limited to imperative resources and do not drive UI subscriptions directly;
pub-event!still returns a promise that resolves or rejects with the event result;- event errors are logged and reported through the existing capture path;
- graph switching, route changes, theme toggles, sidebars, modals, settings, search, import progress, login state, plugin UI, Electron IPC, mobile tabs, RTC indicator, editor editing, and block selection keep prior behavior;
- UI-facing DB query result subscriptions and refresh orchestration go through RFX, while worker-owned query execution and invalidation keep prior behavior;
- no React hook-order warnings, invalid element warnings, or missed subscription updates appear in Chrome;
- focused unit tests cover every state domain, event registration, effect, coeffect, non-hook compatibility wrapper, and replaced flow path;
bb dev:lint-and-testpasses;- relevant app or CLI E2E tests pass for graph switch, editor input, selection, sidebar, settings, plugin, and sync surfaces touched by the migrated keys.