What carries over—and what changes

Differences from React

Bring your React mental model. Octane keeps the component and hook APIs, then changes a small set of rules where the compiler or the browser can do more of the work.

Hooks fit the code

Octane tracks a hook by its source location, not the order in which hooks run. A hook can sit behind a condition or after an early return without shifting another hook's state:

tsrx
export function Profile(props) {
	if (!props.user) return <EmptyState />;

	const [editing, setEditing] = useState(false);
	return <button onClick={() => setEditing(!editing)}>Edit profile</button>;
}

The exception is a slot-based hook inside a plain JavaScript loop. Every iteration would share one source location, so the compiler reports an error. Use keyed @for or move the hook into a child component. use() and useContext are exempt because they are not slot-keyed.

Dependency lists are optional for effects, memos, callbacks, and imperative handles. When you omit one, the compiler derives it from the callback's captures:

tsrx
useEffect(() => {
	syncRoom(room.id);
}); // inferred from syncRoom and room.id

Direct calls to built-in hooks infer their dependencies in every compiler-processed module, including custom hooks written in plain .ts or .js:

ts
export function useLoggedValue(value: string, log: (value: string) => void) {
	useEffect(() => log(value)); // inferred from log and value
}

Inferring an omitted list at a call to a custom wrapper is a narrower operation. It works when a local wrapper in a fully compiled .tsrx/.tsx module transparently forwards its callback and final dependency parameter to a built-in hook. Nested local transparent wrappers also work. Wrapper calls in plain .ts/.js and imported, method-style, or non-transparent wrappers need an explicit list; their internal calls to built-in hooks can still infer dependencies.

An explicit array keeps its exact React meaning. Pass null when you intentionally want the effect or computation to run after every render.

useState and useReducer also offer an optional third tuple item: a stable function that reads the latest scheduled state. It replaces the ref often used after an await:

tsrx
const [draft, setDraft, getDraft] = useState('');

await waitForConnection();
await save(getDraft());

Ordinary two-item destructuring stays on the normal two-item path.

When local state should reset or adjust after another value changes, Octane adds useLinkedState:

tsrx
const [name, setName] = useLinkedState(user.id, () => user.name);

The name remains editable while user.id stays the same. When the ID changes, the hook returns the next user's name immediately. There is no effect, render-time setter, or extra render to correct the old value. The calculation can also read the previous { source, value }, which is useful when a selection should survive if it is still valid.

Strong mode is optional

Strong mode opts into immutable render snapshots and helps catch detectable state, ref, and purity violations. Enable it for a single module with "use strong" before its imports, or across your application with compiler: { strong: true } in octane.config.ts.

The compiler rejects state updates during render, synchronous state updates while an effect is being set up, and writes to ref.current during render. It follows provable calls through useCallback, useEffectEvent, and functions returned by analyzable useMemo factories. Calling a statically known Effect Event during render or including it in explicit hook dependencies is also an error. These hooks remain supported, and other explicit dependency lists retain their existing meaning.

Use useLinkedState when editable state should follow a changing prop. Event handlers, genuinely deferred callbacks, effect cleanup, effects that synchronize external systems, and normal DOM or timer refs remain supported. Installed dependencies keep their existing behavior unless they opt in themselves.

Strong also rejects render-time mutations of provable state snapshots and direct reads of known clocks or random sources, such as Date.now() and Math.random(). Events, effects, and lazy state initializers may obtain these values. The checks follow supported aliases and synchronous helpers, but cannot prove arbitrary imported code pure.

Default compatibility mode conservatively reevaluates method calls: a stable receiver can hide changing state. React Compiler also identifies APIs with interior mutability, including TanStack Table v8, as incompatible with memoization. React Compiler lint results and option-sensitive transforms are comparison evidence, not Octane controls. Outlining options, debug hooks, and React's memo directives do not change Octane's compatibility/Strong contract.

In a Strong module, the author asserts that all rendering is referentially transparent: the same witnessed inputs produce the same result and render work has no application-visible side effects. Production client builds apply that assertion to every user-authored render operation—local, imported, static or computed member, call-produced, callback-bearing, constructed, or tagged—and can then reuse eligible regions and keyed rows. A use* name is not a purity oracle and does not reintroduce Rules of Hooks; actual hook calls stay in normal component or custom-hook setup and use compiler-assigned slots. Built-ins are recognized by import provenance, including optional calls, and same-module custom-hook declarations or function-valued module bindings are resolved by lexical binding to a transitive fixed point, so aliases and cycles retain context, state, suspension, and effect lifecycle behavior. Projection guards witness a method/callable, its receiver, and its explicit arguments; derived receivers are witnessed through the operations and inputs that produce them rather than transient result identities. Component and ordinary-list projection witnesses use Object.is, distinguishing signed zero while stabilizing NaN; a certified keyed-selection operand retains authored strict equality.

Strong diagnostics catch violations the compiler can prove. The analysis is bounded, so an unknown call is assumed pure rather than making memoization fall back. A call that hides ref contents, a state getter, a mutable module or global, a live external store, a clock, randomness, mutation, or any other changing source violates the contract. Keep live accessors in a compatibility-mode consumer, or pass an actual snapshot into a separate Strong component; opting in does not make a library's live objects immutable.

Compatibility keeps a live method call reevaluating only when its containing subscribed render scope runs; it creates no subscription and does not make the same live object safe across an unchanged child, memo, or Strong boundary. A compatibility consumer should subscribe, select a primitive or immutable value, and pass that value into the Strong component. Passing the same row/header, shallow-copying its live methods, or forcing an unrelated render without reading the selected value is not a snapshot handoff.

Fresh local mutation that completes during ordinary setup remains valid—for example, filling a new array in a plain JavaScript loop before rendering it. A keyed @for row is independently retained, so Strong rejects writes from that row to an outer binding with OCTANE_STRONG_RETAINED_ROW_MUTATION. Keep scratch data inside one row, build the full result before @for, or use @for (...; index position; key item.id). Compatibility accepts cross-row writes but does not promise retained-row evaluation order, so rendered output must not depend on them.

Keys preserve surviving DOM nodes, not an exact evaluation count. A diagnostic console.log no longer disqualifies an otherwise eligible Strong row, but a handler such as () => setItems(items.filter(...)) still captures items. Appending changes that capture, so the row must receive its current handler. Strong mode preserves those closure semantics. Logging can therefore differ across production, development, HMR, and profiling builds and is not a commit counter.

Events come from the browser

Handlers receive the browser's real Event object, not a synthetic wrapper. Bubbling, capture, stopPropagation(), and logical bubbling through portals still work. The practical differences are:

  • Use onInput for every text edit. Native change fires when the browser commits the edit, usually on blur.
  • There are no synthetic onChange, onBeforeInput, or onSelect polyfills.
  • Mouse and pointer enter/leave handlers use the platform's native events.

Controlled value and checked still behave like React: the prop drives the DOM and is re-applied after renders and discrete events. Most text-field migrations are a one-word change:

tsx
// React
<input value={text} onChange={(event) => setText(event.currentTarget.value)} />

// Octane
<input value={text} onInput={(event) => setText(event.currentTarget.value)} />

The warning covers <textarea> and text-entry input types: a missing or invalid type, plus text, search, url, tel, password, email, and number. It does not apply to selects, checkbox/radio/file and other non-text input types, custom elements, statically read-only/disabled controls, or component callbacks that happen to be named onChange. The compiler surface uses warning severity; unresolved final-prop violations report through console.error in development, once per broken episode. An onChangeCapture fix preserves the phase by using onInputCapture.

Native commit-on-blur behavior is valid. Mark it explicitly instead of adding a noop input handler:

tsrx
<input
	defaultValue={savedDraft}
	onChange={(event) => save(event.currentTarget.value)}
	suppressNativeChangeWarning
/>

suppressNativeChangeWarning is a JS-only host hint. It suppresses only this diagnostic, never appears in client or server HTML, and changes neither event delivery nor controlled-state restoration.

Checkboxes and radios keep the browser's activation timeline: click, then input, then non-cancelable change. Consequently, preventDefault() in native onChange cannot roll the toggle back. Cancel the earlier onClick when rollback is the intended behavior. React's synthetic checkable onChange is backed by the cancelable click, so cancellation at that callback is an intentional timing divergence even though Octane still restores rejected controlled state and radio cousins after native change.

class and className compose clsx-style, so arrays, objects, and nested values work without another helper:

tsrx
<article class={['card', isActive && 'active', { selected }]} />

React turns ['a', 'b'] into "a,b"; Octane produces "a b" on both client and server.

Transitions without time slicing

Octane batches updates in a microtask, then runs each render to completion. There are urgent and transition updates, but no lanes, yield points, CPU time slicing, or selective hydration.

The useful transition behavior remains: when a slower replacement suspends, the current screen can stay visible and isPending can explain the wait. flushSync drains the full update queue, while passive effects still run after paint.

Octane always applies its compiler transform for avoidable use() waterfalls. Requests it can prove independent can start together; a request that needs an earlier result remains sequential:

tsrx
const user = use(fetchUser(id));
const teams = use(fetchTeams(id)); // starts alongside fetchUser

Promises created during render are safe in Octane — no cache() wrapper needed. The compiler memoizes every creation that feeds a use() at its declaration, keyed on its real inputs, and that includes local promise chains:

tsrx
const userPromise = fetchUser(id);
const thumbnailPromise = userPromise.then((user) => user.thumbnail());
<Avatar thumbnail={use(thumbnailPromise)} />

Errors and server rendering

Octane has no class components, so error boundaries use a template block or the function-based ErrorBoundary component:

tsrx
@try {
	<RiskyPanel />
} @catch (error, reset) {
	<button onClick={reset}>Try again</button>
};

An uncaught error is reported through console.error rather than React's onUncaughtError callback.

Framework-authored errors in the core DOM client and server runtimes retain their complete messages in development. Optimized production builds replace those messages with Octane-owned, append-only codes and a link to the error decoder; Octane does not reuse React's error numbers. User-thrown errors and compiler diagnostics keep their original messages and codes.

Buffered server rendering returns { html, css }; the extra css field carries the sibling-scoped <style> blocks and themes (a block styles its siblings and everything below them) of the components that rendered, collected per request inside each component body rather than at module load, deduplicated by hash. Streaming flushes each scoped <style> inline with the content that uses it and still reveals Suspense content as it becomes ready. During hydration, value mismatches are patched and structural mismatches are rebuilt in place with a warning instead of being thrown to a boundary.

Less common observable differences
  • A same-value state update can skip Octane's component body where React may enter it once more before bailing out. The committed result is the same.

  • useSyncExternalStore does not repeat an unchanged snapshot read at commit just because the callback identity changed. Stores that notify subscribers are unaffected.

  • When a form action rejects, Octane continues later queued actions instead of cancelling them.

  • The keyed reconciler uses a longest-increasing-subsequence algorithm. Final order, node identity, focus, and state match React; only the exact physical move pattern can differ.

  • Custom-element property handling stays closer to the browser. Applicable attribute diagnostics are expanding progressively from upstream behavior without embedding React's complete property-name table in every runtime.

APIs Octane leaves out

Octane is built around function components, hooks, and the DOM. It intentionally does not include:

  • Class components, legacy roots, or class error-boundary lifecycles.
  • Server Components, RSC/Flight, or cache().
  • StrictMode double-invocation, Profiler, or SuspenseList.
  • forwardRef or createRef. Refs are ordinary props, including callback refs, object refs, and arrays of refs.
  • Most React.Children utilities. Keyed @for is the normal collection API.

useDebugValue exists for library compatibility but has no visible runtime effect. Resource hints such as preload, preinit, preconnect, and prefetchDNS are supported.