Skip to content

Add experimental conditional hooks runtime - #74

Draft
aidenybai wants to merge 4 commits into
mainfrom
codex/conditional-hooks-fiber-runtime
Draft

Add experimental conditional hooks runtime#74
aidenybai wants to merge 4 commits into
mainfrom
codex/conditional-hooks-fiber-runtime

Conversation

@aidenybai

@aidenybai aidenybai commented Jul 19, 2026

Copy link
Copy Markdown
Owner

What this PR does

This PR makes ordinary React hooks work inside branches whose hook order changes between renders, without patching or forking React.

const Counter = ({ enabled }: { enabled: boolean }) => {
  if (enabled) {
    const [count, setCount] = React.useState(0)

    React.useEffect(() => {
      console.log("mounted")
      return () => console.log("cleaned up")
    }, [])

    return <button onClick={() => setCount(count + 1)}>{count}</button>
  }

  return null
}

There is one consumer-facing model:

  1. Call installConditionalHooks() before the application renders.
  2. Continue using normal React.useState, React.useReducer, React.useEffect, and related APIs.
  3. Put them inside branches if desired.

There are no public keyed-hook functions and no opt-in interception flag. Interception is automatic after installation. All callsite identity is derived internally.

Important

This only works with React development renderers. It relies on private renderer fields exposed to DevTools: currentDispatcherRef, getCurrentFiber, and scheduleUpdate. It is experimental, unsupported, and not production-safe.

Complete runnable example

The installer must execute before the module that renders the React application.

main.tsx

import { installConditionalHooks } from "bippy/conditional-hooks"

installConditionalHooks()

const { startApplication } = await import("./app.js")

startApplication()

The dynamic import is intentional. It ensures Bippy is listening before ReactDOM injects its renderer and before the first component render.

app.tsx

import React from "react"
import { createRoot } from "react-dom/client"

interface ConditionalCounterProperties {
  enabled: boolean
}

const ConditionalCounter = ({
  enabled,
}: ConditionalCounterProperties): React.ReactNode => {
  let content: React.ReactNode = <p>The branch is disabled.</p>

  if (enabled) {
    const [count, setCount] = React.useState(0)

    const [step, changeStep] = React.useReducer(
      (currentStep: number, direction: number) =>
        Math.max(1, currentStep + direction),
      1,
    )

    const renderCount = React.useRef(0)

    const result = React.useMemo(
      () => count * step,
      [count, step],
    )

    renderCount.current++

    React.useLayoutEffect(() => {
      console.log("layout effect mounted")

      return () => {
        console.log("layout effect cleaned up")
      }
    }, [])

    React.useEffect(() => {
      const intervalIdentifier = window.setInterval(() => {
        console.log("effect tick")
      }, 1000)

      return () => {
        window.clearInterval(intervalIdentifier)
        console.log("effect cleaned up")
      }
    }, [])

    content = (
      <section>
        <p>
          {count} × {step} = {result}
        </p>

        <button
          onClick={() =>
            setCount((currentCount) => currentCount + step)
          }
        >
          add {step}
        </button>

        <button onClick={() => changeStep(-1)}>
          step −
        </button>

        <button onClick={() => changeStep(1)}>
          step +
        </button>

        <small>
          render {renderCount.current}
        </small>
      </section>
    )
  }

  return content
}

const Application = (): React.ReactNode => {
  const [isEnabled, setIsEnabled] = React.useState(false)

  return (
    <main>
      <button
        onClick={() =>
          setIsEnabled((currentValue) => !currentValue)
        }
      >
        {isEnabled ? "disable branch" : "enable branch"}
      </button>

      <ConditionalCounter enabled={isEnabled} />
    </main>
  )
}

export const startApplication = (): void => {
  const rootElement = document.querySelector("#root")

  if (!rootElement) {
    throw new Error("Missing root element.")
  }

  createRoot(rootElement).render(<Application />)
}

The observable behavior is:

first render, enabled = false
  conditional hooks do not run

enable the branch
  state, reducer, ref, and memo cells are created
  layout and passive effects start

update count or step
  the owning component renders again
  existing cells are recovered automatically

disable the branch
  state-like cells remain stored
  conditional effects clean up

enable the branch again
  previous count and step return
  effects start again

Try the playground

cd packages/conditional-hooks-playground
nr dev

The playground shows the full component, highlights the conditional block, and walks through the enable, update, disable, and re-enable sequence.

How the implementation works

The implementation lives in packages/bippy/src/conditional-hooks.ts.

1. Observe React renderers

Bippy attaches through the same global hook used by React DevTools.

Installation handles renderers that already exist and renderers injected later:

export const installConditionalHooks = (
  options: ConditionalHooksOptions = {},
): ConditionalHooksInstallation => {
  const devtoolsHook = getRDTHook()

  for (const renderer of [
    ...knownRenderers,
    ...devtoolsHook.renderers.values(),
  ]) {
    installRenderer(renderer, options)
  }

  const unsubscribeRendererInject = onRendererInject((renderer) => {
    installRenderer(renderer, options)
  })

  const unsubscribeInstrumentation = instrument({
    name: "bippy-conditional-hooks",
    onCommitFiberRoot: (_rendererIdentifier, root) =>
      commitRoot(root),
    onCommitFiberUnmount: (_rendererIdentifier, fiber) =>
      unmountFiber(fiber),
  })

  return createInstallationDisposer(
    unsubscribeRendererInject,
    unsubscribeInstrumentation,
  )
}

A renderer is supported only when it exposes:

renderer.currentDispatcherRef
renderer.getCurrentFiber
renderer.scheduleUpdate

These are development-only DevTools integration fields. No React source or bundle is modified.

2. Intercept the active hook dispatcher

Before React renders a function component, it assigns the dispatcher used by calls such as React.useState().

Bippy replaces the dispatcher property with a getter and setter:

Object.defineProperty(dispatcherRef, dispatcherKey, {
  configurable: true,
  enumerable: originalDescriptor?.enumerable ?? true,

  get: () => getRuntimeDispatcher(runtime),

  set: (dispatcher) => {
    handleDispatcherChange(runtime, dispatcher)
  },
})

The setter observes when React enters a render and captures the current Fiber:

const handleDispatcherChange = (
  runtime: ConditionalHookRuntime,
  dispatcher: ConditionalHookDispatcher | null,
): void => {
  runtime.currentDispatcher = dispatcher

  if (isContextOnlyDispatcher(dispatcher)) {
    runtime.activeFiber = null
    return
  }

  const fiber = runtime.renderer.getCurrentFiber?.()

  if (fiber) {
    beginRender(runtime, fiber)
  }
}

The getter returns a proxy around React's active dispatcher.

3. Redirect normal React hook calls

The proxy redirects supported hook methods into private cell readers:

const createDispatcherProxy = (
  runtime: ConditionalHookRuntime,
  dispatcher: ConditionalHookDispatcher,
): ConditionalHookDispatcher =>
  new Proxy(dispatcher, {
    get: (target, property, receiver) => {
      if (property === "useState") {
        return (initialState: unknown) =>
          readStateCell(
            getAutomaticHookKey(runtime, "useState"),
            initialState,
          )
      }

      if (property === "useReducer") {
        return (
          reducer: (state: unknown, action: unknown) => unknown,
          initialState: unknown,
          initialize?: (value: unknown) => unknown,
        ) =>
          readReducerCell(
            getAutomaticHookKey(runtime, "useReducer"),
            reducer,
            initialState,
            initialize,
          )
      }

      if (property === "useEffect") {
        return (
          create: () => void | (() => void),
          dependencies: readonly unknown[] | undefined,
        ) =>
          registerEffect(
            getAutomaticHookKey(runtime, "useEffect"),
            "effect",
            create,
            dependencies,
          )
      }

      return Reflect.get(target, property, receiver)
    },
  })

The application still calls React.useState(0). React consults its active dispatcher, and the dispatcher proxy privately routes the call into Bippy.

This does not mutate the exported React object.

The automatically handled APIs are:

  • useState
  • useReducer
  • useRef
  • useMemo
  • useCallback
  • useEffect
  • useLayoutEffect

useContext is forwarded to React's context reader. useDebugValue is accepted as a no-op. Other hooks continue through React's dispatcher unchanged.

4. Derive hook identity automatically

React normally identifies a hook by its position in the hook list. Bippy instead creates an internal identity from the application callsite.

The default resolver captures a stack and selects the first frame outside React, Bippy, and bundled React runtime files:

const defaultHookKeyResolver = (
  hookName: string,
  stack: string,
): PropertyKey => {
  const callsite = stack
    .split("\n")
    .map((line) => line.trim())
    .find((line) => isApplicationCallsite(line))

  if (!callsite) {
    throw new Error(
      "Could not derive an automatic hook callsite.",
    )
  }

  return hookName + ":" + callsite
}

A source line can execute repeatedly, such as in a loop. A render-local occurrence counter distinguishes those calls:

const getAutomaticHookKey = (
  runtime: ConditionalHookRuntime,
  hookName: string,
): PropertyKey => {
  const { frame } = getScope()
  const callsite = runtime.getHookKey(
    hookName,
    new Error().stack ?? "",
  )
  const occurrence = frame.callCounts.get(callsite) ?? 0

  frame.callCounts.set(callsite, occurrence + 1)

  return "react:" + String(callsite) + ":" + occurrence
}

Consumers do not pass these keys. They are an internal implementation detail.

5. Store cells beside the Fiber

React stores its positional hooks in fiber.memoizedState. This runtime deliberately does not add anything to that linked list.

Instead, each component Fiber owns a private scope:

interface ConditionalHookScope {
  cells: Map<PropertyKey, ConditionalHookCell>
  effects: Map<PropertyKey, ConditionalEffectCell>
  fiber: Fiber
  didCommit: boolean
  didUnmount: boolean
  renderer: ReactRenderer
}

const scopeByFiber = new WeakMap<Fiber, ConditionalHookScope>()

A state cell contains the state value and stable dispatch function:

interface ConditionalStateCell {
  kind: "state"
  value: unknown
  dispatch: (action: unknown) => void
}

A reducer cell additionally contains its current reducer and queued actions. Ref and memo cells contain their corresponding values and dependencies.

React swaps between a current Fiber and a work-in-progress alternate. Both Fiber objects are associated with the same scope:

const associateScopeWithFiber = (
  scope: ConditionalHookScope,
  fiber: Fiber,
): void => {
  scope.fiber = fiber
  scopeByFiber.set(fiber, scope)

  if (fiber.alternate) {
    scopeByFiber.set(fiber.alternate, scope)
  }
}

That preserves state across renders while keeping separate mounted component instances isolated.

6. Read or create a state cell

The private state path is:

const readStateCell = <State>(
  key: PropertyKey,
  initialState: State | (() => State),
): [State, React.Dispatch<React.SetStateAction<State>>] => {
  const { frame, scope } = getScope()

  let cell = frame.cells.get(key) ?? scope.cells.get(key)

  if (!cell) {
    const stateCell = {
      kind: "state",
      value:
        typeof initialState === "function"
          ? initialState()
          : initialState,

      dispatch: (action: unknown) => {
        if (scope.didUnmount) {
          return
        }

        const nextValue =
          typeof action === "function"
            ? action(stateCell.value)
            : action

        if (Object.is(stateCell.value, nextValue)) {
          return
        }

        stateCell.value = nextValue
        scheduleScopeUpdate(scope)
      },
    }

    cell = stateCell
    frame.cells.set(key, stateCell)
  }

  return [cell.value, cell.dispatch]
}

Consequences:

  • The initializer runs only when an automatically derived callsite has no cell.
  • Skipping a branch does not delete its state cell.
  • Re-entering the branch recovers that cell.
  • Equal updates do not schedule a render.
  • A setter captured before unmount becomes inert.
  • Render-phase updates are queued in the temporary render frame.

Reducers follow the same lookup model and replay pending actions with the reducer from the render processing them.

7. Schedule the owning component

Because these cells are outside React's normal hook queue, a setter must explicitly schedule the owning Fiber:

const scheduleScopeUpdate = (
  scope: ConditionalHookScope,
): void => {
  if (scope.didUnmount) {
    return
  }

  const currentFiber = getCurrentFiberBranch(scope.fiber)

  currentFiber.memoizedProps = {
    ...currentFiber.memoizedProps,
  }

  if (isMemoFiber(currentFiber)) {
    currentFiber.pendingProps = {
      ...currentFiber.pendingProps,
      __bippyConditionalHookUpdate: nextUpdateVersion(),
    }
  }

  scope.renderer.scheduleUpdate(currentFiber)
}

The props changes work around React bailouts and React.memo. They ensure React notices a side-table update even when ordinary props are referentially unchanged.

8. Keep renders transactional

Each render writes into a temporary frame:

interface ConditionalRenderFrame {
  callCounts: Map<PropertyKey, number>
  cells: Map<PropertyKey, ConditionalHookCell>
  effects: Map<PropertyKey, ConditionalEffectRegistration>
  renderPhaseUpdates: Map<PropertyKey, unknown[]>
  scope: ConditionalHookScope
}

Only a successful commit promotes the frame:

const commitRoot = (root: FiberRoot): void => {
  traverseFiber(root.current, (fiber) => {
    const frame = renderFrameByFiber.get(fiber)

    if (!frame) {
      return
    }

    renderFrameByFiber.delete(fiber)
    commitRenderFrame(frame)
  })

  updateHiddenTreeVisibility()
}

If React abandons a render because it suspends, throws, or is replaced by newer work, that frame is never promoted. Its temporary state and effects cannot leak into the committed tree.

9. Reconcile effects after commit

Each successful render records the conditional effects that actually ran. Commit compares the new registrations with the previous committed effects:

for (const [key, previousEffect] of scope.effects) {
  if (!frame.effects.has(key)) {
    runEffectCleanup(previousEffect)
    scope.effects.delete(key)
  }
}

for (const [key, nextEffect] of frame.effects) {
  const previousEffect = scope.effects.get(key)

  if (
    previousEffect &&
    areDependenciesEqual(
      previousEffect.dependencies,
      nextEffect.dependencies,
    )
  ) {
    continue
  }

  if (previousEffect) {
    runEffectCleanup(previousEffect)
  }

  const committedEffect = createEffectCell(nextEffect)
  scope.effects.set(key, committedEffect)
  startEffect(scope, key, committedEffect)
}

This gives the expected branch lifecycle:

disabled → enabled
  create cells
  start effects

enabled → enabled, equal dependencies
  reuse cells
  retain effects

enabled → enabled, changed dependencies
  reuse cells
  clean up and restart changed effects

enabled → disabled
  retain state-like cells
  clean up effects missing from the render

component unmount
  clean up every effect
  delete the entire Fiber scope

The runtime also handles Strict Mode replay and hidden Suspense/Activity subtrees.

Full event sequence

React selects its render dispatcher
  ↓
the dispatcher setter captures the current Fiber
  ↓
Bippy creates a temporary render frame
  ↓
the component calls React.useState() inside a branch
  ↓
the dispatcher proxy derives callsite + occurrence
  ↓
the private state reader finds or creates the cell
  ↓
the button calls the cell's dispatch function
  ↓
dispatch updates the cell and schedules its Fiber
  ↓
React renders and commits
  ↓
Bippy promotes the frame and reconciles effects

Public API

The public surface is intentionally small:

interface ConditionalHooksInstallation {
  readonly supportedRenderers: number
  (): void
}

interface ConditionalHooksOptions {
  getHookKey?: (
    hookName: string,
    stack: string,
  ) => PropertyKey
}

const installConditionalHooks = (
  options?: ConditionalHooksOptions,
): ConditionalHooksInstallation

Normal usage needs no options:

const dispose = installConditionalHooks()

// Later, if needed:
dispose()

supportedRenderers is a live count. The optional resolver customizes automatic callsite derivation globally; it does not add a per-hook keyed API.

Test coverage

The 86 focused, stress, adversarial, and React-upstream-derived cases all exercise ordinary React.use* calls. They cover:

  • branch entry, exit, and re-entry;
  • state retention and component-instance isolation;
  • reducers, refs, memoization, callbacks, and effects;
  • functional and render-phase updates;
  • Strict Mode render and effect replay;
  • Suspense, aborted renders, errors, and retries;
  • transitions, deferred values, external stores, and optimistic state;
  • portals, nested roots, memoized components, and unmounts;
  • effect ordering and hidden Activity trees;
  • repeated automatic callsites in loops;
  • stale setters and installation disposal.

Validation:

vp check
vp run bippy#test
vp run bippy#build
vp run conditional-hooks-playground#build
git diff --check

Result: 642 tests passed, 1 expected failure, and 3 skipped.

Known boundaries

  • Development renderer only; the required helpers are absent from production renderer injection.
  • Private React and DevTools internals may change between React releases.
  • Bundlers and source transforms can rewrite stack-derived callsites. The optional global resolver can customize automatic callsite identity when needed.
  • Linters still enforce the official Rules of Hooks and do not understand this experiment.
  • Custom passive effects cannot be synchronously force-flushed before a later layout commit.
  • Errors from custom passive effects cannot enter React's commit-phase error-boundary handling through this DevTools API.
  • This is research code, not a recommended application architecture.

@vercel

vercel Bot commented Jul 19, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
bippy Ready Ready Preview, Comment Jul 19, 2026 8:04am

@changeset-bot

changeset-bot Bot commented Jul 19, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 89e9ef1

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Jul 19, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/bippy@74

commit: 89e9ef1

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant