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:

ReactLemonadeJS v6
useStatestate(), bound directly in the template
useEffect(fn, [])onMount(fn) (the returned function is the cleanup)
useEffect(fn, [a, b])subscribe() / computed() (no array)
useMemo / useCallbacknot needed; nothing re-runs to re-create
useRef for a DOM nodethe ref directive / ref()
useRef as a mutable boxa plain variable in setup (closures persist)
useContext + Providerstore(), or expose + use
useImperativeHandlethe contract api
setState(s => ())assign to notify, or mutate + touch()
React.memonot needed; no parent-driven re-render
createPortaldeferred by design (see below)
error boundary componentautomatic 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, useTransition and useDeferredValue coordinate 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