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

Locally declared custom hooks in full-compiled .tsrx/.tsx modules participate when they transparently forward their callback and final dependency parameter to one of those hooks. The compiler follows nested transparent wrappers, but it does not guess from a use* name alone. Plain .ts/.js modules, imported custom hooks, and wrappers that transform or inspect those parameters need an explicit list.

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.

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)} />

userPromise is keyed on id, and the derived .then link is keyed on userPromise's identity — so nothing refetches on an unrelated re-render or a suspense replay, the whole chain refetches exactly when id changes, and the chain's head joins the prefetch (__warm) plan an ancestor can start early. React re-runs an uncached creation on every render attempt (with a dev warning), and the React Compiler stabilizes the promise identity but still unwraps serially. In production output these memoized creations — and useMemo/useCallback themselves — compile to inline caches: a dependency hit costs a few comparisons with no closure, dependency-array, or hook-map allocations.

Two known gaps are tracked while this area is under active work: independence analysis stops at module boundaries (independent use() reads inside an imported custom hook still serialize), and a transition-wrapped update currently reveals newly-loaded content progressively — never rolling back — where React holds the previous screen until the whole update completes.

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 contains Octane's scoped styles. Streaming 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.