# React from first principles

> A deep dive into React’s programming model, reconciliation, Fiber, and renderers.

Published Sep 1, 2026.

This is a deep dive into everything I know about React’s internals. I wrote it because:

1.  In order to properly understand how a technology works, I think you should break it down, hack it, and build it from scratch. As React (or a React-like API) will become the default way of building UI. It’s also a great way to reason about new technology, both inside and outside of UI.
2.  I also think most of the articles alongside (except for [Build Your Own React](https://pomb.us/build-your-own-react/) and [jser.dev](https://jser.dev/)) are pretty outdated or just blogslop. This article is intended to zoom way out (understand from first principles) and zoom way in (hack into the react internals)

Interested? Read on…

## Ask an agent

```
Fetch and read https://aidenybai.com/blog/react.md. Clone https://github.com/react/react and https://github.com/aidenybai/bippy into /tmp. Use ASD-STE100 Simplified Technical English. Answer any questions or build any mini tools or visualizations to help me understand React.
```

## React as a UI runtime

React is arguably the most popular JavaScript library for building user interfaces. I think the genius behind React is that it’s a [great programming model for building UI](https://overreacted.io/react-as-a-ui-runtime/), it was so early to many of the foundational concepts, `ui = fn(state)` , one-way data flow, functional components, hooks, etc.

As such, it’s the most widely used web framework on the modern web, and has infiltrated itself into mobile apps ([React Native](https://reactnative.dev/)), 3D ([React Three Fiber](https://r3f.docs.pmnd.rs/)), and general programming logic ([react-nil](https://github.com/pmndrs/react-nil)), etc. There are [a lot more React renderers](https://github.com/chentsulin/awesome-react-renderer).

Agents have pushed this even further. React is widely used among open source projects and is therefore the most in-distribution tech stack. As vibe coding ramps up, React becomes the de-facto UI runtime.

React is usually associated with websites, but React does not actually know what a `div` is. The reconciler figures out a host tree, then the renderer decides what that tree means. React DOM makes DOM nodes, React Native makes native views, React Three Fiber makes Three.js objects, etc.

This is where the word “tree” starts getting confusing because there are actually 3 of them. React elements are the disposable descriptions returned from components. Fibers are how React remembers those components between renders. Host instances are the real output, like an `HTMLButtonElement`. These are related, but they are not the same thing.

```
const root = createRoot(document.querySelector("#app"));
root.render(<App />);

// The renderer implements operations like:
createInstance("button", props);
appendChild(parent, button);
commitUpdate(button, oldProps, newProps);
```

When we call `createRoot`, React creates a root object and a HostRoot Fiber. `root.render` puts our first element tree onto that root. The reconciler figures out what changed, then asks the renderer to create, update, insert, or remove the real host instances. Most renderers mutate the existing tree, but they do not have to. A persistent renderer can clone the changed parts and replace the root. The component model above it is still React.

## Building React from scratch

Let’s start with function components. If I were trying to build React from scratch, the first thing I would do is take a function and call it. The function returns some UI, and then I can recursively walk through whatever it returns.

```
const Button = ({ label }) => {
  return <button>{label}</button>;
};
```

JSX makes this look like the browser somehow understands components, but it is mostly just a transform. The inspector below reads the type, key, and props from the actual React element created by that JSX.

```
<button className="blue">Save</button>
```

If the type is a string, the renderer can create a host element like a DOM node. If the type is a function, it calls the function and keeps walking. This gives us JSX, function components, and composition without needing much machinery at all.

Things get more interesting once the UI changes. Recreating the whole page every time would lose focus, selection, scroll state, and any state stored inside the host environment. So we need some representation of what was rendered before, then we can compare it against what the component returns next.

## Calling components

For our tiny React, calling the component ourselves works. But this is not actually how you use a component in React. You write `<Page />` and let React call it.

```
Page({ user });        // Calls Page right now
<Page user={user} />; // Describes Page to React
```

Calling `Page()` skips the component boundary. React only sees the elements it returns, so Page cannot own state.

`<Page />` does not call anything. JSX creates an object with Page as its type and hands that object to React. React can then create a Fiber for Page and call it whenever it gets to that part of the tree. This is basically inversion of control: instead of us walking the whole component tree, React does it for us.

We get another useful property from this. Components are lazy. Creating `<Comments />` creates the description, but it does not run the Comments function yet.

```
const Page = ({ user, children }) => {
  if (!user.isLoggedIn) return <Login />;
  return <Layout>{children}</Layout>;
};

<Page user={user}>
  <Comments />
</Page>
```

If the user is logged out, Page returns before React ever reaches Comments. If we wrote `Comments()` instead, JavaScript would run it before Page had a chance to decide whether it needed those children. The element object lets React choose when, or if, that work happens.

The weird part is that once React controls the call, we cannot assume a render only happens once. React can call a component, pause halfway through the tree, and throw the entire result away. If we change the DOM or start a subscription while rendering, that abandoned render still leaks into the real app. This is why components need to be idempotent.

Local mutation is still fine. In the example below, the array only belongs to this one render. We can push into it because nothing else can see it until we return it.

```
const FriendList = ({ friends }) => {
  const items = [];
  for (const friend of friends) {
    items.push(<Friend key={friend.id} friend={friend} />);
  }
  return <section>{items}</section>;
};
```

This is also why Strict Mode intentionally calls render logic more than once in development. It is annoying when some code was relying on running once, but that is exactly the code React is trying to find. A render that gets thrown away should leave nothing behind.

## Adding state

The first `useState` I would build is just an array and an index. Every Hook reads the next slot, then we reset the index before rendering the component again.

```
const hooks = [];
let hookIndex = 0;

const useState = (initialValue) => {
  const currentIndex = hookIndex++;
  hooks[currentIndex] ??= initialValue;

  const setState = (action) => {
    enqueueStateUpdate(currentIndex, action);
    scheduleRender();
  };

  return [hooks[currentIndex], setState];
};
```

Obviously, one global array falls apart as soon as we have more than 1 component. Each component needs its own slots. React puts the first Hook on that component’s Fiber, and every Hook points to the next one. Right before calling the component, React sets the current Fiber and starts walking its Hook list.

```
const renderWithHooks = (fiber) => {
  currentlyRenderingFiber = fiber;
  currentHook = fiber.alternate?.memoizedState ?? null;
  workInProgressHook = null;
  fiber.memoizedState = null;

  return fiber.type(fiber.pendingProps);
};

const useState = (initialState) => {
  const hook = getNextHook(initialState);
  hook.memoizedState = processUpdateQueue(hook, renderLanes);
  return [hook.memoizedState, hook.queue.dispatch];
};
```

This is why state and effect Hooks have to stay in the same order. React does not know your variable names. It just moves to the next item in a linked list. Put one behind a condition and every Hook after it starts reading the wrong item. The newer `use` API gets to break this rule because it does not create a normal Hook node. Custom Hooks do not get their own storage either. They share logic, but their Hooks still get added to the Fiber that called them.

The confusing part is that state is a snapshot, not a normal mutable variable. `setCount` queues work for a future render. It cannot change the `count` inside the event handler that is already running.

```
const handleClick = () => {
  setCount(count + 1);
  setCount(count + 1);
  setCount(count + 1);
};
```

If `count` is 0, all 3 calls queue the exact same thing: “replace it with 1.” React batches them into one render, so we end up at 1, not 3. Updater functions work because React runs each one against the result of the previous one:

```
setCount((currentCount) => currentCount + 1);
setCount((currentCount) => currentCount + 1);
setCount((currentCount) => currentCount + 1);
```

Now we get 3. Modern React batches updates from events, promises, timers, etc. that target the same root. When the batch ends, React picks the lanes to work on, processes the queue, and renders another snapshot. The state stays attached to that Fiber position until the type or key changes. Change either one and React creates a new Fiber with new state.

## Adding context

Context looks like a global variable, and at the center of it, it kind of is. `createContext` returns one shared object with a field holding the current value. React keeps 2 value fields so a primary and secondary renderer can use the same context at once.

```
const createContext = (defaultValue) => {
  const context = {
    $$typeof: REACT_CONTEXT_TYPE,
    _currentValue: defaultValue,
    _currentValue2: defaultValue,
    Provider: null,
    Consumer: null,
  };

  context.Provider = context;
  context.Consumer = {
    $$typeof: REACT_CONSUMER_TYPE,
    _context: context,
  };
  return context;
};
```

The clever part is how a Provider makes that basically-global value local to one branch. When`beginWork` enters a Provider, React pushes the old value onto a stack and replaces it. When `completeWork` leaves, React pops the old value back. Nested Providers work naturally because the traversal is already going in stack order.

```
const pushProvider = (providerFiber, context, nextValue) => {
  push(valueCursor, context._currentValue, providerFiber);
  context._currentValue = nextValue;
};

const popProvider = (context, providerFiber) => {
  context._currentValue = valueCursor.current;
  pop(valueCursor, providerFiber);
};
```

`useContext` could just return that current value, but then React would have no idea who needs to update. So while it reads the value, it also adds a dependency to the Fiber currently rendering. The dependency remembers the context object and the exact value we saw.

```
const readContextForConsumer = (consumerFiber, context) => {
  const value = context._currentValue;
  const dependency = {
    context,
    memoizedValue: value,
    next: null,
  };

  appendContextDependency(consumerFiber, dependency);
  return value;
};
```

When the Provider changes, React compares the values with `Object.is`, walks down looking for that context dependency, and marks every matching consumer with the current lanes. It also marks `childLanes` all the way back up. Otherwise a memoized parent could bailout and accidentally hide the context update below it.

```
if (!Object.is(oldValue, newValue)) {
  for (const consumer of findContextConsumers(providerFiber, context)) {
    consumer.lanes |= renderLanes;
    if (consumer.alternate) consumer.alternate.lanes |= renderLanes;
    scheduleContextWorkOnParentPath(consumer.return, renderLanes);
  }
}
```

The real implementation lives in [ReactFiberNewContext](https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiberNewContext.js). It is a lot more code, but the idea is still pretty small: a stack tells React which value is in scope, and dependency lists tell it which Fibers read that value.

## Adding effects

We can build `useEffect` with basically the same trick. Instead of putting state in the Hook slot, we keep the last dependencies and the cleanup function. On the next render, we compare the dependencies with `Object.is`.

```
const pendingEffects = [];

const useEffect = (effect, dependencies) => {
  const currentIndex = hookIndex++;
  const previous = hooks[currentIndex];
  const didChange =
    !previous ||
    !dependencies ||
    dependencies.length !== previous.dependencies?.length ||
    dependencies.some(
      (value, index) => !Object.is(value, previous.dependencies[index])
    );

  if (!didChange) return;

  pendingEffects.push(() => {
    previous?.cleanup?.();
    const cleanup = effect();
    hooks[currentIndex] = { dependencies, cleanup };
  });
};
```

The part that actually makes this an effect is that we do not call it yet. We put it in a queue, finish the render, update the host tree, and then run it. If the old Hook has a cleanup function, we run that before the new effect.

```
const commit = () => {
  updateHostTree();

  for (const runEffect of pendingEffects.splice(0)) {
    runEffect();
  }
};
```

State and effects use the same Hook ordering trick, but they live on opposite sides of the process. We read state while figuring out the UI. We run effects after applying that UI.

Since a Hook is just a call that advances the current Fiber’s Hook list, we can compose them. A custom Hook can use state, context, and effects, but every component calling it still gets its own Hook nodes and its own state.

```
const useDocumentTitle = (title) => {
  useEffect(() => {
    document.title = title;
  }, [title]);
};
```

Every effect belongs to the render that created it. If it reads `count` but says it has no dependencies, the callback keeps seeing the count from that old render. Here we include count, so React replaces the interval when the snapshot changes.

```
useEffect(() => {
  const timer = setInterval(() => console.log(count), 1000);
  return () => clearInterval(timer);
}, [count]);
```

React runs the cleanup before the replacement effect, and runs the last cleanup when the Fiber unmounts. I find it more useful to think of the dependency array as “which snapshot does this effect belong to?” rather than a list of things that magically trigger it.

## The virtual DOM

We can finally call the component again after a state update and get another tree of React elements. This is what people mean by the “virtual DOM,” which honestly makes it sound more magical than it is. It is just a tree of JavaScript objects describing the UI we want next. The objects are immutable and disposable. The Fiber and host instance are what can survive into the next render.

```
// Current render
<main>
  <h1>Count</h1>
  <p>0</p>
</main>

// Next render
<main>
  <h1>Count</h1>
  <p>1</p>
</main>
```

## Reconciliation

People usually explain this as React comparing the old virtual DOM to the new one. That is close enough, but the old side is not really another pile of JSX objects. The new elements are temporary input. The current Fiber tree is what React actually compares them against.

React starts at the root and builds a work-in-progress Fiber tree. When it reaches a function component, it calls it and matches the returned elements against the old child Fibers. The basic rule is surprisingly small:

```
const reconcileChild = (currentFiber, nextElement) => {
  const hasSameIdentity =
    currentFiber.key === nextElement.key &&
    currentFiber.type === nextElement.type;

  if (hasSameIdentity) {
    return reuseFiber(currentFiber, nextElement.props);
  }

  deleteFiber(currentFiber);
  return createFiber(nextElement);
};
```

The type says what this thing is and the key says which sibling it is. If both match, React reuses the Fiber, which is also how the component keeps its state and Hooks. Change either one and React deletes the old subtree and mounts a new one. This is why changing a key resets state.

Empty children still matter here. Keeping a conditional in one slot lets everything after it stay in the same position when the condition flips.

```
<form>
  {showMessage ? <Message /> : null}
  <input />
</form>
```

The input stays in the second slot, so adding Message does not destroy it. Lists are harder because their positions move, which is why they need keys. A key only means something inside one parent, though. React can move `key="notes"` around a list, but moving it from a section into an aside still creates a new Fiber and resets the state. Keys are not global IDs.

Reusing the Fiber does not mean React is done. It still compares props and walks into the children. Along the way it leaves flags behind: placements for new nodes, updates for changed nodes, and a deletion list for anything removed.

> React compares both virtual trees, then commits the difference to the real DOM.

The walk has 2 halves. `beginWork` goes down, calls components, and reconciles children. `completeWork` comes back up, prepares the host instances, and bubbles every child flag into its parent. By the end, the commit can skip most of the tree and only touch the things we marked.

## Reactivity

Something I find really interesting about React is that it gets this far without making your data reactive. React does not wrap every object in a proxy or keep track of the fact that Profile happened to read `user.name`. You can take some random JSON from an API and pass it straight through the tree.

```
const user = await response.json();
root.render(<App user={user} />);
```

This means React schedules components, not individual property reads. If App renders again, React has no idea which part of the new object each child cares about, so by default it goes back through those children. Memoization, context dependencies, and Fiber lanes help React skip a lot of that work, but there is no dependency graph for every value you read while rendering.

Signals basically flip this around. Reading a signal creates a subscription, which means an update can target a much smaller piece of the program. That is great for values changing all the time, but now the runtime has to create and maintain all of those subscriptions. I don’t think either model is strictly better. React makes the work follow the size of the UI, while signals spend more bookkeeping to target updates more precisely. If we really need a fine-grained subscription to an external store, we can bolt it on with `useSyncExternalStore`.

We now have another problem with our tiny React. JSX objects are disposable, but every component needs somewhere to keep its Hooks, pending updates, place in the tree, previous version, and whatever work reconciliation found. That place is a Fiber.

## Fiber

React creates a Fiber for basically everything meaningful in the tree: components, host elements, text, fragments, Providers, Suspense boundaries, and a bunch of weird internal types. A Fiber is both React’s memory for that thing and one unit of work it can process.

The panel below is the actual Fiber inspector I built, pointed at this page. You can search the live tree, select any component, inspect its props and Hooks, or open the raw Fiber itself.

> Interactive Fiber inspector available in the HTML article.

I don’t think memorizing every Fiber field is useful. I group them by what React is trying to remember instead. `tag`, `type`, and `key` identify the node. `pendingProps`, `memoizedProps`, `memoizedState`, and `updateQueue` describe its current and pending data. For a function component, `memoizedState` points to the first hook, and each hook points to the next one.

The 3 pointers I actually care about are `child`, `sibling`, and `return`. They let React go down, across, and back up the tree without keeping the entire traversal on the JavaScript call stack.

```mermaid
flowchart TD
  App -->|child| Main["main"]
  subgraph Siblings
    direction LR
    Header -->|sibling| Counter
    Counter -->|sibling| Footer
  end
  Main -->|child| Header
```

*React follows child pointers down the tree & sibling pointers across it.*

This is the part that makes Fiber different from just recursively calling every component. A recursive call has to finish before it can return. React stores where to go next on the Fiber itself, so it can finish one unit, give control back to the browser, and continue from the same object later. The loop is basically:

```
const performUnitOfWork = (fiber) => {
  const child = beginWork(fiber);
  if (child) return child;

  let completedFiber = fiber;
  while (completedFiber) {
    completeWork(completedFiber);
    if (completedFiber.sibling) return completedFiber.sibling;
    completedFiber = completedFiber.return;
  }

  return null;
};
```

Scheduling gets stored on this tree too. `lanes` says which work belongs to this Fiber, while `childLanes` says there is work somewhere below it. This is how a click can jump ahead of a transition, and how React skips branches with nothing pending.

React also double-buffers the Fiber tree. This is the same basic idea as graphics: keep one buffer on screen, prepare the other one offscreen, then swap them. The current tree describes the UI we already committed. The work-in-progress tree is where React writes the next render.

```
// First render
root.current = treeA;
treeA.alternate = treeB;

// Update
readFrom(treeA);
writeTo(treeB);

// Commit
root.current = treeB;

// Next update: reuse the old buffer
readFrom(treeB);
writeTo(treeA);
```

The 2 versions point at each other through `alternate`. React creates the second Fiber when it first needs it, then keeps flipping the pair instead of allocating another full history on every render. This does not mean we have 2 DOM trees. Both Fibers can point through `stateNode` to the same DOM node.

This is why React can pause or delete work-in-progress without us seeing half a screen. If the render does finish, `flags` and `subtreeFlags` tell the commit exactly what to change. React applies those changes, points the root at the finished tree, and the second buffer becomes current.

## Scheduling updates

We can now follow a `setState` all the way through React. It does not replace state immediately. React creates an update, puts it in the Hook queue, gives it a lane, and marks the path from this Fiber back to the root.

```
setState(nextValue)
  → enqueueUpdate(fiber, nextValue, lane)
  → markUpdateLaneFromFiberToRoot(fiber, lane)
  → ensureRootIsScheduled(root)
  → renderRoot(root, lanes)
  → commitRoot(root)
```

A lane is literally a bit in a bitmask. Different bits represent different classes of work. The Fiber keeps lanes for itself and `childLanes` for anything below it, while the root keeps every lane still pending. React can grab the highest-priority bits and only process work that belongs to them.

React has many more lane bits than this, but shrinking the mask to 4 bits makes the process a lot easier to see:

```mermaid
sequenceDiagram
  participant I as SearchInput
  participant R as Root
  participant T as Results
  I->>R: bubble urgent 0001
  T->>R: OR transition 0100
  R->>I: render 0001 first
  I-->>R: commit, pending 0100
  R->>T: render 0100
  T-->>R: commit, pending 0000
```

*The root keeps both lanes pending, then renders them in priority order.*

This is the real difference between a click and a transition even though both end up calling `setState`. The click can get an urgent lane and commit first. The transition sits in a lower-priority lane, gets interrupted, then continues afterwards without disappearing.

React also cannot just delete the updates it skipped. It keeps a base state, copies skipped updates into a base queue, and replays them when their lanes come around again. That is how 2 priorities can finish at different times without putting state updates in the wrong order.

## Bailouts

React does not blindly call every component on every update. Before calling one, it checks if the props changed and whether this Fiber, or anything below it, has work in the lanes we are rendering. If nothing changed, React can reuse the children and skip the branch.

```
const canSkipComponent =
  oldProps === newProps &&
  !includesSomeLane(renderLanes, fiber.lanes);

if (canSkipComponent) {
  if (!includesSomeLane(renderLanes, fiber.childLanes)) return null;
  cloneChildFibers(currentFiber, fiber);
  return fiber.child;
}
```

If `childLanes` is empty, React skips the whole branch. If something below does have work, React skips this component, clones the child pointers, and keeps going until it finds the Fiber that actually needs attention.

`React.memo` adds another props check at the component boundary, but it is still using this same machinery. It cannot hide the component’s own state or context updates because those mark lanes independently from the parent props.

A bailout is not the same as “nothing changed in the DOM.” React can call a component, walk all of its children, and only then find out the output stayed the same. A bailout means React knew it could skip the work before doing it.

## Render and commit

This gets us to a distinction that I think makes most React internals easier to understand: render and commit are completely different jobs. Render processes queues, calls components, reconciles children, and builds work-in-progress. It is speculative. React can pause it, restart it with different lanes, or delete it without changing the screen.

Commit is the part that becomes real. Once React starts, it cannot yield halfway through and leave the DOM split between 2 renders. It runs the before-mutation work, applies placements, updates, and deletions, switches the root to the finished Fiber tree, then runs refs and layout effects. Passive effects happen afterwards.

```
render phase (interruptible)
  beginWork → completeWork

commit phase (synchronous)
  before mutation
  → mutation
  → root.current = finishedWork
  → layout effects
  → passive effects
```

```mermaid
sequenceDiagram
  participant S as Scheduler
  participant F as Fiber Trees
  participant H as Host & Browser
  S->>F: queues and beginWork
  F->>F: completeWork
  F-->>S: finishedWork and flags
  S->>H: before mutation and mutations
  S->>F: swap root.current
  S->>H: refs and layout effects
  H->>H: paint
  S-->>H: passive effects
```

*Render prepares a finished Fiber tree; commit makes it real without yielding halfway through.*

Effects do not give us an escape hatch into the middle of render. `useLayoutEffect` runs after mutations but before paint, and `useEffect` runs later. The component itself still needs to survive being called more than once without any of those calls committing.

## Suspense

Suspense fits into the same model, but the actual control flow is weirder than “a component throws a Promise.” From component code, we read a thenable with `use` inside a Suspense boundary:

```
const Profile = () => {
  const user = use(userPromise);
  return <h1>{user.name}</h1>;
};

<Suspense fallback={<Spinner />}>
  <Profile />
</Suspense>
```

The actual [React source](https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiberThenable.js) gets even weirder. A pending `use` does not throw the thenable itself. React saves it in a module-level variable, then throws one pre-created `Error` as a sentinel:

```
const SuspenseException = Error(
  "This is not a real error. It interrupts the current render."
);

const trackUsedThenable = (thenable) => {
  if (thenable.status === "fulfilled") return thenable.value;
  if (thenable.status === "rejected") throw thenable.reason;

  suspendedThenable = thenable;
  throw SuspenseException;
};
```

React catches its own Error, checks it by identity, and swaps it back to the thenable it saved. Now the normal exception path sees something with a `then` method, finds the closest Suspense Fiber, puts the thenable in its retry queue, and attaches a ping listener.

```
const handleThrow = (thrownValue) => {
  if (thrownValue === SuspenseException) {
    thrownValue = getSuspendedThenable();
  }

  throwException(root, returnFiber, sourceFiber, thrownValue, lanes);
};

const throwException = (root, returnFiber, sourceFiber, value) => {
  if (typeof value.then === "function") {
    const boundary = findNearestSuspenseBoundary(returnFiber);
    boundary.flags |= ShouldCapture;
    boundary.updateQueue.add(value);
    attachPingListener(root, value, lanes);
  }
};
```

```mermaid
sequenceDiagram
  participant C as Profile Fiber
  participant W as Work Loop
  participant S as Suspense Fiber
  participant R as Root
  C->>W: throw SuspenseException
  W->>W: recover saved thenable
  W->>S: capture boundary
  S->>R: commit fallback
  C-->>R: thenable resolves
  R->>C: schedule retry lane
  C->>R: commit primary tree
```

*React turns its internal error sentinel back into a thenable before capturing the Suspense boundary.*

React does not always throw the primary tree away either. It can keep it under an Offscreen Fiber with all of its state, then decide if those lanes should show the primary tree or the fallback. When the thenable resolves, it pings the root and schedules a retry lane.

During a transition, React can keep the old screen visible while trying the suspended tree in the background. This comes back to render vs commit again: React can do partial, disposable work for as long as it wants, but only a complete tree gets to become visible.

## Error boundaries

Error boundaries use almost the same path. Something throws, React stops that Fiber, and walks back towards a boundary. The difference is what got thrown: a thenable means “try this again later,” while an Error means “this render failed.”

```
const throwException = (thrownValue) => {
  if (isThenable(thrownValue)) {
    captureSuspenseBoundary(thrownValue);
    attachPingListener(thrownValue);
    return;
  }

  captureNearestErrorBoundary(thrownValue);
};
```

A rejected thenable makes this really obvious. The next `use` throws the rejection reason instead of the Suspense sentinel. React stops treating it as loading and keeps walking until it finds an error boundary.

Weirdly, React still does not have a function-component API for declaring an error boundary. We need a class with `getDerivedStateFromError` to pick the fallback or `componentDidCatch` to report the error.

```
class ErrorBoundary extends Component {
  state = { error: null };

  static getDerivedStateFromError(error) {
    return { error };
  }

  componentDidCatch(error, info) {
    reportError(error, info.componentStack);
  }

  render() {
    if (this.state.error) return <ErrorScreen />;
    return this.props.children;
  }
}
```

Internally, React puts a capture update on the class Fiber and retries from there with the fallback state. The boundary catches errors while rendering descendants and running their lifecycle work. It cannot catch an event handler, some detached async callback, server render, or its own render because none of those throws travel through that descendant path.

## Introducing bippy

I created bippy to hack into the React internals of any app and [open sourced it on GitHub](https://github.com/aidenybai/bippy).

bippy starts with the same global hook as React DevTools. Renderers use it to expose the Fiber tree, report commits, and map host elements back to React:

```
const hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
//                         ↑ shared by React, DevTools, and bippy

const rendererId = hook.inject(renderer);
//                             ↑ React DOM, Native, etc.
```

When React DOM, React Native, or another renderer starts, it calls `inject(renderer)` and passes in a private interface:

```
const renderer = {
  version: "19.2.0",
  findFiberByHostInstance(element) { /* element → Fiber */ },
  scheduleUpdate(fiber) { /* ... */ },
  overrideProps(fiber, path, value) { /* ... */ }
};
```

bippy’s part is pretty small conceptually: install the hook and remember every renderer that gets injected. The annoying constraint is timing. The hook has to exist before the renderer starts, otherwise the injection has already happened, which is why the import comes first.

```
import "bippy";
import { createRoot } from "react-dom/client";
```

Of course, bippy is not the only thing touching this global. React DevTools and React Refresh use it too, so the implementation preserves whatever is already installed and reconnects if another tool replaces the hook later.

## Getting a Fiber

bippy wraps the renderer lookup in one API:

```
import { getFiber } from "bippy";

const fiber = getFiber(element);
```

`getFiber` works with DOM elements, native views, canvas objects, or anything else a custom renderer treats as a host instance. The mapping looks like this:

> Interactive DOM-to-Fiber inspector available in the HTML article.

## Zooming back out

This is a lot of machinery, but when you zoom back out, React is still pretty simple: `ui = fn(state)`. You describe the UI, update some state, and React uses Hooks, Fibers, lanes, etc. to keep the host tree in sync.

You do not need to understand Fiber to use React. But it explains a lot of behavior that otherwise feels arbitrary: why state is tied to position, why Hooks need a stable order, why effects run after rendering, and why a transition can be interrupted without showing half of the next screen.

This is why I think building things from scratch is so useful. The tiny version breaks, you figure out which constraint you missed, then you can go into the real source and see how React handles the same problem. It is much easier to understand the implementation once you know why the machinery needs to exist.

This is also how bippy came about. Fiber already had all of this useful information about the running app, but getting to it meant repeating the same hacks.

bippy is the small bridge I wanted. If you are building on React’s internals, use it instead of repeating those hacks yourself.

[GitHub](https://github.com/aidenybai/bippy)
