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 helps catch state and ref patterns that React applications sometimes use as workarounds. 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, state updates made directly while an effect is being set up, and writes to ref.current during render. Use useLinkedState when editable state should follow a changing prop. Event handlers, effects that synchronize external systems, and normal DOM or timer refs remain supported. Installed dependencies keep their existing behavior unless they opt in themselves.

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 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.