Skip to content

Instantly share code, notes, and snippets.

@tomtheisen
Last active July 21, 2023 04:47
Show Gist options
  • Save tomtheisen/7a59a1c2e6bffbfae9a799bc368047ae to your computer and use it in GitHub Desktop.
Save tomtheisen/7a59a1c2e6bffbfae9a799bc368047ae to your computer and use it in GitHub Desktop.
React Journey

My Journey to the Center of the React

At the end of this, I'll know I failed if I can't write my own useState.

Tutorial

Here we go. The very first tutorial. Should be simple. https://react.dev/learn/tutorial-tic-tac-toe

It has some kind of online sandbox thing. I prefer local dev for this, as I think their sandbox is going to be hiding smoke and mirrors. I want to be able to run react on arbitrary places outside their walled gardens. Fortunately, they have instructions for doing it.

  1. Install Node.js
  2. In the CodeSandbox tab you opened earlier, press the top-left corner button to open the menu, and then choose File Export to ZIP in that menu to download an archive of the files locally
  3. Unzip the archive, then open a terminal and cd to the directory you unzipped
  4. Install the dependencies with npm install
  5. Run npm start to start a local server and follow the prompts to view the code running in a browser

Worked. Hundreds of security flaws are reported by npm. Whatever. The lock file has 25k lines of dependency resolutions.
Looks like they're mostly coming from react-scripts. Ok. That wouldn't got to prod anyway.

Ok, now to load it in a browser.

Failed to compile

./src/App.js
 Line 2:  'React' must be in scope when using JSX  react/react-in-jsx-scope

Search for the keywords to learn more about each error.

Uh, ok. I haven't even done anything yet.

Debugging

App.js looks like this

export default function Square() {
  return <button className="square">X</button>;
}

I tried adding this line to the top import React, { StrictMode } from "react";. Now it works.

Hold up.

I got these files from the sandbox thing. Is it broken there also? No, it is not. So what is that doing? Do I have a different version of node? Did the dependencies get resolved differently?

What is this sandbox anyway?

It's called codesandbox.io. I thought it was just a way to put files in a virtual folder and then load it in a web browser.

No, it is not that. There's a file in here called index.js that has the line import "./styles.css"; and it actually works. That's a css file, and the css rules get applied to the document where this supposed js code is running. I say "supposed js code" because importing css is not actually a thing in javascript. This has to be some kind compilation or pre-processor or something. I tried setting some breakpoints and logging to console. I discovered a crazy nest of generated-looking code. How it got that way is less obvious. I tried removing a bunch of stuff from package.json to try to provoke it into breaking in a more informative way. The root of the issue remains elusive.

I could go down this rabbit hole for days, and this wasn't even the point. Try another way.

Codesandbox.io also offers a thing called a "vanilla js template". Maybe that will provide a clue. Sounds good.

Spoiler: Their "vanilla js" is still importing css. I get that stuff has changed and javascript is fancy now, but I'm absolutely certain that's not a thing you can do in ECMAScript, as defined by spec. I guess vanilla doesn't mean as much as I thought it did.

What have we learned? (so far)

  • codesandbox.io is magic and mysterious. I don't trust it.
  • The react tic tac toe tutorial claims to run locally or in codesandbox.io
  • It doesn't run locally.

I'm not feeling too excited about the differences between these two environments unless I can account for why they exist. I'd rather not do this learning in an environment that's not transferrable to outside the environment. I'm kind of burned out on this for today. I'll come back later.

I can't quit now

Ok, one more try. Stack Overflow. https://stackoverflow.com/questions/42640636/react-must-be-in-scope-when-using-jsx-react-react-in-jsx-scope

There are basically three ideas here.

  1. Just add the import.
  2. Do something with .eslintrc.json
  3. Something about next.js.

I might end up doing the first one. If that's actually the solution, does that mean there's a trivial bug in the very first official react tutorial that prevents it from running? That's kind of hard to believe.

I attempted the second one. I put the file in a couple of places. No effect.

And there's no way I'm messing with next.js. From what I understand it's built on top of react. I wouldn't touch something like that until I understood the foundation.

Another day, more ideas.

Start by restating the problem

The official react tutorial doesn't build because App.js contains JSX, but no React import.

Ideas

  1. It works on codesandbox.io. Chase that down some more.
  • Reverse engineering.
  • Maybe there are docs, but I wouldn't count on it. This type of stuff moves fast.
  1. I read that next.js sometimes doesn't require the imports when bare React does. next.js is newer. Maybe I can find the repo where the tutorial files are, and maybe there used to be an import there. If I can find when that changed, I might be able to see what else changed.
  2. Maybe JSX itself has documentation about when the import is required.
  3. The stack of build tools that I npm installed has to have something for compiling the .js files in the project into actual javascript. Probably babel. Babel probably has docs.
  4. Just internet search for "jsx no import" or something.
  5. I have all the source for all the build tools. I have an entry point for npm start. I could debug the entire stack until I find the component responsible for the compilation failure. That piece could be investigated more closely.

I'll start with the search.

Well, would you look at this April Fools React announcement. This looks promising.

Ok, I'm almost sure this is it. It's a JSX compiler "transform" introduced in React 17. It also requires compatible build tools (babel, typescript, etc).
It generates a different import in the target javascript, when it's working:

import {jsx as _jsx} from 'react/jsx-runtime';

Now I have a more focused question.

Why am I not using "the new JSX transform"?

React version:

PS C:\Users\windo\source\repos\tictactoe> npm list react
[email protected] C:\Users\windo\source\repos\tictactoe
+-- [email protected]
| `-- [email protected] deduped
`-- [email protected]

React looks good. Hopefully, it's the build tool, but I guess I have to figure out what that even is. I invoke it using npm start, which is defined like this.

"scripts": {
    "start": "react-scripts start",

According to npm page, react-scripts is basically the implementation of...

create-react-app

Oh, you again

At least there are docs. https://create-react-app.dev/docs/getting-started/

Ok, I'm looking for some kind of configuration for "a new JSX transform", although it doesn't seem to have a name, unfortunately.

Change of Setting

I'm back at work. I showed the failure to Michael. Surprisingly it failed in a different way than at home! I guess I'm just lucky it didn't start working. Here's the other bug.

./src/App.js
Syntax error: C:/Users/ttheisen/source/tictactoe/src/App.js: Unexpected token (34:5)

  32 | 
  33 |   return (
> 34 |     <>
     |      ^
  35 |       <div className="status">{status}</div>
  36 |       <div className="board-row">
  37 |         <Square value={squares[0]} onSquareClick={() => handleClick(0)} />

It's got a problem with the <> token. It sounds like a lower-version parser, because <> came into existence after <identifier>. That suggests babel version or maybe a babel plugin?

But anyway, I'm putting this on the shelf. Because it's time to

Update from future Tom

I found out why I got a different error message. With Michael, I was attempting to run the finished tutorial. It has more code in it so there's more to go wrong. When I switch back to the starting point, everything lines up with my previous experience.

Just slap it together

On a recommendation from Michael, I will just get it to work somehow. Then I will "circle back" and account for all the things that went wrong. So here's my current list of adjustments.

Adjustments

  1. Add import to App.js: import React from 'react';

And with that, the code is running. I see an X in a box. On with the tutorial. The tutorial explains what the return keyword means. But it glosses over a css import, as if that were totally normal.

The first code change is to add a fragment via <>. Predictably there's another compilation failure.

./src/App.js
Syntax error: C:/Users/windo/source/repos/tictactoe/src/App.js: Unexpected token (4:10)

  2 | 
  3 | export default function Square() {
> 4 |   return <>
    |           ^
  5 |     <button className="square">X</button><button className="square">X</button>
  6 |   </>;
  7 | }

I'll replace it with <React.Fragment>. That's another adjustment.

Adjustments

  1. Add import to App.js: import React from 'react';
  2. Convert <> to <React.Fragment>.

Actually doing the thing.

Tutorial, tutorial... writing code. Passed the part about useState. Nothing about the identity of the component the state is attached to. Apparently the sandbox has some react devtools. I'm not using the sandbox.

I can install it as an add-on though. I never installed the knockout devtools. But I'm not opposed to it as long as I remember to turn it off.

Moving on, now we're hoisting state. Now we're writing the click handler. They're trying to get me to use squares.slice(). I'm writing {...squares, 0: "X"}. Hah.

Now that your state handling is in the Board component, the parent Board component passes props to the child Square components so that they can be displayed correctly.

Hm. I find this use of "passes" to be counter-intuitive, but it is technically correct. Maybe this is my problem? Moving on.

Why immutability is important

Here we go. This is what I'm looking for. From my angle, for data that's modeling UI state that a user is interacting with, immutability seems... actively bad. A user interacts for the purpose of changing the state. The most direct modeling of the action seems to be a mutation.

The tutorial claims two benefits to immutable models.

  1. Ease of building a time-travel-type feature.
  2. Re-rendering can be avoided using cheap equality comparisons to detect when the model changed. There's a link to the memo docs here.

The point about time travel seems to be true as far as it goes. It seems to be a small minority of applications that even want such a feature, but fair enough.

The re-rendering thing seems like a strawman. They seem to be pretending that the alternative to immutable models is brute-force recursive model-walking equality checking. I suppose that if you think that's true, then it would make sense.

Speaking of memo, I want to try something real quick.

let contents = 1;
const MemoTest = memo(() => <p>{++contents}</p>);

I put two of these next to each other just to see what it does. I get 3 and 5. Taking out the StrictMode yields 2 and 3. When I remove the memo, the numbers increase when interacting with the board. Memo in this context actually seems to be operating within the scope of a component instance. This is probably tied into the definition component identity as used to match stored state to provided props. I remember being confused about why memoization would be a react feature, since it's more generally applicable. This must be why. I wonder what would happen if I put a general purpose memoizer on this function. For a function of no arguments, that's pretty simple to do.

const singleton = <p>real</p>;
const RealTrivialMemo = () => singleton;

Imagine my shock when I found out this actually works. So what would happen if I used a real memoizer instead of the react one? What advantage can the react one have? It seems to forget it's cache a lot. Huh.

Ok, more tutorial. Now we're doing the history thing. They have history[history.length - 1]. I guess they don't know about .at().

The board state history is still immutable even though neither of the given reasons seem to apply. Maybe there could be a history of histories? Like a tic tac toe multiverse? I'm going to try making it mutable to see what happens.

Now there's a part about keys. It's talking about controlling the creation and destruction of components. They're getting at a component's identity, but they don't really get to the crux of it. "Using array index as a key is problematic". Oh yeah? What problems? And how can you prevent those problems from happening at other times? And then the tutorial has you just use the list index anyway lol.

Almost there

Anyway, more tutorial.

There's a special piece of code here in the tutorial [...history.slice(0, currentMove + 1), nextSquares]. It's a double allocation! Slice and spread, just like making a sandwich. I'm not going to do it that way.

Done.

Ok, the tutorial's complete. I'm not feeling any more particularly illuminated. I have more questions though. Here are all of them at the moment.

  1. Why is the tutorial broken? How can it be fixed?
  2. Why immutability all the time? It seems like a losing proposition most of the time.
  3. What does memo really do. It seems like you can replace it with a trivial memoizer, without loss of function, at least in my toy case. A trivial memoizer is something that remembers function inputs and then when it sees them again returns the same output as the previous time. I'm not sure what react is doing, but it doesn't seem to be that. (A function of no argument should always get the same result)
  4. What's up with keys? It's part of component identity, but what is the full behavior of component identity tracking. On one side, there's VDOM generation. On the other side its matched to some hook storage structure. The two structures are matched together. Is that algorithm specified?
  5. Bonus Challenge to self: It seems that some problems are better suited to 1-way data binding, and some for 2-way. In react, you only get the 1-way. To what extent is it possible to create a text input component that keeps state in sync with user input and abstracts the boilerplate event handler?

Digging Deeper

I think I'll start with immutability. I'll see if I can provoke React into explaining what's wrong with mutation by mutating stuff. Ok, stuff is throwing in mysterious ways. The debugger is pretty hard to use. I don't understand what bundling has taken place. There'a a practically unlimited number of intentional throws before my code even runs. Wow. 3.75 MB transferred. I'm not debugging through that yet.

Anyway, using a mutable array for the move history is just completely fine. I'm not sure if I'm surprised by this or not. Maybe it's just hard to describe which scenarios it's ok, so they just say "never do it"?

How about 2-way binding?

Let's say I want a text box to be bound to a model variable. The react way has always felt stilted to me. An event handler sets the state causing the component to re-generate. But then replacement is suppressed at reconciliation time. It feels like so many extra steps.

Can I make a thing that just updates a state (or some state like thing) that's readable elsewhere without triggering the whole react ceremony? After all, I got the new state from the input. I don't need to shove it back in. I don't want to put anything back in. Read-only please.

If I just don't use a value property, I can kind of do this. I'm not sure how to provide an initial default.

Ok, I'm at least a little proud of this monstrosity.

function TextInput({initial}) {
  console.log("rendering TextInput");
  const [val, setVal] = useState(initial);

  function handleInput(ev) {
    setVal(ev.target.value);
  }

  let input = val === initial 
    ? <input value={initial} onInput={handleInput} /> 
    : <input onInput={handleInput} />;

  return (
    <div>
      {input}
      <p>The value is { val }</p>
    </div>);
}

And I provoked some juicy stuff from React. Witness:

Warning: A component is changing a controlled input to be uncontrolled. This is likely caused by the value changing from a defined to undefined, which should not happen. Decide between using a controlled or uncontrolled input element for the lifetime of the component. More info: https://reactjs.org/link/controlled-components

Maybe this will be the link that explains everything.

You can optionally specify the initial value for any input. Pass it as the defaultValue string for text inputs.

I thought the reason I can't use class is that it's not a DOM property. defaultValue definitely is not a DOM property.

Warning: Invalid DOM property class. Did you mean className?

Huh. Well, at least there's a way. Let's give it a try then. Sure enough. You can just cut out the conditional then. I'm very curious how that works. How does it know the default applies or does not apply? I suppose it's something along the line of when effects run that have empty dependency lists. I don't really know how that works.

Hooks, hooks everywhere.

I talked to Tim. He convinced me to review all the standard hooks. He did not intend to do this.
All the hooks seem to be subject to the component identity problem. Some are interesting beyond this.

useCallback

In react, sometimes you have to create new closures because your references got stale.
The presence of a new callback can cause DOM thrashing as the diff determines that VDOM changes need to be reconciled. As a perf opimization, you can limit unnecessary callback identity changes.

However, the original closure never would have even gotten stale if state used getters. lol

useDeferredValue

I don't get this at all. It seems to have something to do with suspense. Maybe that's why.
Suspense has its own docs with examples, but none of them seem to include the code that shows what's happening. It has something to do with loading spinners. Whatever. We can already just do those, right?

useId

It's a way of generating unique ids for elements. In my mind, I wonder "how is this better than id={++someGlobal}?". To their credit, they have a "Deep Dive: Why is useId better than an incrementing counter?"

It has something to do with "server rendering".

For hydration to work, the client output must match the server HTML.

It's actually trying to make event handlers transparent across the client/server boundary somehow?
I would generally assume that anything like that would be too brittle to even try.

This is very difficult to guarantee with an incrementing counter because the order in which the client components are hydrated may not match the order in which the server HTML was emitted. By calling useId, you ensure that hydration will work, and the output will match between the server and the client.

Inside React, useId is generated from the “parent path” of the calling component. This is why, if the client and the server tree are the same, the “parent path” will match up regardless of rendering order.

So, using a counter is one way to break server rendering. I would guess that most code I would write would be full of other ways of breaking it anyway. It seems kind of difficult to guarantee that the generated "HTML" is identical. (React uses DOM anyway, not HTML)

But then, there's a part about parent path. I have a sneaky suspicion that has something to do with React's definition of component identity. It might be interesting to try reverse-engineering these values some time.

useLayoutEffect

As far as I can tell, this runs code in between the time that layout calculations are done, and before any DOM changes are visible.

I wonder how they did that... must be a fancy document event or something?

useMemo

There are memo and useMemo. They seem to do the same thing. I read a few paragraphs about why you should use one or another, but I am not getting it.

Update: memo is for components and goes to the trouble of tracking its own dependencies. useMemo is for any values, but you have to pass dependencies in a separate list.

Maybe there's a reason they couldn't just put the good parts of each together.

userReducer

I thought this was a redux thing. But it looks like all the worst stuff about redux is right in react also. So why does redux even exist? I must be missing something. (probably a lot of things)

useRef

Could you make a ref like this?

const [myref] = useState({});
myref.current ??= initial;

And then myref is just the same as a ref, right? It also says this.

The information is local to each copy of your component (unlike the variables outside, which are shared).

I wonder what's considered to be a "copy" and what's not.

let a = <hr />;
let b = a;
return <div>{a}{b}</div>;

Is b a separate copy from a? I suspect it is. I'm not totally sure about that though.

There's a little thing in here about "purity". I understand functional purity, but React has a different take, that I haven't been able to precisely understand.

React expects that the body of your component behaves like a pure function:

The hyperlink reveals this.

In computer science (and especially the world of functional programming), a pure function is a function with the following characteristics:

  • It minds its own business. It does not change any objects or variables that existed before it was called.
  • Same inputs, same output. Given the same inputs, a pure function should always return the same result.

So I guess using setState makes a component impure? They can't really mean that, can they? If so, maybe purity makes more sense than I thought.

useSyncExternalStore

I don't understand the vocabulary used to describe this. At the moment I'm not intersted in any type of synchronization though.

useTransition

This one would be magic. If it worked.

useTransition is a React Hook that lets you update the state without blocking the UI.

It's all still javascript right? The only way you can run javascript without blocking the UI is in a web worker AFAIK. Anyway, I ran the demo, and it totally blocked the UI anyway. Firefox even popped up the little slow script alert. lol

Now I know it exists. At the moment I'm not interested, since the showcase demo doesn't seem to actually work.

Back to the tutorial

I'm going to try to find out why it's broken. Maybe submit an issue report.

My first theory is that the jsx compiler is too old for the syntax transform required. Now I just have to find out what that is to confirm. I'm going to start with 'React' must be in scope when using JSX react/react-in-jsx-scope.

My home base is going to be this announcement. It looks like the transform is supported starting in babel v7.9.0. I'm not even positive if I'm using babel, much less what version it is. Normally, I'd use npm list. There's like dozens of babel things with different names, but no babel. And that might not even be the thing doing the building. Hmmm...

Ok, react-scripts has 6 direct dependencies starting with "babel". One of them is [email protected]. This looks solid. react-scripts has this dependency:

"babel-core": "6.24.1",

That's a pretty precise version. So I probably want to find the oldest react-scripts that has a babel-core dependency >= 7.9.

I had to track it down inside the create-react-app repo. It looks like the versioning is synchronous therewith. Anyway, 3.4.4 is the one I want. https://github.com/facebook/create-react-app/blob/v3.4.4/packages/react-scripts/package.json

Wait, it already has ^5.0.0? Ohhh....

  "dependencies": {
    "react": "^18.0.0",
    "react-dom": "^18.0.0",
    "react-scripts": "^5.0.0"
  },
  "main": "/index.js",
  "devDependencies": {
    "react-scripts": "1.0.0"
  },

It's got two different versions. That's kind of nutty. I didn't know you could do that. I guess I'll update them both to ^5.0.0?

npm install react-scripts@^5.0.0 --save-dev

Bingo. That's it. I got the <> fragment transform too.

Now I will try to report it

Whose fault even is this? Let me retrace my steps. Going back to https://codesandbox.io/s/ljg0t8

Wait, did they fix it?!? I have this package.json in the sandbox.

{
  "dependencies": {
    "react": "^18.0.0",
    "react-dom": "^18.0.0",
    "react-scripts": "^5.0.0"
  },
  "main": "/index.js",
  "devDependencies": {}
}

Maybe it gets inserted when you download it? Yup.

{
  "dependencies": {
    "react": "^18.0.0",
    "react-dom": "^18.0.0",
    "react-scripts": "^5.0.0"
  },
  "main": "/index.js",
  "devDependencies": {
    "react-scripts": "1.0.0"
  },
  "name": "ljg0t8",
  "description": null,
  "version": "0.0.0",
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test --env=jsdom",
    "eject": "react-scripts eject"
  }
}

Well, that explains why it works in the sandbox.

And... the issue is filed.

Elsewhere...

I read the tutorial for SolidJS on my phone while my kids were eating their bedtime snacks. I've gotten accustomed to the slog of making sense of the React docs. By contrast, (most of) the stuff in Solid just makes sense. All the stuff about component identity is gone. Rules of hooks are gone. Reconciliation is gone. Stale references are gone. It still has refs but you never actually need them.

But I'm going to stay focused... for now. Once I can get to the point where I can re-implement useState from scratch, I'll feel like I accomplished something.

Into the core

Today I'm going into react source code to try to figure out where hooks persist stuff. There are probably docs on this stuff, but it always seems evasive on the points I actually care about. At the moment, I want to know the behavior of the state persistence mechanism. I'm talking about persistence between renders. Like, where does useState keep the value from the previous render.

Yesterday, I thought this had to do with reconciliation, but now I'm doubting that. Reconciliation happens after the VDOM is constructed. But in order to do that state must have already been matched with jsx nodes. So there must be a previous phase.

I got the repo cloned. Here I go.

First Impressions

There are a lot of .js files in here. That's not particularly surprising. What's surprising is that they're not javascript. Must be JSX! Nope. It's type annotations. I'm guessing it's Flow, although I haven't used it before. Who thought it was a good idea to use a .js extension for this language?

Contact with useState

Found the primary source at packages\react\src\ReactHooks.js.

export function useState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  const dispatcher = resolveDispatcher();
  return dispatcher.useState(initialState);
}

It looks like the thing I'm after is called a dispatcher, and the hook implemention is completely delegated to one of those.

Dispatchers

One more step.

function resolveDispatcher() {
  const dispatcher = ReactCurrentDispatcher.current;
  if (__DEV__) {
    if (dispatcher === null) {
      console.error(
        'Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for' +
          ' one of the following reasons:\n' +
          '1. You might have mismatching versions of React and the renderer (such as React DOM)\n' +
          '2. You might be breaking the Rules of Hooks\n' +
          '3. You might have more than one copy of React in the same app\n' +
          'See https://reactjs.org/link/invalid-hook-call for tips about how to debug and fix this problem.',
      );
    }
  }
  // Will result in a null access error if accessed outside render phase. We
  // intentionally don't throw our own error because this is in a hot path.
  // Also helps ensure this is inlined.
  return ((dispatcher: any): Dispatcher);
}

I'm not familiar with this syntax. What is that colon doing?

Eureka! It's a cast.

With that in mind, this whole thing resolves to ReactCurrentDispatcher.current on the happy path. So far, so good.

ReactCurrentDispatcher, and current thereof

In react terms, it's just a ref.

const ReactCurrentDispatcher = {
  current: (null: null | Dispatcher),
};

export default ReactCurrentDispatcher;

So it would seem, just prior to the start of component rendering, somewhere, something does something like this.

ReactCurrentDispatcher.current = ???;

That will be the thing that knows the implementation details of state.

So many to choose from

A code search for ReactCurrentDispatcher.current = shows 65 results across 7 files. Many of these probably amount to about the same thing, but I'll need to figure that out.

Excluding stuff with "react-server" or "legacy" in the name cuts it to 3 files. The bulk of what's remaining is in packages\react-reconciler\src\ReactFiberHooks.js. Interesting. I had recently concluded that state identification had to happen prior to reconciliation. But I definitely don't have the whole picture yet. Here are the function definitions that seem good.

  • export function renderWithHooks<Props, SecondArg>
  • function finishRenderingHooks<Props, SecondArg>
  • function renderWithHooksAgain<Props, SecondArg>
  • export function resetHooksAfterThrow()
  • function useThenable<T>(thenable: Thenable<T>)
  • function dispatchSetState<S, A>( fiber: Fiber, queue: UpdateQueue<S, A>, action: A, ) (that trailing comma must be a flow thing, i like it)
  • Then there are like a whole bunch inside a 1kloc+ if (__DEV__) {} block. Maybe I can rule these out.

Looks like __DEV__ is an honest-to-goodness global. It's assigned in at least half a dozen locations, but always from environment variables. Not sure how that works, but hooks work in production, so I think I can safely rule all that out.

That leaves 6 functions, at least one of which has a reference to a dispatcher that has an implementation of state storage.

Based on the name, and exportedness of it, I like the sound of renderWithHooks.

renderWithHooks

~140 lines, most of it comments.

Ok. Looks like this is the business. Happy path. Production.

    ReactCurrentDispatcher.current =
      current === null || current.memoizedState === null
        ? HooksDispatcherOnMount
        : HooksDispatcherOnUpdate;

From context, I'd say the condition is basically testing whether "this" component has rendered before, where "this" refers to React's idea of component identity. Looks like I'm getting closer. There are 3 intereststing variables here.

  1. current is an argument declared to be Fiber | null. I've heard "Fiber" thrown around when talking about the details of how React persists state between renders. It looks like some of the juicy part is already done before this function is entered then?
  2. HooksDispatcherOnMount is a const object literal at file scope.
  3. HooksDispatcherOnUpdate is a const object literal at file scope.

Those last two have mountState and updateState respectively for the setState property. Those will be the implementations I'm looking for. And here they are, courtesy the same file.

function mountState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  const hook = mountStateImpl(initialState);
  const queue = hook.queue;
  const dispatch: Dispatch<BasicStateAction<S>> = (dispatchSetState.bind(
    null,
    currentlyRenderingFiber,
    queue,
  ): any);
  queue.dispatch = dispatch;
  return [hook.memoizedState, dispatch];
}

function updateState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  return updateReducer(basicStateReducer, initialState);
}

It's updateState which is responsible for figuring out where to get the previously setState-ed values. So I'll chase down that one. The type signature reassures me I'm going the right way. It's completely delegated to updateReducer.

updateReducer

Looks like the authors of these things are big believers in super short functions.

function updateReducer<S, I, A>(
  reducer: (S, A) => S,
  initialArg: I,
  init?: I => S,
): [S, Dispatch<A>] {
  const hook = updateWorkInProgressHook();
  return updateReducerImpl(hook, ((currentHook: any): Hook), reducer);
}

And some declarations:

function updateWorkInProgressHook(): Hook
function updateReducerImpl<S, A>(
  hook: Hook,
  current: Hook,
  reducer: (S, A) => S,
): [S, Dispatch<A>]

Looks like I'm gonna need to know what Hook is.

export type Hook = {
  memoizedState: any,
  baseState: any,
  baseQueue: Update<any, any> | null,
  queue: any,
  next: Hook | null,
};

And finally Update.

export type Update<S, A> = {
  lane: Lane,
  revertLane: Lane,
  action: A,
  hasEagerState: boolean,
  eagerState: S | null,
  next: Update<S, A>,
};

It feels like I'm getting closer to the real implementation.

If I might hazard a guess at this point, hooks are stored in a linked list by invocation order, for each component identity. Hook.next contains the subsequent hook in the invocation list.

Ok, back to updateReducerImpl. I've added a file to the gist with the slightly edited source code. I removed some error handling and __DEV__ stuff. You'll note it has a bunch of stuff about "lanes". If that's a react server thing, or for like suspense, or some other esoteric feature, I could ignore it for my happy path investigation. I did a web search and found this blog. Looks like I need to keep it in. It also has a link to a youtube video about setState. I think I'll watch it now. I could use a break from staring at this.

Ok, I watched 20 or 30 minutes of it. I think there are probably dozens of hours. I'm not going to watch all of them now. Or perhaps ever. But I got a little bit of intuition from the video. The persistence structure I'm looking for seems to have something to do with "fiber".

Fiber

Fiber is basically just an interface in a separate file. Nothing about hooks. I'm coming back to updateReducerImpl. Also I'm going to remove some LOC more agressively that seem to be perf optimizations, or otherwise not on the happy path.

Would you look at that. Lane and Lanes are basically flags enums.

export type Lanes = number;
export type Lane = number;
// dozens of consts omitted

Knowing that a lane is a number, some of the comments in updateReducerImpl make more sense.

Anyway, this code has too many variable for my brain. I think I need to get a hello-world under a debug build of react. I'll put it under a debugger, and step through this function.

function updateReducerImpl<S, A>(
hook: Hook,
current: Hook,
reducer: (S, A) => S,
): [S, Dispatch<A>] {
const queue = hook.queue;
queue.lastRenderedReducer = reducer;
// The last rebase update that is NOT part of the base state.
let baseQueue = hook.baseQueue;
// The last pending update that hasn't been processed yet.
const pendingQueue = queue.pending;
if (pendingQueue !== null) {
// We have new updates that haven't been processed yet.
// We'll add them to the base queue.
if (baseQueue !== null) {
// Merge the pending queue and the base queue.
const baseFirst = baseQueue.next;
const pendingFirst = pendingQueue.next;
baseQueue.next = pendingFirst;
pendingQueue.next = baseFirst;
}
current.baseQueue = baseQueue = pendingQueue;
queue.pending = null;
}
if (baseQueue !== null) {
// We have a queue to process.
const first = baseQueue.next;
let newState = hook.baseState;
let newBaseState = null;
let newBaseQueueFirst = null;
let newBaseQueueLast: Update<S, A> | null = null;
let update = first;
do {
// An extra OffscreenLane bit is added to updates that were made to
// a hidden tree, so that we can distinguish them from updates that were
// already there when the tree was hidden.
const updateLane = removeLanes(update.lane, OffscreenLane);
const isHiddenUpdate = updateLane !== update.lane;
// Check if this is an optimistic update.
const revertLane = update.revertLane;
if (!enableAsyncActions || revertLane === NoLane) {
// This is not an optimistic update, and we're going to apply it now.
// But, if there were earlier updates that were skipped, we need to
// leave this update in the queue so it can be rebased later.
if (newBaseQueueLast !== null) {
const clone: Update<S, A> = {
// This update is going to be committed so we never want uncommit
// it. Using NoLane works because 0 is a subset of all bitmasks, so
// this will never be skipped by the check above.
lane: NoLane,
revertLane: NoLane,
action: update.action,
hasEagerState: update.hasEagerState,
eagerState: update.eagerState,
next: (null: any),
};
newBaseQueueLast = newBaseQueueLast.next = clone;
}
} else {
// This is an optimistic update.
throw 'TJT removed this code';
}
// Process this update.
const action = update.action;
if (update.hasEagerState) {
// If this update is a state update (not a reducer) and was processed eagerly,
// we can use the eagerly computed state
newState = ((update.eagerState: any): S);
} else {
newState = reducer(newState, action);
}
update = update.next;
} while (update !== null && update !== first);
if (newBaseQueueLast === null) {
newBaseState = newState;
} else {
newBaseQueueLast.next = (newBaseQueueFirst: any);
}
// Mark that the fiber performed work, but only if the new state is
// different from the current state.
if (!Object.is(newState, hook.memoizedState)) {
markWorkInProgressReceivedUpdate();
}
hook.memoizedState = newState;
hook.baseState = newBaseState;
hook.baseQueue = newBaseQueueLast;
queue.lastRenderedState = newState;
}
if (baseQueue === null) {
// `queue.lanes` is used for entangling transitions. We can set it back to
// zero once the queue is empty.
queue.lanes = NoLanes;
}
const dispatch: Dispatch<A> = (queue.dispatch: any);
return [hook.memoizedState, dispatch];
}

Test app

Here's my test case. No compiler. No hot modules. No transforms. No jsx. No bundler. Just the minimal react app that exercises state.

<!DOCTYPE html>
<html>
    <head>
        <title>hello state</title>
        <script src="node_modules/react/umd/react.development.js"></script>
        <script src="node_modules/react-dom/umd/react-dom.development.js"></script>
    </head>
    <body>
        <div id="root"></div>
        <script>
            const tree = React.createElement(function App() {
                const [clicks, setClicks] = React.useState(0);
                const button = React.createElement("button", { onClick: () => setClicks(clicks + 1) }, "click me");
                return React.createElement("div", {}, "Count so far: ", clicks, React.createElement("br"), button);
            });

            ReactDOM.createRoot(document.getElementById("root")).render(tree);
        </script>
    </body>
</html>

I intend to break in the debugger on useState during mounting, and then during subsequent renders. There should be separate code paths. Both of them should have some reference to the state persistence mechanism. That's what I want to find.

During mount, I think I found something.

if (workInProgressHook === null) {
  // This is the first hook in the list
  currentlyRenderingFiber$1.memoizedState = workInProgressHook = hook;
} else {
  // Append to the end of the list
  workInProgressHook = workInProgressHook.next = hook;
}

This looks like that linked list. I will add another piece of state to see if it goes into the else clause.

const [clicks, setClicks] = React.useState(0);
const [clonks, setClonks] = React.useState(-1);
const button = React.createElement("button", { onClick: () => (setClicks(clicks + 1), setClonks(clonks - 1)) }, "click me");

Yes. That's exactly what it's doing. That means that the head of the linked list is currentlyRenderingFiber$1.memoizedState. This looks like the compiled js, so the identifiers probably aren't exact. But anyway, where did currentlyRenderingFiber$1 come from?

From packages\react-reconciler\src\ReactFiberHooks.js, line 239:

// The work-in-progress fiber. I've named it differently to distinguish it from
// the work-in-progress hook.
let currentlyRenderingFiber: Fiber = (null: any);

There it is. It's a non-exported module scoped variable. It's assigned to only 5 places in source.

  1. renderWithHooks
  2. finishRenderingHooks
  3. renderWithHooksAgain
  4. resetHooksAfterThrow
  5. resetHooksOnUnwind

I've bolded the assignments that can ever be non-null. And I'm pretty sure the "again" one is that StrictMode thing. Not production. I'm not interested.

renderWithHooks

So this function seems to establish the file-scoped fiber variable in which all hook state is stored. So how does it work? The top few lines look like this.

export function renderWithHooks<Props, SecondArg>(
  current: Fiber | null,
  workInProgress: Fiber,
  Component: (p: Props, arg: SecondArg) => any,
  props: Props,
  secondArg: SecondArg,
  nextRenderLanes: Lanes,
): any {
  renderLanes = nextRenderLanes;
  currentlyRenderingFiber = workInProgress;

So the fiber comes in as the second argument. Ok, lets go find the callers.

I traced it up a few frames without change in name or value until I got to one where it's known as unitOfWork. The value passed here is reference identical to the fiber object, so I'm still on the right track.

beginWork$1 = function (current, unitOfWork, lanes) {

Up we go. Eventually we find this.

function workLoopSync() {
  // Already timed out, so perform work without checking if we need to yield.
  while (workInProgress !== null) {
    performUnitOfWork(workInProgress);
  }
}

Huh. What's going on? It's another file-scoped variable in ReactFiberWorkLoop.js.

// The fiber we're working on
let workInProgress: Fiber | null = null;

It's a wild Fiber chase.

There are a total of 12 non-null assignments to this variable in this file. And none anywhere else, obviously. They are all in these functions.

  1. prepareFreshStack
  2. workLoopSync (hello friend)
  3. performUnitOfWork
  4. replaySuspendedUnitOfWork (is that suspense? no thanks)
  5. throwAndUnwindWorkLoop (doesn't sound like a happy path)
  6. completeUnitOfWork
  7. unwindUnitOfWork

I recognize the first 3 from my recent stack traversal. I'll set some more breakpoints on these. Curiously, my react dev build seems to have different assignments than the repo source. Maybe it's tree-shaken or a different version or something. In any case the same variable seems to exist, and seems to have the same purpose, so I can probably still use it.

While setting the breakpoints, I found a function called setCurrentFiber. I'll definitely be coming back to that one...

A Fiber is set

The very first time workInProgress is set, it comes from prepareFreshStack(root, lanes)

var rootWorkInProgress = createWorkInProgress(root.current, null);
workInProgress = rootWorkInProgress;

lanes is 16. root is an object. The original flow source says the type is FiberRoot.

A second Fiber is set

I see another fiber setting in performUnitOfWork. The new value is similar, but has a null stateNode, and has flags 2 instead of 0. stateNode looks very promising.

  1. First call of useState for a given component identity calls mountState.
  2. mountState calls mountWorkInProgressHook to create hook, which has the state in it.
  3. The constructed hook is just on object literal. It's assigned to the memoizedState property of the fiber via the variable currentlyRenderingFiber$1.memoizedState. currentlyRenderingFiber$1 is currently reference identical to workInProgress.

Point of interest: I found a string array hookTypesDev that tracks the hook type names e.g. "useState" for a component rendering.

Second invocation of useState in a rendering follows another path

This time it went down the else.

if (workInProgressHook === null) {
  // This is the first hook in the list
  currentlyRenderingFiber$1.memoizedState = workInProgressHook = hook;
} else {
  // Append to the end of the list
  workInProgressHook = workInProgressHook.next = hook;
}

This is definitively the linked list of state. Going back to the variable declaration in flow source corroborates this.

// Hooks are stored as a linked list on the fiber's memoizedState field. The
// current hook list is the list that belongs to the current fiber. The
// work-in-progress hook list is a new list that will be added to the
// work-in-progress fiber.
let currentHook: Hook | null = null;
let workInProgressHook: Hook | null = null;

This suggests that a "fiber" is approximately one-to-one with a rendered component. So it probably has a hierarchy too.

A third fiber is set

After all the initial rendering useState invocations, performUnitOfWork is setting a new workInProgress fiber. It kind of seems like some react scheduling mumbo jumbo, and not really relevant to state. None of the app code is on the stack.

Running past the breakpoint caused it to get hit again.

A fifth fiber is set

This time it's coming from a siblingFiber in completeUnitOfWork().

if (siblingFiber !== null) {
  // If there is more work to do in this returnFiber, do that next.
  workInProgress = siblingFiber;
  return;
}

Running hits this one again. And again.

An eighth fiber is set

This time it skips the if and makes it to workInProgress = completedWork;. And again. And again. And again.

So, in the end, the total number of fiber changes roughly approximates double the total number of elements rendered. I'm thinking that's significant.

The app is now interactive. I can click a button. So I will. I traced through some breakpoints. It's pretty much just solidifying my understanding. I encountered one new concept: "alternate" Fibers have a ton of properties. One kind of mysterious one is alternate. It's documented in ReactInternalTypes.js.

  // This is a pooled version of a Fiber. Every fiber that gets updated will
  // eventually have a pair. There are cases when we can clean up pairs to save
  // memory if we need to.
  alternate: Fiber | null,

What's next?

  • I'd like to get a slightly more complex hierarhcy of stateful components, and be able to see the entire apps state in a single root fiber object.
  • I'd like to pin down exactly what are the rules that create fibers and/or retrieve existing ones.

New test app

I'm going to change my test app to have more structure in its rendered component tree. The purpose of this is to be able to recognize it more easily when inspecting various data structures in the debugger. I'll also use more distinctive values for the state. I'll use a component hierarchy like this.

  • App
    • A
    • B
      • C
      • D
    • E

Each component type (as determined by function identity) will be used only once. I am interested how component type affects hook persistence as well, but I'll build to that in a controlled way. So I'm starting here.

<!DOCTYPE html>
<html>
    <head>
        <title>hello state</title>
        <script src="node_modules/react/umd/react.development.js"></script>
        <script src="node_modules/react-dom/umd/react-dom.development.js"></script>
    </head>
    <body>
        <div id="root"></div>
        <script>
            function A() {
                const [aState, setAState] = React.useState("init a");
                return React.createElement("div", {id: "a"}, "A: ", aState);
            }

            function C() {
                const [cState, setCState] = React.useState("init c");
                return React.createElement("div", {id: "c"}, "C: ", cState);
            }

            function D() {
                const [dState, setDState] = React.useState("init d");
                return React.createElement("div", {id: "d"}, "D: ", dState);
            }

            function B() {
                const [bState, setBState] = React.useState("init b");
                const c = React.createElement(C, {});
                const d = React.createElement(D, {});
                return React.createElement("div", {id: "b"}, "B: ", bState, c, d);
            }

            function E() {
                const [eState, setEState] = React.useState("init e");
                return React.createElement("div", {id: "e"}, "E: ", eState);
            }

            function App() {
                const a = React.createElement(A, {});
                const b = React.createElement(B, {});
                const e = React.createElement(E, {});
                return React.createElement("div", {}, a, b, e);
            }

            const tree = React.createElement(App);

            ReactDOM.createRoot(document.getElementById("root")).render(tree);
        </script>
    </body>
</html>

My next goal is going to be to find a reference to the root fiber somewhere around the end of rendering of App. Then I want to verify that I can see the state hierarchy matching the components.

Tracking down the source of root

I'm finding something that looks like the root fiber passed to performConcurrentWorkOnRoot(root, didTimeout) as root. But the call site is var continuationCallback = callback(didUserCallbackTimeout); in workLoop(). It must have had its first argument .bind()ed.

Hm.

callback basically comes from peek(taskQueue).callback. taskQueue is a file-scoped array variable. peek appears to be part of a min-heap implmentation, looking at the import it came from. So the next step will be finding who's pushing to the heap. It seems there are only two such call sites.

  1. in advanceTimers()
  2. in unstable_scheduleCallback()

advanceTimers looks like it's some kind of unit-of-work slicing coroutine or scheduling or something. My hope is that what I'm doing is trivial enough that I'm still doing everything fully synchronously. So I'll start with unstable_scheduleCallback.

Sure enough. This one leads back to the caller ensureRootIsScheduled(root, currentTime). And there's the bind. Got it.

newCallbackNode = scheduleCallback$1(schedulerPriorityLevel, performConcurrentWorkOnRoot.bind(null, root));

A few more stack frames up from there, we have this root's origin in updateContainer().

var root = enqueueUpdate(current$1, update, lane);

A breakpoint on this line is hit exactly once during the initial render of the app. This might be the real root.

Root fiber dissection

This root is a big tangled object. My idea was to dump the whole thing as JSON and plain text search it, but it has cycles, so that's a bit non-trivial. Exploring through it by hand might be more enlightening though. I found a connection from my actual app state to the contents of this object.

>> root.current.child.child.child.memoizedState.memoizedState
"init a"

That's the state value from my A component. Promising.

Some time passes...

I think I have the structure figured out. root.current is a Fiber, which basically represents a jsx element or value, approximately. Among many other things, Fiber has these properties.

  • sibling - the subsequent element
  • child - the first child
  • memoizedState - stored state for the first hook

Each one of these is basically a linked list in a separate dimension.

All of this means that it's probably not possible to reimplement useState. In order to track component identity, component rendering establishes a stack of dispatchers, only available in a non-exported file scoped variable. useState depends on that. I don't know a way to get around that. On the other hand, react devtools exists...

So is that it then?

I'm not sure. There are still some unanswered questions.

  • When rendering a component, under what circumstances is a new fiber created?
  • When rendering a component, under what circumstances is an existing fiber used as the dispatcher?
  • If an existing fiber is used, how is it retrieved?

Further questions

More questions came to me, as if in a dream. These are testable scenarios regarding component identity for the purpose of received memoized state. I find React's use of the word "memoized" to be a little confusing. In my mind, functions are memoized, not data. But now I get what they mean. I think.

So anyway, does a component's identity persist when:

  1. It's replaced with null, but then replaced again with something like the original during immediately resolved state updates?
  2. It's replaced by a function component whose function has a different name, but is otherwise identical?
  3. It's replaced by a function component whose function has the same name, but has implementation differences?
  4. It's replaced by a function component whose function implementation just calls the first function?
  5. It's invoked directly? (e.g. { Comp() } vs <Comp />)
  6. A formerly preceding sibling with a key is moved after it?
  7. A formerly subsequent sibling with a key is moved before it?
  8. The key of a preceding sibling changes?
  9. A wrapping fragment is introduced around it?
  10. It's replaced by a function component whose function has the same name and implementation?

Let's answer those questions then.

I'm going to start with a react playground. I'll build locally if anything's interesting enough to debug.

  1. No, but it does if the changes get all the way to the DOM.
  2. No.
  3. Still no. I'm a little surprised at this one.
  4. I'm just going to say no, based on those.
  5. Won't run - Error: Rendered fewer hooks than expected. This may be caused by an accidental early return statement. This provides a clue about what the scope of a fiber is. This suggests it's approximately one-to-one with React.createComponent() calls.
  6. No but interestingly, it does when the keyed sibling is replaced by a tag element with a different name.
  7. Yes.
  8. No.
  9. No.
  10. No. This is kind of wild. Bonus - omg this is the craziest thing I've seen in React, wtf.

Some hypotheses

Ignoring keys for a moment, I think I can begin to hypothesize the necessary circumstances for a rendered function component C2 to have the same "react identity" as component C1 from a previous rendering.

  • The parent of C2 has the same "react identity" as the parent in C1, if either of them have a parent. Fragments count.
  • C1 must also be a function component. The function definition for C1 must be reference identical to the function definition for C2.
  • C2 must have the same child index as C1. Things like { cond ?? <Foo /> } counts for one, regardless of values. Arrays count for one also.

Open questions

  • Does "parent" refer to the nearest enclosing component? Or nearest enclosing JSX element?
  • How do keys affect this?
  • Bonus from left field: would this work? const useRef = () => useState({ current: null})[0];

Keys

I have just a few burning question about key behavior after seeing the results above.

  1. What happens if a keyed component is replaced by an unkeyed component, and a matching key is found a subsequent sibling?
  2. What happens if a keyed component changes its rendering function?

Ok, now I try it out.

  1. The state is moved to the new keyed component. Interestingly, if a keyed component is replaced with an unkeyed one, the state just vanishes.
  2. This just never persists. It looks like component function equality is a hard requirement for component persistence.

Ok, I think I can update my persistence requirements hypotheses for keys.

Persistence hypothesis

Function component C2 is considered to have the same identity for react state purposes as component C1 if and only if it satisfies these criteria.

  • The parent of C2 has the same "react identity" as the parent in C1, if either of them have a parent. Fragments count.
  • C1 must also be a function component. The function definition for C1 must be reference identical to the function definition for C2.
  • If C2 does not have a key:
    • C1 must not have a key.
    • C2 must have the same child index as C1. Things like { cond ?? <Foo /> } counts for one, regardless of values. Arrays count for one also.
  • If C2 does have key:
    • C1 must have a key with a matching value to C2.
    • Neither C1 nor C2 may have any siblings with a matching key.

I've found that react issues a warning if it renders an array that has an element without a key. The behavior of array rendering seems to be very similar to fragment rendering. I wonder if there is a practical difference...

I had an idea!

What if I built an enclave of simplicity, renderable from inside of react? I wouldn't need fiber access.

  • No re-renders.
  • No hooks. (except maybe useRef, not sure if one can be eliminated)
  • There would be a new structure for storing state. Something that tracks reads and writes. Call it a bucket.
  • There could be a read-only version called a calculation with bucket dependencies.
  • All components would be declared with a decorator that would:
    • Ensure all props are buckets or calculations.
    • (if necessary) memoize the component function to ensure no react re-renders
  • UI changes would be made as necessary to DOM elements during bucket or calculation change.

Just by coincidence, after I had this idea, I learned from Ryan Carniato that this is called a "meta-framework", and that in general, they are terrible.

A glimmer of hope?

Perhaps I can still implement useState. I found a spooky property on React that maintains a reference to the current dispatcher. __NEVER_USE_THIS_OR_YOU_WILL_BE_FIRED or something. Whatever. It will be worth it.

But also. React devtools exists. That thing works somehow. It has source code. I lets you walk the whole state. There's a leak somewhere. If they can get the state, I can get the state.

More refinement regarding component identity

My working set of rules for determining component equality on subsequent renders deals with document tree position. It occurred to me, as if in a dream, that this can't be right. Hooks have to be resolved at render time. The rendered subcomponent might not be positioned in the document yet. Hell, there's no way to tell yet, whether it even ever will be.

So I have a new test. Swap the order of two sub-components in the result without swapping their render order. Will the state swap? I think not.

Ok, here's the test.

The state still follows document position!

This seems impossible. When does the render code run? I can instrument the test code with console logging to pin down an order.

I'll do this to create the children.

console.log("pre a");
const a = <Stateful label="A" />;
console.log("between");
const b = <Stateful label="B" />;
console.log("post b");

And then this while resolving state inside that sub-component.

const [value, setValue] = useState('');
console.log("rendering", {label, value});

And the result:

pre a
between
post b
rendering
(2) {label: "B", value: ""}
rendering
(2) {label: "A", value: ""}

I know that <Func /> compiles to something like React.createElement(Func, ...), or one of the other similar jsx transforms, like _jsx. What I did not know is that this call does no constitute rendering. Func is not invoked at this time.

Huh. I guess I'll chew on that one for a while.

I suppose I might have been able to realize this when I found that two reference-identical react elements can have different states. It seems I didn't incorporate that observation into my intuition. I can chew on this for a while.

Wait, what?

If the component function isn't invoked at "jsx time", then it's tree children can't be known either. I'm still wrong about something... It seems like the kind of thing that can be bisected using some more playground tests, if I can figure out how to write them.

But no one really writes code like that

I just found this by coincidence in some production-type code by total coincidence.

<FormControlLabel value='bart' control={<Radio />} label='Bart' />

Could I change that element to be conditionally rendered? What would happen if I did? I don't know.

What order does stuff happen anyway?

I instrumented a test app to figure out what order things happen in. https://playcode.io/1531829

It seems basically like the render functions happen in some kind of traversal order. I'm not getting this. But it does seem like component children are not rendered until after their parent is done rendering. Which is not what I thought.

Change of approach (useId)

I'm getting diminishing returns by continuing to pursue the useState avenue. I might come back to it. I'm going to switch to another line of inquiry.

useId. If the name is to be believed, which is definitely not a given, this might actually be a reference implementation of component identity. That's what I'm trying to get anyway. At least at first.

Ok, well I tried it. Output from useId doesn't seem useful for reverse engineering component identity. I was hoping for some hierarchy encoded in the value. But it seems to have none. I also thought the presence of a key would affect it. It does not. It's basically just integers with some formatting.

Another change of approach (devtools)

React has some (semi?) official devtools released as a browser plugin. It's able to read app state as defined by hooks, among other things. This must mean there is some way to extract fibers' memoized state from outside the fiber implementation. I'm going to try to find out how they do it.

Would you look at that, it's actually in the react repo in tree/main/packages/react-devtools-*. Definitely official then. But I still don't think it can use that to get access to private implementations because it's going to be loaded in the global scope or something, with no special access to non-exported variables.

Breakthrough

I found the leak. React devtools has a special global callback for receiving react internals. It's called __REACT_DEVTOOLS_GLOBAL_HOOK__. Now let's see if I can use that to get the current fiber during component rendering.

And here's how I will use it.

<!DOCTYPE html>
<html>
    <head>
        <script>
            let = __REACT_DEVTOOLS_GLOBAL_HOOK__ = {
                supportsFiber: true,
                checkDCE: true,
                inject(internals) {
                    window.reactInternals = internals;
                }
            };
        </script>
        <script src="node_modules/react/umd/react.development.js"></script>
        <script src="node_modules/react-dom/umd/react-dom.development.js"></script>
    </head>
    <body>
        <div id="root"></div>

        <script>
            let mounted = false;

            function App() {
                const [state, setState] = React.useState('tjt initial');
                if (!mounted) {
                    mounted = true;
                    setState(s => s + '+'); 
                }
                console.log(reactInternals);
                return React.createElement('div', {}, "state: ", state );
            }

            const root = ReactDOM.createRoot(document.getElementById("root"));
            root.render(React.createElement(App, {}));
        </script>
    </body>
</html>

This produces two similar outputs.

{bundleType: 1, version: '18.2.0', rendererPackageName: 'react-dom', rendererConfig: undefined, overrideHookState: ƒ, …}

Inside there is a getCurrentFiber(). Getting warm now... Function inspection shows an implementation named getCurrentFiberForDevTools, with an implementation as simple as this.

function getCurrentFiberForDevTools() {
  return ReactCurrentFiberCurrent;
}

That looks like what I want. This should allow me to inspect my app root fiber from my own code. Let's see. Sure enough. I'll try this from inside my component function.

console.log(reactInternals.getCurrentFiber().memoizedState);

Yielding:

{memoizedState: 'tjt initial+', baseState: 'tjt initial+', baseQueue: null, queue: {…}, next: null}

This looks like the biggestt development so far. What other goodies might be here? Here are the keys to the internals object.

  • bundleType
  • version
  • rendererPackageName
  • rendererConfig
  • overrideHookState
  • overrideHookStateDeletePath
  • overrideHookStateRenamePath
  • overrideProps
  • overridePropsDeletePath
  • overridePropsRenamePath
  • setErrorHandler
  • setSuspenseHandler
  • scheduleUpdate
  • currentDispatcherRef
  • findHostInstanceByFiber
  • findFiberByHostInstance
  • findHostInstancesForRefresh
  • scheduleRefresh
  • scheduleRoot
  • setRefreshHandler
  • getCurrentFiber
  • reconcilerVersion
  • getLaneLabelMap
  • injectProfilingHooks

I'm particularly interested in findFiberByHostInstance. Host instance sounds like a DOM node. I got those. It's implementation is named getClosestInstanceFromNode. Even better.

It's pretty long, but the most interesting part is right at the beginning.

// Given a DOM node, return the closest HostComponent or HostText fiber ancestor.
// If the target node is part of a hydrated or not yet rendered subtree, then
// this may also return a SuspenseComponent or HostRoot to indicate that.
// Conceptually the HostRoot fiber is a child of the Container node. So if you
// pass the Container node as the targetNode, you will not actually get the
// HostRoot back. To get to the HostRoot, you need to pass a child of it.
// The same thing applies to Suspense boundaries.
export function getClosestInstanceFromNode(targetNode: Node): null | Fiber {
  let targetInst = (targetNode: any)[internalInstanceKey];

Are you tellin' me I can get the fiber as a property of the component's DOM node. What is internalInstanceKey?

const randomKey = Math.random().toString(36).slice(2);
const internalInstanceKey = '__reactFiber$' + randomKey;

I can deal with that. Let me grab a node from my app and try it out.

> $0
<div>​…​</div>​"state: ""tjt initial+"</div>​

> Object.keys($0)
(2) ['__reactFiber$zyi2wrs26t', '__reactProps$zyi2wrs26t']

It was right there the whole time....

> $0.__reactFiber$zyi2wrs26t
FiberNode {tag: 5, key: null, elementType: 'div', type: 'div', stateNode: div, …}

> $0.__reactProps$zyi2wrs26t
{children: Array(2)}

As a bonus, it looks like I get the props too. Now this is something I can work with.

I found the state.

But can I manipulate it?

A hook of my own

Anyone can make a hook composed of other hooks. I'm going to start breaking all the rules and making some first order hooks of my own. Toward this end, I'm going to go back into the react flow source and try to reread some of it. Perhaps it will be more approachable with some of my recent learnings. I'm going to start with /packages/react-reconciler/src/ReactFiberHooks.js. It seems to have most of the stuff I care about. It's about 4kLOC, and roughly a third of that is in conditional __DEV__ blocks. The dev stuff is good for instrumentation and debugging, but for reading comprehension, I want to make a clone of the file with all the __DEV__ stuff tree-shaken out. I'll be doing this tree shaking by hand.

Side note: I can not get flow syntax working in VS Code. I don't need static analysis. I'd settle for syntax support. Maybe some keyword highlighting. I installed the language plugin. But it just... does nothing. This might not be too hard to convert to typescript...

As a matter of fact, it's not tooo bad. In a couple of hours, I "tree shook" the typescript, and got all the type declarations close enough. I know have a ReactFiberHooks reference source in typescript. All the dev stuff is removed, but the types are more-or-less intact.

Fiber hook implementation, continued

Yesterday, I edited ReactFiberHooks.js for length and types. I did it manually, just to get familiar with the code. I started with 4kLOC, and ended with 2.5kLOC. I still need to marinate in that code for a while. Today I'm going to build a call graph of the file. There are probably some automated static analysis tools to do it. I'm not sure what they are. But I'm going to do it by hand, just to get some more quality time with the code.

This graph captures only "static" dependencies. There are a lot of function pointers flying around. Those aren't represented here. Also, I probably made mistakes. So here's my static call graph of ReactFiberHooks.js with all the dev stuff removed.

export function renderTransitionAwareHostComponentWithHooks() {
renderWithHooks();
}
export function renderWithHooks() {
function renderWithHooksAgain() {}
finishRenderingHooks();
}
export function replaySuspendedComponentWithHooks() {
function renderWithHooksAgain() {}
finishRenderingHooks();
}
function finishRenderingHooks() {
checkIfWorkInProgressReceivedUpdate(); // from './ReactFiberBeginWork'
checkIfContextChanged(); // from './ReactFiberNewContext'
markWorkInProgressReceivedUpdate(); // from './ReactFiberBeginWork'
}
export function TransitionAwareHostComponent() {
useThenable();
}
export function checkDidRenderIdHook() {}
export function bailoutHooks() {}
export function resetHooksAfterThrow() {}
export function resetHooksOnUnwind() {}
function mountOptimistic() {
function mountWorkInProgressHook() {}
}
function updateOptimistic() {
function updateWorkInProgressHook() {}
function updateReducerImpl() {
removeLanes(); // from './ReactFiberLane'
isSubsetOfLanes(); // from './ReactFiberLane'
mergeLanes(); // from './ReactFiberLane'
markSkippedUpdateLanes(); // from './ReactFiberLane'
markWorkInProgressReceivedUpdate(); // from './ReactFiberBeginWork'
}
}
function rerenderOptimistic() {
updateOptimistic();
function updateWorkInProgressHook() {}
}
function createEffectInstance() {}
function mountRef() {
function mountWorkInProgressHook() {}
}
function updateRef() {
function updateWorkInProgressHook() {}
}
function mountEffect() {
mountEffectImpl();
}
function mountEffectImpl() {
function mountWorkInProgressHook() {}
function pushEffect() {}
}
function updateEffect() {
updateEffectImpl();
}
function mountEvent() {
function mountWorkInProgressHook() {}
}
function updateEvent() {
function updateWorkInProgressHook() {}
function useEffectEventImpl() {
function createFunctionComponentUpdateQueue() {}
}
}
function mountInsertionEffect() {}
function updateInsertionEffect() {
updateEffectImpl();
}
function mountLayoutEffect() {
mountEffectImpl();
}
function updateLayoutEffect() {
updateEffectImpl();
}
function imperativeHandleEffect() {}
function mountImperativeHandle() {
mountEffectImpl();
}
function updateImperativeHandle() {
updateEffectImpl();
}
function updateEffectImpl() {
function updateWorkInProgressHook() {}
function areHookInputsEqual() {}
function pushEffect() {}
}
function mountDebugValue() {}
function mountCallback() {
function mountWorkInProgressHook() {}
}
function updateCallback() {
function updateWorkInProgressHook() {}
function areHookInputsEqual() {}
}
function mountMemo() {
function mountWorkInProgressHook() {}
}
function updateMemo() {
function updateWorkInProgressHook() {}
function areHookInputsEqual() {}
}
function mountDeferredValue() {
function mountWorkInProgressHook() {}
}
function updateDeferredValue() {
function updateWorkInProgressHook() {}
updateDeferredValueImpl();
}
function rerenderDeferredValue() {
function updateWorkInProgressHook() {}
updateDeferredValueImpl();
}
function updateDeferredValueImpl() {
includesOnlyNonUrgentLanes(); // from './ReactFiberLane'
claimNextTransitionLane(); // from './ReactFiberLane'
mergeLanes(); // from './ReactFiberLane'
markSkippedUpdateLanes(); // from './ReactFiberWorkLoop'
markWorkInProgressReceivedUpdate(); // from './ReactFiberBeginWork'
}
export function startHostTransition() {
function startTransition() {
getCurrentUpdatePriority(); // from './ReactEventPriorities'
higherEventPriority(); // from './ReactEventPriorities'
setCurrentUpdatePriority(); // from './ReactEventPriorities'
dispatchOptimisticSetState();
dispatchSetState();
now(); // from './Scheduler';
requestAsyncActionContext(); // from './ReactFiberAsyncAction'
dispatchSetState();
setCurrentUpdatePriority(); // from './ReactEventPriorities'
}
}
function mountTransition() {
function mountStateImpl() {
function mountWorkInProgressHook() {}
}
}
function updateTransition() {
function updateState() {
function updateReducer() {
function updateWorkInProgressHook() {}
}
}
function updateWorkInProgressHook() {}
useThenable();
}
function rerenderTransition() {
function rerenderState() {
function rerenderReducer() {
function updateWorkInProgressHook() {}
markWorkInProgressReceivedUpdate(); // from './ReactFiberBeginWork'
}
}
function updateWorkInProgressHook() {}
useThenable();
}
function useThenable() {
createThenableState(); // from './ReactFiberThenable'
trackUsedThenable(); // from './ReactFiberThenable'
}
function useHostTransitionStatus() {
readContext(); // from './ReactFiberNewContext'
}
function mountId() {
function mountWorkInProgressHook() {}
getWorkInProgressRoot(); // from './ReactFiberWorkLoop'
getIsHydrating() // from './ReactFiberHydrationContext'
getTreeId(); // from './ReactFiberTreeContext'
}
function updateId() {
function updateWorkInProgressHook() {}
}
function mountRefresh() {
function mountWorkInProgressHook() {}
}
function updateRefresh() {
function updateWorkInProgressHook() {}
}
function dispatchSetState() {
requestUpdateLane(); // from './ReactFiberWorkLoop'
function isRenderPhaseUpdate() {}
function enqueueRenderPhaseUpdate() {}
enqueueConcurrentHookUpdateAndEagerlyBailout(); // from './ReactFiberConcurrentUpdates'
enqueueConcurrentHookUpdate(); // from './ReactFiberConcurrentUpdates'
scheduleUpdateOnFiber(); // from './ReactFiberWorkLoop'
function entangleTransitionUpdate() {
isTransitionLane(); // from './ReactFiberLane'
intersectLanes(); // from './ReactFiberLane'
mergeLanes(); // from './ReactFiberLane'
markRootEntangled(); // from './ReactFiberLane'
}
markUpdateInDevTools();
}
function dispatchOptimisticSetState() {
requestTransitionLane(); // from './ReactFiberRootScheduler'
function isRenderPhaseUpdate() {}
enqueueConcurrentHookUpdate(); // from './ReactFiberConcurrentUpdates'
scheduleUpdateOnFiber(); // from './ReactFiberWorkLoop'
markUpdateInDevTools();
}
function markUpdateInDevTools() {
markStateUpdateScheduled(); // from './ReactFiberDevToolsHook'
}

More call graph

So I have this dubious call graph thing. I want to organize it some more. Group some stuff. Inline where possible.

The updates are done. I just replaced the file in place. Seemed like a good use case for a mutation. It may or may not be useful, but it's probably not getting much better than this.

But what to do with a call graph?

I'm going to start from some selected entry points, particularly whatever useState does. After stripping out all the guards and logging and blah blah blah, useState is basically this.

export function useState(initialState) {
  ReactCurrentDispatcher.current.useState(initialState)
}

Outside of __DEV__, there are just a few possible consts that ReactCurrentDispatcher.current can be.

  • HooksDispatcherOnMount, delegating useState to mountState
  • HooksDispatcherOnUpdate, delegating useState to updateState
  • ContextOnlyDispatcher, which just throws if useState is invoked
  • HooksDispatcherOnRerender, delegating useState to rerenderState

So there are three possible entry points from a useState invocation: mountState, updateState, rerenderState.

Do I even care about rerender though?

I wonder if I even need to care about "rerender". Maybe I can figure out what that actually is. rerenderState has two callers: replaySuspendedComponentWithHooks and renderAgainWithHooks. I don't care about suspense for now, so it's down to renderAgainWithHooks

In renderWithHooks(), the call to renderWith() is conditional on didScheduleRenderPhaseUpdateDuringThisPass. That is a file scoped variable, which is only set to true during enqueueRenderPhaseUpdate(). That function is only called by dispatchSetState(). And then that one seems to branch off into some dark corners and function pointer references.

I guess I need to care about rerender().

When does the dispatcher get set?

I want to see if I can find a way to get notified when the dispatcher changes, and then just run an app. I'll start with my internals injection.

Oh boy, it totally works. The mere existence of this global variable shows me all the stack traces when the dispatcher changes. I think this technique could be used for getting notified of any ref changes.

const __REACT_DEVTOOLS_GLOBAL_HOOK__ = {
    supportsFiber: true,
    checkDCE: true, // i think stands for "dead code elimination"?
    inject(internals) {
        window.reactInternals = internals;

        let dispatcher = null;
        delete reactInternals.currentDispatcherRef.current;
        Object.defineProperty(reactInternals.currentDispatcherRef, "current", {
            get() { return dispatcher; },
            set(value) { 
                console.trace("somebody setting dispatcher");
                dispatcher = value;
            }
        })
    }
};

I got traces

So I have the simplest react component mounted as an app.

function App() {
    return "hello dispatch";
}

const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(React.createElement(App, {}));

I get 4 stack traces, indicating dispatcher changes during rendering.

index.html:16              	somebody setting dispatcher
set                        	@ index.html:16
pushDispatcher             	@ react-dom.development.js:26346
renderRootSync             	@ react-dom.development.js:26413
performConcurrentWorkOnRoot	@ react-dom.development.js:25748
workLoop                   	@ react.development.js:2653
flushWork                  	@ react.development.js:2626
performWorkUntilDeadline   	@ react.development.js:2920

index.html:16              	somebody setting dispatcher
set                        	@ index.html:16
renderWithHooks            	@ react-dom.development.js:16311
mountIndeterminateComponent	@ react-dom.development.js:20084
beginWork                  	@ react-dom.development.js:21597
beginWork$1                	@ react-dom.development.js:27436
performUnitOfWork          	@ react-dom.development.js:26567
workLoopSync               	@ react-dom.development.js:26476
renderRootSync             	@ react-dom.development.js:26444
performConcurrentWorkOnRoot	@ react-dom.development.js:25748
workLoop                   	@ react.development.js:2653
flushWork                  	@ react.development.js:2626
performWorkUntilDeadline   	@ react.development.js:2920

index.html:16              	somebody setting dispatcher
set                        	@ index.html:16
renderWithHooks            	@ react-dom.development.js:16355
mountIndeterminateComponent	@ react-dom.development.js:20084
beginWork                  	@ react-dom.development.js:21597
beginWork$1                	@ react-dom.development.js:27436
performUnitOfWork          	@ react-dom.development.js:26567
workLoopSync               	@ react-dom.development.js:26476
renderRootSync             	@ react-dom.development.js:26444
performConcurrentWorkOnRoot	@ react-dom.development.js:25748
workLoop                   	@ react.development.js:2653
flushWork                  	@ react.development.js:2626
performWorkUntilDeadline   	@ react.development.js:2920

index.html:16              	somebody setting dispatcher
set                        	@ index.html:16
popDispatcher              	@ react-dom.development.js:26359
renderRootSync             	@ react-dom.development.js:26453
performConcurrentWorkOnRoot	@ react-dom.development.js:25748
workLoop                   	@ react.development.js:2653
flushWork                  	@ react.development.js:2626
performWorkUntilDeadline   	@ react.development.js:2920

I see a matched push and pop, with two renderWithHooks in between. How can we possibly be rendering twice on this? There's like, one thing. I don't have <StrictMode>. Well, I have line numbers, so I can set breakpoints. I'll just find out then.

Ok. Actually those are two traces from the same call. :16311 vs :16355. And on top of that, I'm using the dev build. It doesn't have __DEV__ anywhere in it, which is the flag to enable it, but maybe it got tree-shaken out. Let me see if I can switch to release?

Ok no. Production also means minified. All the variable names are golfed. Nevermind. So I have a call graph for non-__DEV__ code, but the code I'm debugging is dev code. Here's an example from the original flow source.

if (__DEV__) {
  if (current !== null && current.memoizedState !== null) {
    ReactCurrentDispatcher.current = HooksDispatcherOnUpdateInDEV;
  } else if (hookTypesDev !== null) {
    // This dispatcher handles an edge case where a component is updating,
    // but no stateful hooks have been used.
    // We want to match the production code behavior (which will use HooksDispatcherOnMount),
    // but with the extra DEV validation to ensure hooks ordering hasn't changed.
    // This dispatcher does that.
    ReactCurrentDispatcher.current = HooksDispatcherOnMountWithHookTypesInDEV;
  } else {
    ReactCurrentDispatcher.current = HooksDispatcherOnMountInDEV;
  }
} else {
  ReactCurrentDispatcher.current =
    current === null || current.memoizedState === null
      ? HooksDispatcherOnMount
      : HooksDispatcherOnUpdate;
}

All of my source analysis was on the non-dev branches, but the non-minified build I'm actually running has the else block tree-shaken out. It's probably not a dead end, but something to keep in mind. In this example, all the dev things are named the same as the non-dev things, except they have some "DEV" suffix on the end. Hopefully, it's all like that.

Anyway, that's the code right in renderWithHooks() that determines whether a rendering should be dispatched as a mount or update. It depends whether current?.memoizedState is nullish. I think I recognize current as being a fiber. It's also a parameter. I'll walk back up the stack, but I have a feeling it came from the work queue at the very top.

The feeling is wrong. It comes from performUnitOfWork, which workLoopSync runs repeatedly. There's some support for asynchrony somehow, but in the current configuration, it just runs until workInProgress is null. There's not really a work queue, I don't think. It seems like each unit of work is responsible for naming a successor, if any.

Where are all these units of work coming from?

All the "units of work" for the work loop seem to come from a variable called taskQueue. It's a heap, backed by a file scoped array. For my purposes currently, it never seems to have more than a single element in it, which seems to represent the rendering of the App component. That work unit gets enqueued not too far from the original render call.

So far I've found a few interesting functions in the call stacks.

  • updateContainer is the initial entry point
  • ensureRootIsScheduled schedules the singleton "unit of work" (a partially applied performConcurrentWorkOnRoot)
  • performWorkUntilDeadline yields, but then continues work in a subsequent micro task. There seem to be many layers of work queues. This one is pretty far to the outside, seemingly.
  • workLoopSync is another "work loop" appropriately enough. This one doesn't yield. It calls...
  • performUnitOfWork, which gets the alternate fiber from the unitOfWork fiber.

I've seen this alternate thing come up a bunch, but I don't get it. It feels like now's the time to understand it.

Fiber Alternate

This seems like a big enough deal that there might be some react lore around it. Sure enough.

flush To flush a fiber is to render its output onto the screen.

work-in-progress A fiber that has not yet completed; conceptually, a stack frame which has not yet returned.

At any time, a component instance has at most two fibers that correspond to it: the current, flushed fiber, and the work-in-progress fiber.

The alternate of the current fiber is the work-in-progress, and the alternate of the work-in-progress is the current fiber.

A fiber's alternate is created lazily using a function called cloneFiber. Rather than always creating a new object, cloneFiber will attempt to reuse the fiber's alternate if it exists, minimizing allocations.

You should think of the alternate field as an implementation detail, but it pops up often enough in the codebase that it's valuable to discuss it here.

Hm, I might be able to learn enough to understand that, but I'm not quite there. And there are some comments around the flow source:

// This is a pooled version of a Fiber. Every fiber that gets updated will
// eventually have a pair. There are cases when we can clean up pairs to save
// memory if we need to.
alternate: Fiber | null,

That's less helpful. How about cloneFiber? It doesn't exist. This doc must be out of date. Well, what assigns alternate then? Well, there seems to be only a single if branch in a single function that ever assigns a non-null value to this property: createWorkInProgress.

Edited for brevity:

// This is used to create an alternate fiber to do work on.
export function createWorkInProgress(current: Fiber, pendingProps: any): Fiber {
  let workInProgress = current.alternate;
  if (workInProgress === null) {
    // We use a double buffering pooling technique because we know that we'll
    // only ever need at most two versions of a tree. We pool the "other" unused
    // node that we're free to reuse. This is lazily created to avoid allocating
    // extra objects for things that are never updated. It also allow us to
    // reclaim the extra memory if needed.
    workInProgress = createFiber(current.tag, pendingProps, current.key, current.mode);
    workInProgress.elementType = current.elementType;
    workInProgress.type = current.type;
    workInProgress.stateNode = current.stateNode;

    workInProgress.alternate = current;
    current.alternate = work;
  }
  
  // SO MUCH MUMBO JUMBO REMOVED
  
  return workInProgress;
}

createFiber is a pretty shallow initializer-style factory that creates a FiberNode. No fancy stuff. FiberNode is an implementation of the Fiber flow type.

Ok, I think maybe I get it. As the work in a "unit of work" (aka Fiber) gets done, its reprentation may need to be mutated. But the work may also need to be rolled back if it can't be committed. That's why there are two fibers. One is the initial state, saved in case of rollback. On represents the happy path of work so far. And then after it's done there's a separate "flush" state. Maybe that one can be rolled back too. The naive implementation might just keep 3 separate thread state objects, but it's possible with 2. Maybe that's why everythings such a big deal about the "pooling" and so on.

Ok, the first unit of work eventually ends up calling updateHostRoot, which ends up calling reconcileChildren(current, workInProgress, nextChildren, renderLanes) and then returning workInProgress.child. That return value ends up being the next unit of work for workLoopSync.

reconcileChildren

function reconcileChildren(current, workInProgress, nextChildren, renderLanes) {
  if (current === null) {
    // If this is a fresh new component that hasn't been rendered yet, we
    // won't update its child set by applying minimal side-effects. Instead,
    // we will add them all to the child before it gets rendered. That means
    // we can optimize this reconciliation pass by not tracking side-effects.
    workInProgress.child = mountChildFibers(workInProgress, null, nextChildren, renderLanes);
  } else {
    // If the current child is the same as the work in progress, it means that
    // we haven't yet started any work on these children. Therefore, we use
    // the clone algorithm to create a copy of all the current children.
    // If we had any progressed work already, that is invalid at this point so
    // let's throw it out.
    workInProgress.child = reconcileChildFibers(workInProgress, current.child, nextChildren, renderLanes);
  }
}

Here is some code that's partially responsible for determining component identity between renders. I am heartened. In the flow source it's found in packages/react-reconciler/src/ReactFiberBeginWork.js, which is another monster file, weighing in at >100kB for just that file.

But I kind of think this might be the entry point for all reconciliation. If that's true, this might be the key to understanding component identity, and in turn, stateful hooks. (which is most of them I think)

Some light reading

Today, I start with some articles that I now know enough to grok. Maybe I won't even write code.

Sometimes you just need to read the right thing

That last pair of links is most of what I've been needing this entire time. At this point, most of the stuff it said is stuff I already know. But not all of it. I learned a few things.

  • Reconciliation always starts from the root fiber. I thought that it could opportunistically be started from sub-trees as needed. Nope.
  • Rendering is interleaved with reconciliation. React walks the fiber tree and invokes render functions as necessary. There are a bunch of rules about when rendering is or is not necessary.
  • There are only supposed to be three things that can cause an application re-render, assuming all function components.
    1. setState from useState
    2. dispatch from useReducer
    3. something from useSyncExternalStore

My goal is to get a setState replacement working without using any existing hooks. So that doesn't bode well. Also, supposedly createElement returns pretty plain objects with a very small number of properties, entirely consisting of stuff you're likely to have around the house.

I'm gonna check on those.

What does createElement really do?

function createElement(
  type: mixed,
  key: mixed,
  props: mixed,
): React$Element<any> {
  const element: any = {
    // This tag allows us to uniquely identify this as a React Element
    $$typeof: REACT_ELEMENT_TYPE,

    // Built-in properties that belong on the element
    type: type,
    key: key,
    ref: null,
    props: props,

    // Record the component responsible for creating this element.
    _owner: null,
  };
  return element;
}

It looks like the only tricky part is $$typeof. The source is this.

export const REACT_ELEMENT_TYPE: symbol = Symbol.for('react.element');

Cool. I could just use Symbol.for('react.element') from my own code. But who's setting _owner then? There are two functions named ReactElement that take owner as an argument. Looks like some jsx compiler stuff maybe? I'm not going into that rabbit hole at the moment, at least not now.

Ok, so what's this useSyncExternalStore all about then?

I guess this is a relatively recent thing, and it seems to exist basically for the purpose of being able to trigger a render arbitrarily. I'm still going to need to bend the spirit of the intent though.

Time to write some code and see what happens.

Ok, I have a wild idea. Fibers are re-used when possible. Sometimes they end up getting swapped with their alternate, but they seem to be always mutated. And never more than 2 per component "identity". So I can use the fiber object as a key to a Map or WeakMap or something. Still not sure how to trigger a re-render. Maybe something will present itself.

This is really starting to seem like it might work.

Check this out so far.

if (!reactInternals) throw 'getinternals.js needs to be loaded before react';

const fiberStateMap = new WeakMap;
// each fiber has two representations, and by the time i get them they're more or less interchangable.
// i need to arbitrarily pick on of the two to be the key for me weakmap holding state
// this function will pick one
function pickKey(fiber) {
    return fiber.alternate == null || fiberStateMap.has(fiber)
        ? fiber
        : fiber.alternate;
}
function fakeState(state) {
    const key = pickKey(reactInternals.getCurrentFiber());
    if (fiberStateMap.has(key)) {
        state = fiberStateMap.get(key);
    }
    else {
        fiberStateMap.set(key, state);
    }
    function setState(newState) {
        if (typeof newState === 'function') {
            newState = newState(fiberStateMap.get(key));
        }
        fiberStateMap.set(key, newState);
    }
    return [state, setState];
}

My new crappy hook fakeState is kind of a drop-in replacement for useState. But you can't use it more than once per component. Also, it doesn't trigger re-renders. Yet. Maybe I can dig something up from the internals. I see a function in here called scheduleRoot that looks very promising. I'm just going to give it a shot. Ok, that takes root as a parameter, and element as well. How will I get those?

Bad news. scheduleRoot only exists in __DEV__ mode. I'll look for something else.

But another door opens. I found scheduleUpdate, that takes only a fiber. I think I'm in business. Let's give it a try.

Hm. Scheduling an update did not seem to accomplish anything. Maybe I needed to schedule the alternate? Nope.

Oops, it looks like getCurrentFiber is __DEV__-only as well. I'm relying on that pretty hard at this point. I guess I'll just go with it.

Wait,

I can bring my own render function in from the outside.

function render() {
    root.render(React.createElement(App, {}));
}
render();

I can use this function to force arbitrary re-renders.

It works!

Here's the "library code" lol.

{
    const TRACE_ON_DISPATCHER_TRANSITION = false;

    const inject = internals => {
        Object.defineProperty(internals.currentDispatcherRef, "current", {
            get() { return this._current; },
            set(value) { 
                if (TRACE_ON_DISPATCHER_TRANSITION) {
                    console.trace("Dispatcher changed");
                }
                this._current = value;
            }
        });
        return window.reactInternals = internals;
    }

    if (__REACT_DEVTOOLS_GLOBAL_HOOK__) {
        // react devtools already created the global, just MitM the inject call
        const originalInject = __REACT_DEVTOOLS_GLOBAL_HOOK__.inject;
        __REACT_DEVTOOLS_GLOBAL_HOOK__.inject = internals => originalInject(inject(internals));
    }
    else {
        var __REACT_DEVTOOLS_GLOBAL_HOOK__ = { supportsFiber: true, checkDCE: true, inject };
    }
}

const fiberStateMap = new WeakMap;
// each fiber has two representations, and by the time i get them they're more or less interchangable.
// i need to arbitrarily pick on of the two to be the key for me weakmap holding state
// this function will pick one
function pickKey(fiber) {
    return fiber.alternate == null || fiberStateMap.has(fiber)
        ? fiber
        : fiber.alternate;
}
function fakeState(refresh, state) {
    const fiber = reactInternals.getCurrentFiber();
    const key = pickKey(fiber);
    if (fiberStateMap.has(key)) {
        state = fiberStateMap.get(key);
    }
    else {
        fiberStateMap.set(key, state);
    }
    function setState(newState) {
        if (typeof newState === 'function') {
            newState = newState(fiberStateMap.get(key));
        }
        fiberStateMap.set(key, newState);
        refresh();
    }
    return [state, setState];
}

And here's my app code.

const fibers = new Set;
function App() {
    const [count1, setCount1] = React.useState(0);
    const [count2, setCount2] = fakeState(render, 0);

    // inlined createElement, but don't need to
    const btn1 = {
        $$typeof: Symbol.for('react.element'), 
        type: "button", 
        ref: null, 
        props: { onClick: () => setCount1(c=>c+1), children: count1 },
    };
    const btn2 = {
        $$typeof: Symbol.for('react.element'), 
        type: "button", 
        ref: null, 
        props: { onClick: () => setCount2(c=>c+1), children: count2 },
    };
    return [btn1, btn2];
}

const root = ReactDOM.createRoot(rootdiv);
function render() {
    root.render(React.createElement(App, {}));
}
render();

The whole thing works. I'm declaring success.

And yet...

I am unsatisfied about the re-render solution. There should be a way to force a re-render after state update, without getting passed any function references from the fake hook caller. I have an idea about why scheduleUpdate was not re-rendering the component. It may have completed a render pass. So why no render? Possibly because the fiber was unchanged. If I can mutate the fiber in just the right way, I think it may cause the render I'm looking for.

Indeed

Mutating the memoized props object inside the fiber causes the render not to be suppressed during the render phase. It works. The full code is here.

fin

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