What Is the Fre bubble() Function? Processing Hook Effects in the Reconciler

The bubble() function in Fre executes component side-effects after reconciliation, running layout effects synchronously while scheduling normal effects to run after the DOM commit phase.

The bubble() function serves as the critical bridge between Fre's render phase and commit phase, ensuring hook effects run with the correct timing semantics. Located in the core reconciler at src/reconcile.ts, this function processes effect hooks collected during component rendering in the frejs/fre repository.

How bubble() Works in Fre's Reconciler

The Fre bubble() function resides in src/reconcile.ts and executes after a fiber's children have been fully reconciled. The reconciler invokes bubble() via the sibling() function, passing the current fiber to process any pending side-effects.

// src/reconcile.ts:32-39
const bubble = (fiber: Fiber) => {
  if (fiber.isComp) {
    if (fiber.hooks) {
      // ① run layout-effects immediately
      side(fiber.hooks.layout)

      // ② schedule normal effects to run after the commit phase
      schedule(() => side(fiber.hooks.effect) as undefined)
    }
  }
}

When bubble() receives a component fiber, it checks for the presence of hooks. If hooks exist, it immediately executes layout effects through the side() helper, then queues normal effects using the schedule() utility to defer execution until after the DOM update completes.

The Side Effect Runner: Understanding the side() Helper

Both effect types delegate to the side() function defined in the same file. This utility manages the cleanup and registration cycle for hook effects:

// src/reconcile.ts:34-38
const side = (effects?: HookEffect[]) => {
  effects.forEach((e) => e[2] && e[2]())   // run cleanup from previous render
  effects.forEach((e) => (e[2] = e[0]())) // store new cleanup function
  effects.length = 0
}

The side() function iterates over an array of HookEffect tuples. For each effect, it first invokes any existing cleanup function stored at index 2 of the tuple, then executes the effect callback at index 0 and stores the returned cleanup function back into index 2. Finally, it clears the effects array to prevent duplicate executions.

Effect Timing: Immediate vs. Scheduled Execution

The Fre bubble() function distinguishes between two effect categories with different execution timing:

  • Layout effects (useLayoutEffect-style): Stored in fiber.hooks.layout, these execute synchronously inside bubble() during the render phase. This ensures measurements or DOM mutations occur before the browser paints.
  • Normal effects (useEffect-style): Stored in fiber.hooks.effect, these are asynchronously scheduled to run after the commit phase completes. This prevents blocking the visual update with non-urgent side-effects.

This implementation mirrors React's commit-after-render model, where layout effects fire immediately after calculating the DOM tree but before painting, while standard effects fire after the visual update is complete.

Practical Code Examples

Using useEffect in a Fre Component

import { useEffect } from "fre"

function Counter() {
  useEffect(() => {
    console.log("Mounted")
    return () => console.log("Unmounted")
  }, [])

  return <div>🧮</div>
}

When Fre renders this component:

  1. updateHook() builds the fiber and records the effect in fiber.hooks.effect.
  2. After children process, sibling() calls bubble(fiber).
  3. bubble() schedules the effect via schedule(() => side(fiber.hooks.effect)).
  4. Following the DOM commit, the callback executes and logs "Mounted".
  5. If the component unmounts, the cleanup function stored at e[2] runs before the next effect execution.

Using useLayoutEffect for Synchronous Measurements

import { useLayoutEffect, useRef } from "fre"

function Measure() {
  const ref = useRef(null)
  
  useLayoutEffect(() => {
    const rect = ref.current?.getBoundingClientRect()
    console.log("size:", rect?.width, rect?.height)
  }, [])

  return <div ref={ref}>Measure me</div>
}

Here, useLayoutEffect stores its effect in fiber.hooks.layout. During the same render pass, bubble() calls side(fiber.hooks.layout) immediately, allowing the component to measure DOM nodes before the browser paints. This synchronous execution matches the semantics of React's useLayoutEffect hook.

Key Source Files

The effect processing system spans several core files in the frejs/fre repository:

  • src/reconcile.ts: Contains the bubble() function and side() helper that orchestrate effect execution during the reconciliation process.
  • src/hook.ts: Defines how useEffect and useLayoutEffect populate fiber.hooks with HookEffect entries during component rendering.
  • src/type.ts: Declares the TypeScript definitions for HookEffect tuples and the HookList structure used by the reconciler.
  • src/schedule.ts: Provides the schedule() function that bubble() uses to defer normal effect execution until after the DOM commit.

Summary

  • The Fre bubble() function lives in src/reconcile.ts and processes hook effects after child reconciliation completes.
  • It executes layout effects synchronously via side(fiber.hooks.layout) during the render phase.
  • It schedules normal effects asynchronously using schedule(() => side(fiber.hooks.effect)) to run after the DOM commit.
  • The side() helper manages the cleanup cycle by calling previous cleanup functions at e[2] before registering new ones.
  • This architecture ensures compatibility with React's effect timing semantics while maintaining a lightweight implementation.

Frequently Asked Questions

Where is the bubble() function defined in Fre?

The bubble() function is defined in src/reconcile.ts at lines 32-39. The reconciler invokes it through the sibling() function after processing a fiber's children, ensuring effects run only once the component subtree has been fully reconciled.

What is the difference between layout effects and normal effects in bubble()?

Layout effects (triggered by useLayoutEffect) execute immediately inside bubble() via side(fiber.hooks.layout), allowing synchronous DOM measurements before painting. Normal effects (triggered by useEffect) are deferred via schedule() to run after the commit phase, preventing blocking of the visual update.

How does bubble() handle effect cleanup?

Through the side() helper function, bubble() first iterates over effects to invoke any existing cleanup functions stored at index 2 of the HookEffect tuple. It then executes the new effect callback at index 0, stores the returned cleanup function back into index 2, and clears the effects array to prevent duplicate processing.

When is bubble() called during the render cycle?

bubble() is called after a fiber's children have been reconciled, specifically within the sibling() function in src/reconcile.ts. This placement ensures that parent component effects execute only after all descendant components have completed their rendering and reconciliation phases.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →