How Side Effects Are Managed in Fre Using useEffect and useLayoutEffect

Fre implements a React-compatible hook system where useLayoutEffect runs synchronously before the DOM is painted to prevent visual flicker, while useEffect runs asynchronously after the commit phase via a microtask scheduler.

Side effects are essential for interfacing with external systems in functional components. In the Fre library—a lightweight, fiber-based React alternative—side effects are managed through a unified hook architecture that separates layout effects (synchronous) from passive effects (asynchronous). This design ensures predictable execution timing while maintaining compatibility with React’s mental model.

Hook Registration and Effect Types

Both useEffect and useLayoutEffect in Fre are thin wrappers around a shared helper function called effectImpl. Located in src/hook.ts at lines 61–71, this function registers effect callbacks into the component’s fiber-based hook list.

export const useEffect = (cb: EffectCallback, deps?: DependencyList) =>
  effectImpl(cb, deps!, 'effect');          // ← passive effect

export const useLayout = (cb: EffectCallback, deps?: DependencyList) =>
  effectImpl(cb, deps!, 'layout');          // ← layout effect

When invoked, effectImpl stores a tuple containing the callback, its dependency array, and a placeholder for the cleanup function in current.hooks[key]. The hook list is attached directly to the fiber representing the component, enabling persistent state across renders. The second parameter ('effect' or 'layout') acts as a tag that determines when the callback will be executed during the reconciliation lifecycle.

Dependency Tracking with isChanged

Fre optimizes performance by comparing dependency arrays before queueing effects. Before registration, the system checks whether inputs have changed using the isChanged utility located at lines 42–47 in src/hook.ts.

if (isChanged(hook[1], deps)) {
  hook[0] = cb;
  hook[1] = deps;
  current.hooks[key].push(hook as Required<HookEffect>);
}

The isChanged function performs a shallow comparison of the previous and current dependency arrays. If no changes are detected, the effect is skipped entirely. When dependencies differ, the new callback and array replace the old values in the hook tuple (hook[0] and hook[1]), and the effect is pushed into the fiber’s pending effects list for execution.

Execution Order and Scheduling

During the reconciliation phase, Fre processes effects in a specific sequence to guarantee correct timing. After a component’s render function executes, the reconciler “bubbles” up the fiber tree. In src/reconcile.ts at lines 33–38, the bubble function distinguishes between the two effect types:

side(fiber.hooks.layout);                 // run layout effects now
schedule(() => side(fiber.hooks.effect) as undefined); // schedule passive effects

Layout effects are flushed immediately via the side helper, blocking the main thread until completion. This synchronous execution ensures the DOM has not yet been painted, allowing components to measure layout or adjust styles before the browser displays a frame.

Passive effects are deferred using schedule, which queues them for asynchronous execution. According to src/schedule.ts (lines 17–28), the scheduler uses queueMicrotask, MessageChannel, or setTimeout depending on the environment. This guarantees that passive effects run after the DOM commit phase completes.

Cleanup Handling and Effect Lifecycle

Cleanup functions in Fre follow React’s execution semantics: the previous cleanup runs before the new effect starts. The side function in src/reconcile.ts (lines 34–38) implements this lifecycle:

const side = (effects?: HookEffect[]) => {
  effects.forEach(e => e[2] && e[2]());   // call previous cleanup, if any
  effects.forEach(e => (e[2] = e[0]())); // run the effect and store new cleanup
  effects.length = 0;                    // empty the list for the next render
};

The function iterates through the effect array twice: first invoking any stored cleanup functions (e[2]), then executing the current effect callback and storing its return value (the new cleanup) back into e[2]. Finally, the effects array is truncated to prevent duplicate executions on subsequent renders.

Practical Implementation Examples

Synchronous Layout Measurement

Use useLayout (Fre’s equivalent to useLayoutEffect) when you need to read DOM dimensions or modify styles before the browser paints to prevent visual inconsistency.

import { h, render, useState, useLayout, useEffect } from 'fre';

function Tooltip() {
  const [position, setPosition] = useState({ top: 0, left: 0 });
  const ref = useRef<HTMLDivElement>(null);

  useLayout(() => {
    // Runs synchronously after DOM mutation but before paint
    const rect = ref.current!.getBoundingClientRect();
    setPosition({ top: rect.bottom + 10, left: rect.left });
  }, []);

  return <div style={position} ref={ref}>Content</div>;
}

Asynchronous Data Fetching

Use useEffect for side effects that do not impact initial rendering, such as subscriptions or network requests.

function SearchResults({ query }: { query: string }) {
  const [results, setResults] = useState<string[]>([]);

  useEffect(() => {
    // Runs asynchronously after the commit phase
    const controller = new AbortController();
    
    fetch(`/api/search?q=${query}`, { signal: controller.signal })
      .then(res => res.json())
      .then(data => setResults(data));

    // Cleanup runs before the next effect or unmount
    return () => controller.abort();
  }, [query]); // Only re-run when query changes

  return <ul>{results.map(r => <li key={r}>{r}</li>)}</ul>;
}

Summary

  • useEffect and useLayout in Fre are both implemented via effectImpl in src/hook.ts, differing only in their execution timing tags.
  • Dependency tracking relies on the isChanged function to prevent unnecessary effect re-runs through shallow array comparison.
  • Layout effects execute synchronously during the bubble phase in src/reconcile.ts, ensuring access to the DOM before browser painting.
  • Passive effects are scheduled asynchronously using the microtask queue defined in src/schedule.ts, deferring work until after the commit phase.
  • Cleanup functions are stored in the hook tuple’s third index (hook[2]) and are always invoked before the corresponding effect runs again or the component unmounts.

Frequently Asked Questions

What is the difference between useEffect and useLayoutEffect in Fre?

useLayoutEffect (exported as useLayout) runs synchronously immediately after the DOM is updated but before the browser paints the screen, making it suitable for measuring layout or preventing visual flicker. useEffect runs asynchronously after the paint cycle, which makes it better suited for data fetching, subscriptions, or other side effects that do not require immediate DOM consistency.

How does Fre determine when to re-run an effect?

Fre uses the isChanged utility in src/hook.ts to perform a shallow comparison of the current and previous dependency arrays provided to the hook. If any dependency has changed reference or value, the effect is queued for execution; otherwise, it is skipped entirely for that render cycle.

When are effect cleanups called in Fre?

Cleanup functions are invoked during the side function execution in src/reconcile.ts, specifically in the first loop that iterates through effects. This occurs before the new effect callback runs, ensuring that resources from the previous render (like timers or event listeners) are released prior to setting up new ones. Cleanups also run when the component is unmounted and the fiber is destroyed.

Does Fre support the same scheduling priorities as React's concurrent mode?

Fre uses a simplified scheduling mechanism based on queueMicrotask, MessageChannel, or setTimeout (as implemented in src/schedule.ts), which provides deferred execution for passive effects but does not implement React’s full concurrent priority lanes or time-slicing features. Layout effects remain strictly synchronous and blocking, identical to React’s behavior.

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 →