Coming from React
This page maps React concepts to their v6 equivalents, explains why each one differs, and covers the cases where React remains the better tool.
The core difference
React re-runs your component function to produce a fresh view, then reconciles that view against the previous one and patches the DOM. The function runs again on every relevant change; the hooks, the dependency arrays, the memoization, and the immutable-update discipline all exist to make repeated re-execution correct and affordable.
v6 runs your component function once. It wires fine-grained bindings between states and the exact DOM positions that read them, and never calls the function again. A state change updates only the bindings that depend on it. No new view, no reconciliation, no diff.
The equivalent idioms:
| React | LemonadeJS v6 |
|---|---|
useState | state(), bound directly in the template |
useEffect(fn, []) | onMount(fn) (the returned function is the cleanup) |
useEffect(fn, [a, b]) | subscribe() / computed() (no array) |
useMemo / useCallback | not needed; nothing re-runs to re-create |
useRef for a DOM node | the ref directive / ref() |
useRef as a mutable box | a plain variable in setup (closures persist) |
useContext + Provider | store(), or expose + use |
useImperativeHandle | the contract api |
setState(s => ()) | assign to notify, or mutate + touch() |
React.memo | not needed; no parent-driven re-render |
createPortal | deferred by design (see below) |
| error boundary component | automatic containment (LJS-205) |
“Render runs twice”: StrictMode
Because React re-executes render functions and effects repeatedly, both must be pure and idempotent, because a render that mutates outside itself, or an effect that does not clean up after itself, is a latent bug that only shows under the right re-run. StrictMode is the tool React ships to surface exactly those: in development it deliberately double-invokes render and mounts→unmounts→mounts effects, so a missing cleanup or an impure render fails loudly while you are looking, instead of silently in production. It is a diagnostic, not a misfire, and a good one for the model it serves.
v6 has nothing to double-invoke. Setup runs once, so there is no repeated
render to test for purity; cleanup is the function returned from onMount,
or a listen() registration, or the destroy path, each
wired once and owned by disposal rather than re-validated every render. The
class of bug StrictMode hunts is reshaped out of existence, so the tool that
hunts it is unnecessary. v6 does not double-invoke in any mode.
The honest cost runs the other way: run-once means there is no re-render to
re-run per-appearance work either. A branch that hides and shows again
reattaches its cached DOM without re-firing onMount or refs, so work that
must happen on each appearance is driven by the controlling state, not a
lifecycle hook. The pattern, and the trade, are in
Lifecycle.
The dependency array
useEffect, useMemo and useCallback take a dependency array because a
re-render model needs to be told which values, having changed, should
re-run the effect or invalidate the memo. The exhaustive-deps lint rule
guards the array, and in a project that runs it most omissions are caught.
Outside one, a missing entry produces no error, just a value frozen at the
render where it was captured.
v6 has no array. subscribe() and computed() track the states actually
read while they run and re-run when those change. The dependency list is
observed, never written. And because setup does not re-execute, there is no
captured closure to go stale in the first place: the value a binding reads
is the live state, not a snapshot from a past render.
The reverse is worth stating: useEffect is one general construct,
“synchronize with an external system after any relevant change.” v6 splits
that across onMount, subscribe and computed: three smaller, single-
purpose tools instead of one configurable one. That is more concepts to
learn for a smaller surface each; whether that is a gain depends on the
task.
Immutable updates versus mutate + touch()
React state is immutable by contract. You update by producing new objects,
setState(s => ({ ...s, a: { ...s.a, b: [...s.a.b, x] } })), and that
immutability is what makes React’s equality checks, memoization and
dev-tooling work. Deep nested updates require spreading at each level;
a dropped key is not caught at the update site.
v6 state is mutable. You assign to notify (rows.value = next), or you
mutate in place and call touch() when done. The update cost is
proportional to the number of bindings that changed, not the size of the
structure cloned, which is why the datagrid block edits thousands of rows
in a 100,000-row array and repaints only its visible window in single-digit
milliseconds (State).
The cost of this design, stated plainly: silent mutation. Mutating
without touch() updates nothing and there is no compiler to stop you.
Dev builds freeze state contents so an accidental mutation throws
LJS-201, but it is a discipline backed by a warning, not a guarantee
backed by the type system.
”What props does this take?”
In React the answer lives in TypeScript types or propTypes: compile-time
or runtime-shallow. JSX, in return, gives something v6 does not: the markup
itself is type-checked and autocompleted, because JSX is TypeScript, so a
wrong prop or a misspelled attribute is a red squiggle as you type.
v6 moves the answer to a single runtime source. component(name, contract, fn) declares props, types, defaults, events and api in one literal, and
from it the engine derives a generated .d.ts (the call site is fully
typed), a machine-readable contract.json an agent reads in one request
instead of parsing source, live states in every deployment, and a
verify() proof that the component honors what it declares
(Contracts).
The precise trade: the call site is typed, so passing the wrong prop to a
contract component is a type error. The template interior is not.
TypeScript does not check the expressions inside an html`...`
tagged template the way it checks expressions inside JSX, because the tag is
an opaque string to the language service. That is the price of having no build
step. The runtime catches in the template what the compiler would have
(Errors); it does not catch it as you type.
Where React is still the right tool
The following cases favor React or its ecosystem. The fuller list, including the compiler gap, portals and accessibility, is in When NOT to use LemonadeJS.
-
Server-side rendering. React’s SSR, streaming and Server Components are a mature pipeline for server-rendered first paint and server-side data fetching. v6 renders in the browser; it pairs well with Astro as interactive islands, but it is not itself an SSR layer. If SEO and time-to-first-byte are the business, this is decisive.
-
Targets beyond the DOM. React Native, react-three-fiber, terminal and PDF renderers all run React’s reconciler against a non-DOM target. v6 is DOM-only. “Write the component once, render it to native mobile” is a React reach v6 does not have.
-
Ecosystem, and familiarity. React’s package ecosystem is measured in hundreds of thousands; v6 ships an engine and a first-party block catalog. There is also a specific, honest point for agent authors: a model arrives fluent in React from its training data and works in LemonadeJS from llms.txt and the
verify()loop. That loop is v6’s deliberate answer to the unfamiliarity: it turns guessing into a pass/fail check. But the raw fluency is React’s, today. -
Coordinated async. Suspense,
useTransitionanduseDeferredValuecoordinate many async sources behind one boundary and keep the interface responsive during heavy work.resource()is deliberately the lifecycle of one fetch (abort, race-drop, staleness), not an orchestration layer (Async). For complex coordinated loading, React’s primitives are broader. -
Chosen recovery UI on error. A React error boundary lets you place a fallback and a recovery path around a subtree declaratively. v6 contains a failing binding automatically (the DOM keeps its last good content and every other binding still updates,
LJS-205), but there is no per-subtree fallback component to design the failure with. -
Tooling for human eyes. React DevTools’ component tree and profiler flamegraph are mature and visual. v6’s introspection (
trace()as a JSON causality log,inspect(),explain()) is built for a machine reader first (Debugging). Which is the better fit depends on who, or what, is doing the inspecting.
You do not have to choose
The two frameworks coexist in the same app. A contract component becomes a
first-class React component in one call: props diffed into live states,
bind mapped to value/onChange, the api delivered through a React ref,
all derived from the contract and tested against real React 18 including
StrictMode:
import { adaptReact } from 'lemonadejs/react';
import { Switch } from './switch';
const ReactSwitch = adaptReact(Switch);
// <ReactSwitch label="Dark mode" value={on} onChange={setOn} ref={api} />
And when a large-data component should stay outside React’s re-render
cycle, mount() it as an island and let its bindings write the DOM
directly. React does not reconcile what it does not render.
Both directions are in React: adaptReact().
Further reading
- Why v6 exists: the thesis, and the error profile this page’s differences come from
- When NOT to use LemonadeJS: the complete honest list
- Lifecycle: setup-runs-once,
onMount,listen() - State and computed(): mutability and derivation
- Contracts: the single source the call site, the
agent and
verify()all read