react-conditional-hooks
v0.0.1
Published
Run conditional hooks in React (experiment)
Maintainers
Readme
react-conditional-hooks
[!WARNING] ⚠️⚠️⚠️ this project may break production apps and cause unexpected behavior ⚠️⚠️⚠️
this project uses react internals, which can change at any time. we don't recommend depending on internals unless you really, really have to. by proceeding, you acknowledge the risk of breaking your own code or apps that use your code.
Run conditional hooks in React (experiment)
Install
Install the package with your project’s package manager:
npm install react-conditional-hooksUse hooks inside a conditional branch
This app puts useState and useMemo inside a conditional branch:
import { installConditionalHooks } from "react-conditional-hooks";
import { useMemo, useState, type ReactNode } from "react";
installConditionalHooks();
export const App = (): ReactNode => {
const [isPartyMode, setIsPartyMode] = useState(false);
if (!isPartyMode) {
return <button onClick={() => setIsPartyMode(true)}>Start party</button>;
}
const [ducks, setDucks] = useState(3);
const danceFloor = useMemo(() => "🦆".repeat(ducks), [ducks]);
return (
<section>
<p>{danceFloor}</p>
<button onClick={() => setDucks(ducks + 1)}>Add duck</button>
<button onClick={() => setIsPartyMode(false)}>Stop party</button>
</section>
);
};Start the party, add a duck, stop it, and start it again. The conditional ducks state resumes at its previous value. React normally rejects this pattern because the number of hooks changes between renders.
The runtime supports:
- State:
useState,useReducer, anduseRef - Memoization:
useMemoanduseCallback - Effects:
useEffectanduseLayoutEffect
useContext continues through React’s dispatcher because context lookup belongs to the renderer.
How conditional hooks work
The runtime intercepts React’s dispatcher, derives an identity from each hook’s source location, and stores state beside the component Fiber. Bippy supplies renderer access and commit lifecycle events.
React sends hook calls through a dispatcher
Functions such as useState delegate to React’s current dispatcher. The runtime replaces the dispatcher property with a proxy and redirects supported hooks:
const proxy = new Proxy(dispatcher, {
get: (target, property, receiver) => {
if (property === "useState") {
return (initialState: unknown) =>
readStateCell(getAutomaticHookKey(runtime, "useState"), initialState);
}
return Reflect.get(target, property, receiver);
},
});Application code still calls React’s APIs. Unsupported hooks pass through to the original dispatcher.
The source callsite identifies each hook
React normally identifies a hook by its position in the component’s hook list. This runtime captures an error stack and uses Bippy to parse V8, Firefox, and Safari stack formats:
const applicationFrame = parseStack(stack).find(
(stackFrame) =>
Boolean(stackFrame.fileName) &&
!isRuntimeStackFrame(stackFrame) &&
!isReactStackFrame(stackFrame),
);
if (!applicationFrame?.fileName) throw new Error("Missing callsite");
const callsiteKey = [
applicationFrame.fileName,
applicationFrame.lineNumber,
applicationFrame.columnNumber,
].join(":");The file, line, and column form the stable identity. A per-render occurrence counter separates repeated calls from the same callsite.
Each Fiber owns a committed scope
React creates a Fiber for each mounted component instance. The runtime associates that Fiber with a side table instead of modifying React’s private hook list:
interface ConditionalHookScope {
cells: Map<PropertyKey, ConditionalHookCell>;
effects: Map<PropertyKey, ConditionalEffectCell>;
fiber: Fiber;
}
const scopeByFiber = new WeakMap<Fiber, ConditionalHookScope>();
const renderFrameByFiber = new WeakMap<Fiber, ConditionalRenderFrame>();Hook calls write into a temporary render frame. A successful commit promotes that frame into the Fiber’s scope. Suspended, failed, or abandoned renders never change committed state.
Commits control effect cleanup
Each successful render records the effects whose branches ran. The commit removes effects that disappeared from the next render:
for (const [key, effectCell] of scope.effects) {
if (nextEffects.has(key)) continue;
runEffectCleanup(effectCell);
scope.effects.delete(key);
}State cells remain in the scope when their branch disappears. Effects clean up when the branch exits and start again when it returns.
Development
Run the package checks from the repository root:
ni
nr build
nr test
nr checkLicense
MIT
