diff --git a/packages/react-debug-tools/src/ReactDebugHooks.js b/packages/react-debug-tools/src/ReactDebugHooks.js
index 957838ed58a9c..eed8c46df7d6d 100644
--- a/packages/react-debug-tools/src/ReactDebugHooks.js
+++ b/packages/react-debug-tools/src/ReactDebugHooks.js
@@ -341,6 +341,17 @@ function useOpaqueIdentifier(): OpaqueIDType | void {
   return value;
 }
 
+function useId(): string {
+  const hook = nextHook();
+  const id = hook !== null ? hook.memoizedState : '';
+  hookLog.push({
+    primitive: 'Id',
+    stackError: new Error(),
+    value: id,
+  });
+  return id;
+}
+
 const Dispatcher: DispatcherType = {
   getCacheForType,
   readContext,
@@ -361,6 +372,7 @@ const Dispatcher: DispatcherType = {
   useSyncExternalStore,
   useDeferredValue,
   useOpaqueIdentifier,
+  useId,
 };
 
 // Inspect
diff --git a/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js b/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js
index 3a6ac01f98161..d17a01a258277 100644
--- a/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js
+++ b/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js
@@ -628,7 +628,7 @@ describe('ReactHooksInspectionIntegration', () => {
   it('should support composite useOpaqueIdentifier hook in concurrent mode', () => {
     function Foo(props) {
       const id = React.unstable_useOpaqueIdentifier();
-      const [state] = React.useState(() => 'hello', []);
+      const [state] = React.useState('hello');
       return <div id={id}>{state}</div>;
     }
 
@@ -656,6 +656,33 @@ describe('ReactHooksInspectionIntegration', () => {
     });
   });
 
+  it('should support useId hook', () => {
+    function Foo(props) {
+      const id = React.unstable_useId();
+      const [state] = React.useState('hello');
+      return <div id={id}>{state}</div>;
+    }
+
+    const renderer = ReactTestRenderer.create(<Foo />);
+    const childFiber = renderer.root.findByType(Foo)._currentFiber();
+    const tree = ReactDebugTools.inspectHooksOfFiber(childFiber);
+
+    expect(tree.length).toEqual(2);
+
+    expect(tree[0].id).toEqual(0);
+    expect(tree[0].isStateEditable).toEqual(false);
+    expect(tree[0].name).toEqual('Id');
+    expect(String(tree[0].value).startsWith('r:')).toBe(true);
+
+    expect(tree[1]).toEqual({
+      id: 1,
+      isStateEditable: true,
+      name: 'State',
+      value: 'hello',
+      subHooks: [],
+    });
+  });
+
   describe('useDebugValue', () => {
     it('should support inspectable values for multiple custom hooks', () => {
       function useLabeledValue(label) {
diff --git a/packages/react-dom/src/__tests__/ReactDOMUseId-test.js b/packages/react-dom/src/__tests__/ReactDOMUseId-test.js
new file mode 100644
index 0000000000000..b61e79fa670d6
--- /dev/null
+++ b/packages/react-dom/src/__tests__/ReactDOMUseId-test.js
@@ -0,0 +1,515 @@
+/**
+ * Copyright (c) Facebook, Inc. and its affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ * @emails react-core
+ */
+
+let JSDOM;
+let React;
+let ReactDOM;
+let Scheduler;
+let clientAct;
+let ReactDOMFizzServer;
+let Stream;
+let Suspense;
+let useId;
+let document;
+let writable;
+let container;
+let buffer = '';
+let hasErrored = false;
+let fatalError = undefined;
+
+describe('useId', () => {
+  beforeEach(() => {
+    jest.resetModules();
+    JSDOM = require('jsdom').JSDOM;
+    React = require('react');
+    ReactDOM = require('react-dom');
+    Scheduler = require('scheduler');
+    clientAct = require('jest-react').act;
+    ReactDOMFizzServer = require('react-dom/server');
+    Stream = require('stream');
+    Suspense = React.Suspense;
+    useId = React.unstable_useId;
+
+    // Test Environment
+    const jsdom = new JSDOM(
+      '<!DOCTYPE html><html><head></head><body><div id="container">',
+      {
+        runScripts: 'dangerously',
+      },
+    );
+    document = jsdom.window.document;
+    container = document.getElementById('container');
+
+    buffer = '';
+    hasErrored = false;
+
+    writable = new Stream.PassThrough();
+    writable.setEncoding('utf8');
+    writable.on('data', chunk => {
+      buffer += chunk;
+    });
+    writable.on('error', error => {
+      hasErrored = true;
+      fatalError = error;
+    });
+  });
+
+  async function serverAct(callback) {
+    await callback();
+    // Await one turn around the event loop.
+    // This assumes that we'll flush everything we have so far.
+    await new Promise(resolve => {
+      setImmediate(resolve);
+    });
+    if (hasErrored) {
+      throw fatalError;
+    }
+    // JSDOM doesn't support stream HTML parser so we need to give it a proper fragment.
+    // We also want to execute any scripts that are embedded.
+    // We assume that we have now received a proper fragment of HTML.
+    const bufferedContent = buffer;
+    buffer = '';
+    const fakeBody = document.createElement('body');
+    fakeBody.innerHTML = bufferedContent;
+    while (fakeBody.firstChild) {
+      const node = fakeBody.firstChild;
+      if (node.nodeName === 'SCRIPT') {
+        const script = document.createElement('script');
+        script.textContent = node.textContent;
+        fakeBody.removeChild(node);
+        container.appendChild(script);
+      } else {
+        container.appendChild(node);
+      }
+    }
+  }
+
+  function normalizeTreeIdForTesting(id) {
+    const [serverClientPrefix, base32, hookIndex] = id.split(':');
+    if (serverClientPrefix === 'r') {
+      // Client ids aren't stable. For testing purposes, strip out the counter.
+      return (
+        'CLIENT_GENERATED_ID' +
+        (hookIndex !== undefined ? ` (${hookIndex})` : '')
+      );
+    }
+    // Formats the tree id as a binary sequence, so it's easier to visualize
+    // the structure.
+    return (
+      parseInt(base32, 32).toString(2) +
+      (hookIndex !== undefined ? ` (${hookIndex})` : '')
+    );
+  }
+
+  function DivWithId({children}) {
+    const id = normalizeTreeIdForTesting(useId());
+    return <div id={id}>{children}</div>;
+  }
+
+  test('basic example', async () => {
+    function App() {
+      return (
+        <div>
+          <div>
+            <DivWithId />
+            <DivWithId />
+          </div>
+          <DivWithId />
+        </div>
+      );
+    }
+
+    await serverAct(async () => {
+      const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
+      pipe(writable);
+    });
+    await clientAct(async () => {
+      ReactDOM.hydrateRoot(container, <App />);
+    });
+    expect(container).toMatchInlineSnapshot(`
+      <div
+        id="container"
+      >
+        <div>
+          <div>
+            <div
+              id="101"
+            />
+            <div
+              id="1001"
+            />
+          </div>
+          <div
+            id="10"
+          />
+        </div>
+      </div>
+    `);
+  });
+
+  test('indirections', async () => {
+    function App() {
+      // There are no forks in this tree, but the parent and the child should
+      // have different ids.
+      return (
+        <DivWithId>
+          <div>
+            <div>
+              <div>
+                <DivWithId />
+              </div>
+            </div>
+          </div>
+        </DivWithId>
+      );
+    }
+
+    await serverAct(async () => {
+      const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
+      pipe(writable);
+    });
+    await clientAct(async () => {
+      ReactDOM.hydrateRoot(container, <App />);
+    });
+    expect(container).toMatchInlineSnapshot(`
+      <div
+        id="container"
+      >
+        <div
+          id="0"
+        >
+          <div>
+            <div>
+              <div>
+                <div
+                  id="1"
+                />
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+    `);
+  });
+
+  test('empty (null) children', async () => {
+    // We don't treat empty children different from non-empty ones, which means
+    // they get allocated a slot when generating ids. There's no inherent reason
+    // to do this; Fiber happens to allocate a fiber for null children that
+    // appear in a list, which is not ideal for performance. For the purposes
+    // of id generation, though, what matters is that Fizz and Fiber
+    // are consistent.
+    function App() {
+      return (
+        <>
+          {null}
+          <DivWithId />
+          {null}
+          <DivWithId />
+        </>
+      );
+    }
+
+    await serverAct(async () => {
+      const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
+      pipe(writable);
+    });
+    await clientAct(async () => {
+      ReactDOM.hydrateRoot(container, <App />);
+    });
+    expect(container).toMatchInlineSnapshot(`
+      <div
+        id="container"
+      >
+        <div
+          id="10"
+        />
+        <div
+          id="100"
+        />
+      </div>
+    `);
+  });
+
+  test('large ids', async () => {
+    // The component in this test outputs a recursive tree of nodes with ids,
+    // where the underlying binary representation is an alternating series of 1s
+    // and 0s. In other words, they are all of the form 101010101.
+    //
+    // Because we use base 32 encoding, the resulting id should consist of
+    // alternating 'a' (01010) and 'l' (10101) characters, except for the the
+    // 'R:' prefix, and the first character after that, which may not correspond
+    // to a complete set of 5 bits.
+    //
+    // Example: R:clalalalalalalala...
+    //
+    // We can use this pattern to test large ids that exceed the bitwise
+    // safe range (32 bits). The algorithm should theoretically support ids
+    // of any size.
+
+    function Child({children}) {
+      const id = useId();
+      return <div id={id}>{children}</div>;
+    }
+
+    function App() {
+      let tree = <Child />;
+      for (let i = 0; i < 50; i++) {
+        tree = (
+          <>
+            <Child />
+            {tree}
+          </>
+        );
+      }
+      return tree;
+    }
+
+    await serverAct(async () => {
+      const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
+      pipe(writable);
+    });
+    await clientAct(async () => {
+      ReactDOM.hydrateRoot(container, <App />);
+    });
+    const divs = container.querySelectorAll('div');
+
+    // Confirm that every id matches the expected pattern
+    for (let i = 0; i < divs.length; i++) {
+      // Example: R:clalalalalalalala...
+      expect(divs[i].id).toMatch(/^R:.(((al)*a?)((la)*l?))*$/);
+    }
+  });
+
+  test('multiple ids in a single component', async () => {
+    function App() {
+      const id1 = useId();
+      const id2 = useId();
+      const id3 = useId();
+      return `${id1}, ${id2}, ${id3}`;
+    }
+
+    await serverAct(async () => {
+      const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
+      pipe(writable);
+    });
+    await clientAct(async () => {
+      ReactDOM.hydrateRoot(container, <App />);
+    });
+    // We append a suffix to the end of the id to distinguish them
+    expect(container).toMatchInlineSnapshot(`
+      <div
+        id="container"
+      >
+        R:0, R:0:1, R:0:2
+        <!-- -->
+      </div>
+    `);
+  });
+
+  test('basic incremental hydration', async () => {
+    function App() {
+      return (
+        <div>
+          <Suspense fallback="Loading...">
+            <DivWithId label="A" />
+            <DivWithId label="B" />
+          </Suspense>
+          <DivWithId label="C" />
+        </div>
+      );
+    }
+
+    await serverAct(async () => {
+      const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
+      pipe(writable);
+    });
+    await clientAct(async () => {
+      ReactDOM.hydrateRoot(container, <App />);
+    });
+    expect(container).toMatchInlineSnapshot(`
+      <div
+        id="container"
+      >
+        <div>
+          <!--$-->
+          <div
+            id="101"
+          />
+          <div
+            id="1001"
+          />
+          <!--/$-->
+          <div
+            id="10"
+          />
+        </div>
+      </div>
+    `);
+  });
+
+  test('inserting/deleting siblings outside a dehydrated Suspense boundary', async () => {
+    const span = React.createRef(null);
+    function App({swap}) {
+      // Note: Using a dynamic array so these are treated as insertions and
+      // deletions instead of updates, because Fiber currently allocates a node
+      // even for empty children.
+      const children = [
+        <DivWithId key="A" />,
+        swap ? <DivWithId key="C" /> : <DivWithId key="B" />,
+        <DivWithId key="D" />,
+      ];
+      return (
+        <>
+          {children}
+          <Suspense key="boundary" fallback="Loading...">
+            <DivWithId />
+            <span ref={span} />
+          </Suspense>
+        </>
+      );
+    }
+
+    await serverAct(async () => {
+      const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
+      pipe(writable);
+    });
+    const dehydratedSpan = container.getElementsByTagName('span')[0];
+    await clientAct(async () => {
+      const root = ReactDOM.hydrateRoot(container, <App />);
+      expect(Scheduler).toFlushUntilNextPaint([]);
+      expect(container).toMatchInlineSnapshot(`
+        <div
+          id="container"
+        >
+          <div
+            id="101"
+          />
+          <div
+            id="1001"
+          />
+          <div
+            id="1101"
+          />
+          <!--$-->
+          <div
+            id="110"
+          />
+          <span />
+          <!--/$-->
+        </div>
+      `);
+
+      // The inner boundary hasn't hydrated yet
+      expect(span.current).toBe(null);
+
+      // Swap B for C
+      root.render(<App swap={true} />);
+    });
+    // The swap should not have caused a mismatch.
+    expect(container).toMatchInlineSnapshot(`
+      <div
+        id="container"
+      >
+        <div
+          id="101"
+        />
+        <div
+          id="CLIENT_GENERATED_ID"
+        />
+        <div
+          id="1101"
+        />
+        <!--$-->
+        <div
+          id="110"
+        />
+        <span />
+        <!--/$-->
+      </div>
+    `);
+    // Should have hydrated successfully
+    expect(span.current).toBe(dehydratedSpan);
+  });
+
+  test('inserting/deleting siblings inside a dehydrated Suspense boundary', async () => {
+    const span = React.createRef(null);
+    function App({swap}) {
+      // Note: Using a dynamic array so these are treated as insertions and
+      // deletions instead of updates, because Fiber currently allocates a node
+      // even for empty children.
+      const children = [
+        <DivWithId key="A" />,
+        swap ? <DivWithId key="C" /> : <DivWithId key="B" />,
+        <DivWithId key="D" />,
+      ];
+      return (
+        <Suspense key="boundary" fallback="Loading...">
+          {children}
+          <span ref={span} />
+        </Suspense>
+      );
+    }
+
+    await serverAct(async () => {
+      const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
+      pipe(writable);
+    });
+    const dehydratedSpan = container.getElementsByTagName('span')[0];
+    await clientAct(async () => {
+      const root = ReactDOM.hydrateRoot(container, <App />);
+      expect(Scheduler).toFlushUntilNextPaint([]);
+      expect(container).toMatchInlineSnapshot(`
+        <div
+          id="container"
+        >
+          <!--$-->
+          <div
+            id="101"
+          />
+          <div
+            id="1001"
+          />
+          <div
+            id="1101"
+          />
+          <span />
+          <!--/$-->
+        </div>
+      `);
+
+      // The inner boundary hasn't hydrated yet
+      expect(span.current).toBe(null);
+
+      // Swap B for C
+      root.render(<App swap={true} />);
+    });
+    // The swap should not have caused a mismatch.
+    expect(container).toMatchInlineSnapshot(`
+      <div
+        id="container"
+      >
+        <!--$-->
+        <div
+          id="101"
+        />
+        <div
+          id="CLIENT_GENERATED_ID"
+        />
+        <div
+          id="1101"
+        />
+        <span />
+        <!--/$-->
+      </div>
+    `);
+    // Should have hydrated successfully
+    expect(span.current).toBe(dehydratedSpan);
+  });
+});
diff --git a/packages/react-dom/src/server/ReactPartialRendererHooks.js b/packages/react-dom/src/server/ReactPartialRendererHooks.js
index 168fd78f6103e..26f2dd00ee0c6 100644
--- a/packages/react-dom/src/server/ReactPartialRendererHooks.js
+++ b/packages/react-dom/src/server/ReactPartialRendererHooks.js
@@ -519,6 +519,10 @@ function useOpaqueIdentifier(): OpaqueIDType {
   );
 }
 
+function useId(): OpaqueIDType {
+  throw new Error('Not implemented.');
+}
+
 function useCacheRefresh(): <T>(?() => T, ?T) => void {
   throw new Error('Not implemented.');
 }
@@ -549,6 +553,7 @@ export const Dispatcher: DispatcherType = {
   useDeferredValue,
   useTransition,
   useOpaqueIdentifier,
+  useId,
   // Subscriptions are not setup in a server environment.
   useMutableSource,
   useSyncExternalStore,
diff --git a/packages/react-reconciler/src/ReactChildFiber.new.js b/packages/react-reconciler/src/ReactChildFiber.new.js
index 9071edc24f7a2..658b1f0e7b799 100644
--- a/packages/react-reconciler/src/ReactChildFiber.new.js
+++ b/packages/react-reconciler/src/ReactChildFiber.new.js
@@ -13,7 +13,7 @@ import type {Fiber} from './ReactInternalTypes';
 import type {Lanes} from './ReactFiberLane.new';
 
 import getComponentNameFromFiber from 'react-reconciler/src/getComponentNameFromFiber';
-import {Placement, ChildDeletion} from './ReactFiberFlags';
+import {Placement, ChildDeletion, Forked} from './ReactFiberFlags';
 import {
   getIteratorFn,
   REACT_ELEMENT_TYPE,
@@ -40,6 +40,8 @@ import {
 import {emptyRefsObject} from './ReactFiberClassComponent.new';
 import {isCompatibleFamilyForHotReloading} from './ReactFiberHotReloading.new';
 import {StrictLegacyMode} from './ReactTypeOfMode';
+import {getIsHydrating} from './ReactFiberHydrationContext.new';
+import {pushTreeFork} from './ReactFiberTreeContext.new';
 
 let didWarnAboutMaps;
 let didWarnAboutGenerators;
@@ -334,7 +336,9 @@ function ChildReconciler(shouldTrackSideEffects) {
   ): number {
     newFiber.index = newIndex;
     if (!shouldTrackSideEffects) {
-      // Noop.
+      // During hydration, the useId algorithm needs to know which fibers are
+      // part of a list of children (arrays, iterators).
+      newFiber.flags |= Forked;
       return lastPlacedIndex;
     }
     const current = newFiber.alternate;
@@ -823,6 +827,10 @@ function ChildReconciler(shouldTrackSideEffects) {
     if (newIdx === newChildren.length) {
       // We've reached the end of the new children. We can delete the rest.
       deleteRemainingChildren(returnFiber, oldFiber);
+      if (getIsHydrating()) {
+        const numberOfForks = newIdx;
+        pushTreeFork(returnFiber, numberOfForks);
+      }
       return resultingFirstChild;
     }
 
@@ -843,6 +851,10 @@ function ChildReconciler(shouldTrackSideEffects) {
         }
         previousNewFiber = newFiber;
       }
+      if (getIsHydrating()) {
+        const numberOfForks = newIdx;
+        pushTreeFork(returnFiber, numberOfForks);
+      }
       return resultingFirstChild;
     }
 
@@ -886,6 +898,10 @@ function ChildReconciler(shouldTrackSideEffects) {
       existingChildren.forEach(child => deleteChild(returnFiber, child));
     }
 
+    if (getIsHydrating()) {
+      const numberOfForks = newIdx;
+      pushTreeFork(returnFiber, numberOfForks);
+    }
     return resultingFirstChild;
   }
 
@@ -1013,6 +1029,10 @@ function ChildReconciler(shouldTrackSideEffects) {
     if (step.done) {
       // We've reached the end of the new children. We can delete the rest.
       deleteRemainingChildren(returnFiber, oldFiber);
+      if (getIsHydrating()) {
+        const numberOfForks = newIdx;
+        pushTreeFork(returnFiber, numberOfForks);
+      }
       return resultingFirstChild;
     }
 
@@ -1033,6 +1053,10 @@ function ChildReconciler(shouldTrackSideEffects) {
         }
         previousNewFiber = newFiber;
       }
+      if (getIsHydrating()) {
+        const numberOfForks = newIdx;
+        pushTreeFork(returnFiber, numberOfForks);
+      }
       return resultingFirstChild;
     }
 
@@ -1076,6 +1100,10 @@ function ChildReconciler(shouldTrackSideEffects) {
       existingChildren.forEach(child => deleteChild(returnFiber, child));
     }
 
+    if (getIsHydrating()) {
+      const numberOfForks = newIdx;
+      pushTreeFork(returnFiber, numberOfForks);
+    }
     return resultingFirstChild;
   }
 
diff --git a/packages/react-reconciler/src/ReactChildFiber.old.js b/packages/react-reconciler/src/ReactChildFiber.old.js
index 0128ca8f36f3d..0ef3b301e95a7 100644
--- a/packages/react-reconciler/src/ReactChildFiber.old.js
+++ b/packages/react-reconciler/src/ReactChildFiber.old.js
@@ -13,7 +13,7 @@ import type {Fiber} from './ReactInternalTypes';
 import type {Lanes} from './ReactFiberLane.old';
 
 import getComponentNameFromFiber from 'react-reconciler/src/getComponentNameFromFiber';
-import {Placement, ChildDeletion} from './ReactFiberFlags';
+import {Placement, ChildDeletion, Forked} from './ReactFiberFlags';
 import {
   getIteratorFn,
   REACT_ELEMENT_TYPE,
@@ -40,6 +40,8 @@ import {
 import {emptyRefsObject} from './ReactFiberClassComponent.old';
 import {isCompatibleFamilyForHotReloading} from './ReactFiberHotReloading.old';
 import {StrictLegacyMode} from './ReactTypeOfMode';
+import {getIsHydrating} from './ReactFiberHydrationContext.old';
+import {pushTreeFork} from './ReactFiberTreeContext.old';
 
 let didWarnAboutMaps;
 let didWarnAboutGenerators;
@@ -334,7 +336,9 @@ function ChildReconciler(shouldTrackSideEffects) {
   ): number {
     newFiber.index = newIndex;
     if (!shouldTrackSideEffects) {
-      // Noop.
+      // During hydration, the useId algorithm needs to know which fibers are
+      // part of a list of children (arrays, iterators).
+      newFiber.flags |= Forked;
       return lastPlacedIndex;
     }
     const current = newFiber.alternate;
@@ -823,6 +827,10 @@ function ChildReconciler(shouldTrackSideEffects) {
     if (newIdx === newChildren.length) {
       // We've reached the end of the new children. We can delete the rest.
       deleteRemainingChildren(returnFiber, oldFiber);
+      if (getIsHydrating()) {
+        const numberOfForks = newIdx;
+        pushTreeFork(returnFiber, numberOfForks);
+      }
       return resultingFirstChild;
     }
 
@@ -843,6 +851,10 @@ function ChildReconciler(shouldTrackSideEffects) {
         }
         previousNewFiber = newFiber;
       }
+      if (getIsHydrating()) {
+        const numberOfForks = newIdx;
+        pushTreeFork(returnFiber, numberOfForks);
+      }
       return resultingFirstChild;
     }
 
@@ -886,6 +898,10 @@ function ChildReconciler(shouldTrackSideEffects) {
       existingChildren.forEach(child => deleteChild(returnFiber, child));
     }
 
+    if (getIsHydrating()) {
+      const numberOfForks = newIdx;
+      pushTreeFork(returnFiber, numberOfForks);
+    }
     return resultingFirstChild;
   }
 
@@ -1013,6 +1029,10 @@ function ChildReconciler(shouldTrackSideEffects) {
     if (step.done) {
       // We've reached the end of the new children. We can delete the rest.
       deleteRemainingChildren(returnFiber, oldFiber);
+      if (getIsHydrating()) {
+        const numberOfForks = newIdx;
+        pushTreeFork(returnFiber, numberOfForks);
+      }
       return resultingFirstChild;
     }
 
@@ -1033,6 +1053,10 @@ function ChildReconciler(shouldTrackSideEffects) {
         }
         previousNewFiber = newFiber;
       }
+      if (getIsHydrating()) {
+        const numberOfForks = newIdx;
+        pushTreeFork(returnFiber, numberOfForks);
+      }
       return resultingFirstChild;
     }
 
@@ -1076,6 +1100,10 @@ function ChildReconciler(shouldTrackSideEffects) {
       existingChildren.forEach(child => deleteChild(returnFiber, child));
     }
 
+    if (getIsHydrating()) {
+      const numberOfForks = newIdx;
+      pushTreeFork(returnFiber, numberOfForks);
+    }
     return resultingFirstChild;
   }
 
diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.new.js b/packages/react-reconciler/src/ReactFiberBeginWork.new.js
index 4fe648bc3e767..653ee9e1b4ea7 100644
--- a/packages/react-reconciler/src/ReactFiberBeginWork.new.js
+++ b/packages/react-reconciler/src/ReactFiberBeginWork.new.js
@@ -186,6 +186,7 @@ import {
   invalidateContextProvider,
 } from './ReactFiberContext.new';
 import {
+  getIsHydrating,
   enterHydrationState,
   reenterHydrationStateFromDehydratedSuspenseInstance,
   resetHydrationState,
@@ -235,6 +236,11 @@ import {createClassErrorUpdate} from './ReactFiberThrow.new';
 import {completeSuspendedOffscreenHostContainer} from './ReactFiberCompleteWork.new';
 import is from 'shared/objectIs';
 import {setIsStrictModeForDevtools} from './ReactFiberDevToolsHook.new';
+import {
+  getForksAtLevel,
+  isForkedChild,
+  pushTreeId,
+} from './ReactFiberTreeContext.new';
 
 const ReactCurrentOwner = ReactSharedInternals.ReactCurrentOwner;
 
@@ -1757,6 +1763,7 @@ function mountIndeterminateComponent(
         }
       }
     }
+
     reconcileChildren(null, workInProgress, value, renderLanes);
     if (__DEV__) {
       validateFunctionComponentInDev(workInProgress, Component);
@@ -1845,6 +1852,7 @@ function validateFunctionComponentInDev(workInProgress: Fiber, Component: any) {
 
 const SUSPENDED_MARKER: SuspenseState = {
   dehydrated: null,
+  treeContext: null,
   retryLane: NoLane,
 };
 
@@ -2693,6 +2701,7 @@ function updateDehydratedSuspenseComponent(
     reenterHydrationStateFromDehydratedSuspenseInstance(
       workInProgress,
       suspenseInstance,
+      suspenseState.treeContext,
     );
     const nextProps = workInProgress.pendingProps;
     const primaryChildren = nextProps.children;
@@ -3675,6 +3684,21 @@ function beginWork(
     }
   } else {
     didReceiveUpdate = false;
+
+    if (getIsHydrating() && isForkedChild(workInProgress)) {
+      // Check if this child belongs to a list of muliple children in
+      // its parent.
+      //
+      // In a true multi-threaded implementation, we would render children on
+      // parallel threads. This would represent the beginning of a new render
+      // thread for this subtree.
+      //
+      // We only use this for id generation during hydration, which is why the
+      // logic is located in this special branch.
+      const slotIndex = workInProgress.index;
+      const numberOfForks = getForksAtLevel(workInProgress);
+      pushTreeId(workInProgress, numberOfForks, slotIndex);
+    }
   }
 
   // Before entering the begin phase, clear pending update priority.
diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.old.js b/packages/react-reconciler/src/ReactFiberBeginWork.old.js
index f116897a8661b..9833ef481af70 100644
--- a/packages/react-reconciler/src/ReactFiberBeginWork.old.js
+++ b/packages/react-reconciler/src/ReactFiberBeginWork.old.js
@@ -186,6 +186,7 @@ import {
   invalidateContextProvider,
 } from './ReactFiberContext.old';
 import {
+  getIsHydrating,
   enterHydrationState,
   reenterHydrationStateFromDehydratedSuspenseInstance,
   resetHydrationState,
@@ -235,6 +236,11 @@ import {createClassErrorUpdate} from './ReactFiberThrow.old';
 import {completeSuspendedOffscreenHostContainer} from './ReactFiberCompleteWork.old';
 import is from 'shared/objectIs';
 import {setIsStrictModeForDevtools} from './ReactFiberDevToolsHook.old';
+import {
+  getForksAtLevel,
+  isForkedChild,
+  pushTreeId,
+} from './ReactFiberTreeContext.old';
 
 const ReactCurrentOwner = ReactSharedInternals.ReactCurrentOwner;
 
@@ -1757,6 +1763,7 @@ function mountIndeterminateComponent(
         }
       }
     }
+
     reconcileChildren(null, workInProgress, value, renderLanes);
     if (__DEV__) {
       validateFunctionComponentInDev(workInProgress, Component);
@@ -1845,6 +1852,7 @@ function validateFunctionComponentInDev(workInProgress: Fiber, Component: any) {
 
 const SUSPENDED_MARKER: SuspenseState = {
   dehydrated: null,
+  treeContext: null,
   retryLane: NoLane,
 };
 
@@ -2693,6 +2701,7 @@ function updateDehydratedSuspenseComponent(
     reenterHydrationStateFromDehydratedSuspenseInstance(
       workInProgress,
       suspenseInstance,
+      suspenseState.treeContext,
     );
     const nextProps = workInProgress.pendingProps;
     const primaryChildren = nextProps.children;
@@ -3675,6 +3684,21 @@ function beginWork(
     }
   } else {
     didReceiveUpdate = false;
+
+    if (getIsHydrating() && isForkedChild(workInProgress)) {
+      // Check if this child belongs to a list of muliple children in
+      // its parent.
+      //
+      // In a true multi-threaded implementation, we would render children on
+      // parallel threads. This would represent the beginning of a new render
+      // thread for this subtree.
+      //
+      // We only use this for id generation during hydration, which is why the
+      // logic is located in this special branch.
+      const slotIndex = workInProgress.index;
+      const numberOfForks = getForksAtLevel(workInProgress);
+      pushTreeId(workInProgress, numberOfForks, slotIndex);
+    }
   }
 
   // Before entering the begin phase, clear pending update priority.
diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.new.js b/packages/react-reconciler/src/ReactFiberCompleteWork.new.js
index 20a7fc52db13a..feb38f00461a0 100644
--- a/packages/react-reconciler/src/ReactFiberCompleteWork.new.js
+++ b/packages/react-reconciler/src/ReactFiberCompleteWork.new.js
@@ -155,6 +155,7 @@ import {
   popRootCachePool,
   popCachePool,
 } from './ReactFiberCacheComponent.new';
+import {popTreeContext} from './ReactFiberTreeContext.new';
 
 function markUpdate(workInProgress: Fiber) {
   // Tag the fiber with an update effect. This turns a Placement into
@@ -822,7 +823,11 @@ function completeWork(
   renderLanes: Lanes,
 ): Fiber | null {
   const newProps = workInProgress.pendingProps;
-
+  // Note: This intentionally doesn't check if we're hydrating because comparing
+  // to the current tree provider fiber is just as fast and less error-prone.
+  // Ideally we would have a special version of the work loop only
+  // for hydration.
+  popTreeContext(workInProgress);
   switch (workInProgress.tag) {
     case IndeterminateComponent:
     case LazyComponent:
diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.old.js b/packages/react-reconciler/src/ReactFiberCompleteWork.old.js
index 305359aef206e..ea4d71e8ba371 100644
--- a/packages/react-reconciler/src/ReactFiberCompleteWork.old.js
+++ b/packages/react-reconciler/src/ReactFiberCompleteWork.old.js
@@ -155,6 +155,7 @@ import {
   popRootCachePool,
   popCachePool,
 } from './ReactFiberCacheComponent.old';
+import {popTreeContext} from './ReactFiberTreeContext.old';
 
 function markUpdate(workInProgress: Fiber) {
   // Tag the fiber with an update effect. This turns a Placement into
@@ -822,7 +823,11 @@ function completeWork(
   renderLanes: Lanes,
 ): Fiber | null {
   const newProps = workInProgress.pendingProps;
-
+  // Note: This intentionally doesn't check if we're hydrating because comparing
+  // to the current tree provider fiber is just as fast and less error-prone.
+  // Ideally we would have a special version of the work loop only
+  // for hydration.
+  popTreeContext(workInProgress);
   switch (workInProgress.tag) {
     case IndeterminateComponent:
     case LazyComponent:
diff --git a/packages/react-reconciler/src/ReactFiberFlags.js b/packages/react-reconciler/src/ReactFiberFlags.js
index a82278222bf0a..805c4bed918e9 100644
--- a/packages/react-reconciler/src/ReactFiberFlags.js
+++ b/packages/react-reconciler/src/ReactFiberFlags.js
@@ -12,54 +12,55 @@ import {enableCreateEventHandleAPI} from 'shared/ReactFeatureFlags';
 export type Flags = number;
 
 // Don't change these two values. They're used by React Dev Tools.
-export const NoFlags = /*                      */ 0b0000000000000000000000000;
-export const PerformedWork = /*                */ 0b0000000000000000000000001;
+export const NoFlags = /*                      */ 0b00000000000000000000000000;
+export const PerformedWork = /*                */ 0b00000000000000000000000001;
 
 // You can change the rest (and add more).
-export const Placement = /*                    */ 0b0000000000000000000000010;
-export const Update = /*                       */ 0b0000000000000000000000100;
+export const Placement = /*                    */ 0b00000000000000000000000010;
+export const Update = /*                       */ 0b00000000000000000000000100;
 export const PlacementAndUpdate = /*           */ Placement | Update;
-export const Deletion = /*                     */ 0b0000000000000000000001000;
-export const ChildDeletion = /*                */ 0b0000000000000000000010000;
-export const ContentReset = /*                 */ 0b0000000000000000000100000;
-export const Callback = /*                     */ 0b0000000000000000001000000;
-export const DidCapture = /*                   */ 0b0000000000000000010000000;
-export const ForceClientRender = /*            */ 0b0000000000000000100000000;
-export const Ref = /*                          */ 0b0000000000000001000000000;
-export const Snapshot = /*                     */ 0b0000000000000010000000000;
-export const Passive = /*                      */ 0b0000000000000100000000000;
-export const Hydrating = /*                    */ 0b0000000000001000000000000;
+export const Deletion = /*                     */ 0b00000000000000000000001000;
+export const ChildDeletion = /*                */ 0b00000000000000000000010000;
+export const ContentReset = /*                 */ 0b00000000000000000000100000;
+export const Callback = /*                     */ 0b00000000000000000001000000;
+export const DidCapture = /*                   */ 0b00000000000000000010000000;
+export const ForceClientRender = /*            */ 0b00000000000000000100000000;
+export const Ref = /*                          */ 0b00000000000000001000000000;
+export const Snapshot = /*                     */ 0b00000000000000010000000000;
+export const Passive = /*                      */ 0b00000000000000100000000000;
+export const Hydrating = /*                    */ 0b00000000000001000000000000;
 export const HydratingAndUpdate = /*           */ Hydrating | Update;
-export const Visibility = /*                   */ 0b0000000000010000000000000;
-export const StoreConsistency = /*             */ 0b0000000000100000000000000;
+export const Visibility = /*                   */ 0b00000000000010000000000000;
+export const StoreConsistency = /*             */ 0b00000000000100000000000000;
 
 export const LifecycleEffectMask =
   Passive | Update | Callback | Ref | Snapshot | StoreConsistency;
 
 // Union of all commit flags (flags with the lifetime of a particular commit)
-export const HostEffectMask = /*               */ 0b0000000000111111111111111;
+export const HostEffectMask = /*               */ 0b00000000000111111111111111;
 
 // These are not really side effects, but we still reuse this field.
-export const Incomplete = /*                   */ 0b0000000001000000000000000;
-export const ShouldCapture = /*                */ 0b0000000010000000000000000;
-export const ForceUpdateForLegacySuspense = /* */ 0b0000000100000000000000000;
-export const DidPropagateContext = /*          */ 0b0000001000000000000000000;
-export const NeedsPropagation = /*             */ 0b0000010000000000000000000;
+export const Incomplete = /*                   */ 0b00000000001000000000000000;
+export const ShouldCapture = /*                */ 0b00000000010000000000000000;
+export const ForceUpdateForLegacySuspense = /* */ 0b00000000100000000000000000;
+export const DidPropagateContext = /*          */ 0b00000001000000000000000000;
+export const NeedsPropagation = /*             */ 0b00000010000000000000000000;
+export const Forked = /*                       */ 0b00000100000000000000000000;
 
 // Static tags describe aspects of a fiber that are not specific to a render,
 // e.g. a fiber uses a passive effect (even if there are no updates on this particular render).
 // This enables us to defer more work in the unmount case,
 // since we can defer traversing the tree during layout to look for Passive effects,
 // and instead rely on the static flag as a signal that there may be cleanup work.
-export const RefStatic = /*                    */ 0b0000100000000000000000000;
-export const LayoutStatic = /*                 */ 0b0001000000000000000000000;
-export const PassiveStatic = /*                */ 0b0010000000000000000000000;
+export const RefStatic = /*                    */ 0b00001000000000000000000000;
+export const LayoutStatic = /*                 */ 0b00010000000000000000000000;
+export const PassiveStatic = /*                */ 0b00100000000000000000000000;
 
 // These flags allow us to traverse to fibers that have effects on mount
 // without traversing the entire tree after every commit for
 // double invoking
-export const MountLayoutDev = /*               */ 0b0100000000000000000000000;
-export const MountPassiveDev = /*              */ 0b1000000000000000000000000;
+export const MountLayoutDev = /*               */ 0b01000000000000000000000000;
+export const MountPassiveDev = /*              */ 0b10000000000000000000000000;
 
 // Groups of flags that are used in the commit phase to skip over trees that
 // don't contain effects, by checking subtreeFlags.
diff --git a/packages/react-reconciler/src/ReactFiberHooks.new.js b/packages/react-reconciler/src/ReactFiberHooks.new.js
index 732f7d71a7e71..a1d7009a85a43 100644
--- a/packages/react-reconciler/src/ReactFiberHooks.new.js
+++ b/packages/react-reconciler/src/ReactFiberHooks.new.js
@@ -117,6 +117,7 @@ import {
 } from './ReactUpdateQueue.new';
 import {pushInterleavedQueue} from './ReactFiberInterleavedUpdates.new';
 import {warnOnSubscriptionInsideStartTransition} from 'shared/ReactFeatureFlags';
+import {getTreeId, pushTreeFork, pushTreeId} from './ReactFiberTreeContext.new';
 
 const {ReactCurrentDispatcher, ReactCurrentBatchConfig} = ReactSharedInternals;
 
@@ -203,6 +204,12 @@ let didScheduleRenderPhaseUpdate: boolean = false;
 // TODO: Maybe there's some way to consolidate this with
 // `didScheduleRenderPhaseUpdate`. Or with `numberOfReRenders`.
 let didScheduleRenderPhaseUpdateDuringThisPass: boolean = false;
+// Counts the number of useId hooks in this component.
+let localIdCounter: number = 0;
+// Used for ids that are generated completely client-side (i.e. not during
+// hydration). This counter is global, so client ids are not stable across
+// render attempts.
+let globalClientIdCounter: number = 0;
 
 const RE_RENDER_LIMIT = 25;
 
@@ -396,6 +403,7 @@ export function renderWithHooks<Props, SecondArg>(
   // workInProgressHook = null;
 
   // didScheduleRenderPhaseUpdate = false;
+  // localIdCounter = 0;
 
   // TODO Warn if no hooks are used at all during mount, then some are used during update.
   // Currently we will identify the update render as a mount because memoizedState === null.
@@ -543,6 +551,21 @@ export function renderWithHooks<Props, SecondArg>(
     }
   }
 
+  if (localIdCounter !== 0) {
+    localIdCounter = 0;
+    if (getIsHydrating()) {
+      // This component materialized an id. This will affect any ids that appear
+      // in its children.
+      const returnFiber = workInProgress.return;
+      if (returnFiber !== null) {
+        const numberOfForks = 1;
+        const slotIndex = 0;
+        pushTreeFork(workInProgress, numberOfForks);
+        pushTreeId(workInProgress, numberOfForks, slotIndex);
+      }
+    }
+  }
+
   return children;
 }
 
@@ -612,6 +635,7 @@ export function resetHooksAfterThrow(): void {
   }
 
   didScheduleRenderPhaseUpdateDuringThisPass = false;
+  localIdCounter = 0;
 }
 
 function mountWorkInProgressHook(): Hook {
@@ -2109,6 +2133,39 @@ function rerenderOpaqueIdentifier(): OpaqueIDType | void {
   return id;
 }
 
+function mountId(): string {
+  const hook = mountWorkInProgressHook();
+
+  let id;
+  if (getIsHydrating()) {
+    const treeId = getTreeId();
+
+    // Use a captial R prefix for server-generated ids.
+    id = 'R:' + treeId;
+
+    // Unless this is the first id at this level, append a number at the end
+    // that represents the position of this useId hook among all the useId
+    // hooks for this fiber.
+    const localId = localIdCounter++;
+    if (localId > 0) {
+      id += ':' + localId.toString(32);
+    }
+  } else {
+    // Use a lowercase r prefix for client-generated ids.
+    const globalClientId = globalClientIdCounter++;
+    id = 'r:' + globalClientId.toString(32);
+  }
+
+  hook.memoizedState = id;
+  return id;
+}
+
+function updateId(): string {
+  const hook = updateWorkInProgressHook();
+  const id: string = hook.memoizedState;
+  return id;
+}
+
 function mountRefresh() {
   const hook = mountWorkInProgressHook();
   const refresh = (hook.memoizedState = refreshCache.bind(
@@ -2425,6 +2482,7 @@ export const ContextOnlyDispatcher: Dispatcher = {
   useMutableSource: throwInvalidHookError,
   useSyncExternalStore: throwInvalidHookError,
   useOpaqueIdentifier: throwInvalidHookError,
+  useId: throwInvalidHookError,
 
   unstable_isNewReconciler: enableNewReconciler,
 };
@@ -2453,6 +2511,7 @@ const HooksDispatcherOnMount: Dispatcher = {
   useMutableSource: mountMutableSource,
   useSyncExternalStore: mountSyncExternalStore,
   useOpaqueIdentifier: mountOpaqueIdentifier,
+  useId: mountId,
 
   unstable_isNewReconciler: enableNewReconciler,
 };
@@ -2481,6 +2540,7 @@ const HooksDispatcherOnUpdate: Dispatcher = {
   useMutableSource: updateMutableSource,
   useSyncExternalStore: updateSyncExternalStore,
   useOpaqueIdentifier: updateOpaqueIdentifier,
+  useId: updateId,
 
   unstable_isNewReconciler: enableNewReconciler,
 };
@@ -2509,6 +2569,7 @@ const HooksDispatcherOnRerender: Dispatcher = {
   useMutableSource: updateMutableSource,
   useSyncExternalStore: mountSyncExternalStore,
   useOpaqueIdentifier: rerenderOpaqueIdentifier,
+  useId: updateId,
 
   unstable_isNewReconciler: enableNewReconciler,
 };
@@ -2680,6 +2741,11 @@ if (__DEV__) {
       mountHookTypesDev();
       return mountOpaqueIdentifier();
     },
+    useId(): string {
+      currentHookNameInDev = 'useId';
+      mountHookTypesDev();
+      return mountId();
+    },
 
     unstable_isNewReconciler: enableNewReconciler,
   };
@@ -2822,6 +2888,11 @@ if (__DEV__) {
       updateHookTypesDev();
       return mountOpaqueIdentifier();
     },
+    useId(): string {
+      currentHookNameInDev = 'useId';
+      updateHookTypesDev();
+      return mountId();
+    },
 
     unstable_isNewReconciler: enableNewReconciler,
   };
@@ -2964,6 +3035,11 @@ if (__DEV__) {
       updateHookTypesDev();
       return updateOpaqueIdentifier();
     },
+    useId(): string {
+      currentHookNameInDev = 'useId';
+      updateHookTypesDev();
+      return updateId();
+    },
 
     unstable_isNewReconciler: enableNewReconciler,
   };
@@ -3107,6 +3183,11 @@ if (__DEV__) {
       updateHookTypesDev();
       return rerenderOpaqueIdentifier();
     },
+    useId(): string {
+      currentHookNameInDev = 'useId';
+      updateHookTypesDev();
+      return updateId();
+    },
 
     unstable_isNewReconciler: enableNewReconciler,
   };
@@ -3266,6 +3347,12 @@ if (__DEV__) {
       mountHookTypesDev();
       return mountOpaqueIdentifier();
     },
+    useId(): string {
+      currentHookNameInDev = 'useId';
+      warnInvalidHookAccess();
+      mountHookTypesDev();
+      return mountId();
+    },
 
     unstable_isNewReconciler: enableNewReconciler,
   };
@@ -3425,6 +3512,12 @@ if (__DEV__) {
       updateHookTypesDev();
       return updateOpaqueIdentifier();
     },
+    useId(): string {
+      currentHookNameInDev = 'useId';
+      warnInvalidHookAccess();
+      updateHookTypesDev();
+      return updateId();
+    },
 
     unstable_isNewReconciler: enableNewReconciler,
   };
@@ -3585,6 +3678,12 @@ if (__DEV__) {
       updateHookTypesDev();
       return rerenderOpaqueIdentifier();
     },
+    useId(): string {
+      currentHookNameInDev = 'useId';
+      warnInvalidHookAccess();
+      updateHookTypesDev();
+      return updateId();
+    },
 
     unstable_isNewReconciler: enableNewReconciler,
   };
diff --git a/packages/react-reconciler/src/ReactFiberHooks.old.js b/packages/react-reconciler/src/ReactFiberHooks.old.js
index b78f24e8b47f8..167698271dbac 100644
--- a/packages/react-reconciler/src/ReactFiberHooks.old.js
+++ b/packages/react-reconciler/src/ReactFiberHooks.old.js
@@ -117,6 +117,7 @@ import {
 } from './ReactUpdateQueue.old';
 import {pushInterleavedQueue} from './ReactFiberInterleavedUpdates.old';
 import {warnOnSubscriptionInsideStartTransition} from 'shared/ReactFeatureFlags';
+import {getTreeId, pushTreeFork, pushTreeId} from './ReactFiberTreeContext.old';
 
 const {ReactCurrentDispatcher, ReactCurrentBatchConfig} = ReactSharedInternals;
 
@@ -203,6 +204,12 @@ let didScheduleRenderPhaseUpdate: boolean = false;
 // TODO: Maybe there's some way to consolidate this with
 // `didScheduleRenderPhaseUpdate`. Or with `numberOfReRenders`.
 let didScheduleRenderPhaseUpdateDuringThisPass: boolean = false;
+// Counts the number of useId hooks in this component.
+let localIdCounter: number = 0;
+// Used for ids that are generated completely client-side (i.e. not during
+// hydration). This counter is global, so client ids are not stable across
+// render attempts.
+let globalClientIdCounter: number = 0;
 
 const RE_RENDER_LIMIT = 25;
 
@@ -396,6 +403,7 @@ export function renderWithHooks<Props, SecondArg>(
   // workInProgressHook = null;
 
   // didScheduleRenderPhaseUpdate = false;
+  // localIdCounter = 0;
 
   // TODO Warn if no hooks are used at all during mount, then some are used during update.
   // Currently we will identify the update render as a mount because memoizedState === null.
@@ -543,6 +551,21 @@ export function renderWithHooks<Props, SecondArg>(
     }
   }
 
+  if (localIdCounter !== 0) {
+    localIdCounter = 0;
+    if (getIsHydrating()) {
+      // This component materialized an id. This will affect any ids that appear
+      // in its children.
+      const returnFiber = workInProgress.return;
+      if (returnFiber !== null) {
+        const numberOfForks = 1;
+        const slotIndex = 0;
+        pushTreeFork(workInProgress, numberOfForks);
+        pushTreeId(workInProgress, numberOfForks, slotIndex);
+      }
+    }
+  }
+
   return children;
 }
 
@@ -612,6 +635,7 @@ export function resetHooksAfterThrow(): void {
   }
 
   didScheduleRenderPhaseUpdateDuringThisPass = false;
+  localIdCounter = 0;
 }
 
 function mountWorkInProgressHook(): Hook {
@@ -2109,6 +2133,39 @@ function rerenderOpaqueIdentifier(): OpaqueIDType | void {
   return id;
 }
 
+function mountId(): string {
+  const hook = mountWorkInProgressHook();
+
+  let id;
+  if (getIsHydrating()) {
+    const treeId = getTreeId();
+
+    // Use a captial R prefix for server-generated ids.
+    id = 'R:' + treeId;
+
+    // Unless this is the first id at this level, append a number at the end
+    // that represents the position of this useId hook among all the useId
+    // hooks for this fiber.
+    const localId = localIdCounter++;
+    if (localId > 0) {
+      id += ':' + localId.toString(32);
+    }
+  } else {
+    // Use a lowercase r prefix for client-generated ids.
+    const globalClientId = globalClientIdCounter++;
+    id = 'r:' + globalClientId.toString(32);
+  }
+
+  hook.memoizedState = id;
+  return id;
+}
+
+function updateId(): string {
+  const hook = updateWorkInProgressHook();
+  const id: string = hook.memoizedState;
+  return id;
+}
+
 function mountRefresh() {
   const hook = mountWorkInProgressHook();
   const refresh = (hook.memoizedState = refreshCache.bind(
@@ -2425,6 +2482,7 @@ export const ContextOnlyDispatcher: Dispatcher = {
   useMutableSource: throwInvalidHookError,
   useSyncExternalStore: throwInvalidHookError,
   useOpaqueIdentifier: throwInvalidHookError,
+  useId: throwInvalidHookError,
 
   unstable_isNewReconciler: enableNewReconciler,
 };
@@ -2453,6 +2511,7 @@ const HooksDispatcherOnMount: Dispatcher = {
   useMutableSource: mountMutableSource,
   useSyncExternalStore: mountSyncExternalStore,
   useOpaqueIdentifier: mountOpaqueIdentifier,
+  useId: mountId,
 
   unstable_isNewReconciler: enableNewReconciler,
 };
@@ -2481,6 +2540,7 @@ const HooksDispatcherOnUpdate: Dispatcher = {
   useMutableSource: updateMutableSource,
   useSyncExternalStore: updateSyncExternalStore,
   useOpaqueIdentifier: updateOpaqueIdentifier,
+  useId: updateId,
 
   unstable_isNewReconciler: enableNewReconciler,
 };
@@ -2509,6 +2569,7 @@ const HooksDispatcherOnRerender: Dispatcher = {
   useMutableSource: updateMutableSource,
   useSyncExternalStore: mountSyncExternalStore,
   useOpaqueIdentifier: rerenderOpaqueIdentifier,
+  useId: updateId,
 
   unstable_isNewReconciler: enableNewReconciler,
 };
@@ -2680,6 +2741,11 @@ if (__DEV__) {
       mountHookTypesDev();
       return mountOpaqueIdentifier();
     },
+    useId(): string {
+      currentHookNameInDev = 'useId';
+      mountHookTypesDev();
+      return mountId();
+    },
 
     unstable_isNewReconciler: enableNewReconciler,
   };
@@ -2822,6 +2888,11 @@ if (__DEV__) {
       updateHookTypesDev();
       return mountOpaqueIdentifier();
     },
+    useId(): string {
+      currentHookNameInDev = 'useId';
+      updateHookTypesDev();
+      return mountId();
+    },
 
     unstable_isNewReconciler: enableNewReconciler,
   };
@@ -2964,6 +3035,11 @@ if (__DEV__) {
       updateHookTypesDev();
       return updateOpaqueIdentifier();
     },
+    useId(): string {
+      currentHookNameInDev = 'useId';
+      updateHookTypesDev();
+      return updateId();
+    },
 
     unstable_isNewReconciler: enableNewReconciler,
   };
@@ -3107,6 +3183,11 @@ if (__DEV__) {
       updateHookTypesDev();
       return rerenderOpaqueIdentifier();
     },
+    useId(): string {
+      currentHookNameInDev = 'useId';
+      updateHookTypesDev();
+      return updateId();
+    },
 
     unstable_isNewReconciler: enableNewReconciler,
   };
@@ -3266,6 +3347,12 @@ if (__DEV__) {
       mountHookTypesDev();
       return mountOpaqueIdentifier();
     },
+    useId(): string {
+      currentHookNameInDev = 'useId';
+      warnInvalidHookAccess();
+      mountHookTypesDev();
+      return mountId();
+    },
 
     unstable_isNewReconciler: enableNewReconciler,
   };
@@ -3425,6 +3512,12 @@ if (__DEV__) {
       updateHookTypesDev();
       return updateOpaqueIdentifier();
     },
+    useId(): string {
+      currentHookNameInDev = 'useId';
+      warnInvalidHookAccess();
+      updateHookTypesDev();
+      return updateId();
+    },
 
     unstable_isNewReconciler: enableNewReconciler,
   };
@@ -3585,6 +3678,12 @@ if (__DEV__) {
       updateHookTypesDev();
       return rerenderOpaqueIdentifier();
     },
+    useId(): string {
+      currentHookNameInDev = 'useId';
+      warnInvalidHookAccess();
+      updateHookTypesDev();
+      return updateId();
+    },
 
     unstable_isNewReconciler: enableNewReconciler,
   };
diff --git a/packages/react-reconciler/src/ReactFiberHydrationContext.new.js b/packages/react-reconciler/src/ReactFiberHydrationContext.new.js
index 7275f1663cad8..eabc5e43116bb 100644
--- a/packages/react-reconciler/src/ReactFiberHydrationContext.new.js
+++ b/packages/react-reconciler/src/ReactFiberHydrationContext.new.js
@@ -17,6 +17,7 @@ import type {
   HostContext,
 } from './ReactFiberHostConfig';
 import type {SuspenseState} from './ReactFiberSuspenseComponent.new';
+import type {TreeContext} from './ReactFiberTreeContext.new';
 
 import {
   HostComponent,
@@ -62,6 +63,10 @@ import {
 } from './ReactFiberHostConfig';
 import {enableSuspenseServerRenderer} from 'shared/ReactFeatureFlags';
 import {OffscreenLane} from './ReactFiberLane.new';
+import {
+  getSuspendedTreeContext,
+  restoreSuspendedTreeContext,
+} from './ReactFiberTreeContext.new';
 
 // The deepest Fiber on the stack involved in a hydration context.
 // This may have been an insertion or a hydration.
@@ -96,6 +101,7 @@ function enterHydrationState(fiber: Fiber): boolean {
 function reenterHydrationStateFromDehydratedSuspenseInstance(
   fiber: Fiber,
   suspenseInstance: SuspenseInstance,
+  treeContext: TreeContext | null,
 ): boolean {
   if (!supportsHydration) {
     return false;
@@ -105,6 +111,9 @@ function reenterHydrationStateFromDehydratedSuspenseInstance(
   );
   hydrationParentFiber = fiber;
   isHydrating = true;
+  if (treeContext !== null) {
+    restoreSuspendedTreeContext(fiber, treeContext);
+  }
   return true;
 }
 
@@ -287,6 +296,7 @@ function tryHydrate(fiber, nextInstance) {
         if (suspenseInstance !== null) {
           const suspenseState: SuspenseState = {
             dehydrated: suspenseInstance,
+            treeContext: getSuspendedTreeContext(),
             retryLane: OffscreenLane,
           };
           fiber.memoizedState = suspenseState;
diff --git a/packages/react-reconciler/src/ReactFiberHydrationContext.old.js b/packages/react-reconciler/src/ReactFiberHydrationContext.old.js
index 654de3f9a2894..48e60581e0f28 100644
--- a/packages/react-reconciler/src/ReactFiberHydrationContext.old.js
+++ b/packages/react-reconciler/src/ReactFiberHydrationContext.old.js
@@ -17,6 +17,7 @@ import type {
   HostContext,
 } from './ReactFiberHostConfig';
 import type {SuspenseState} from './ReactFiberSuspenseComponent.old';
+import type {TreeContext} from './ReactFiberTreeContext.old';
 
 import {
   HostComponent,
@@ -62,6 +63,10 @@ import {
 } from './ReactFiberHostConfig';
 import {enableSuspenseServerRenderer} from 'shared/ReactFeatureFlags';
 import {OffscreenLane} from './ReactFiberLane.old';
+import {
+  getSuspendedTreeContext,
+  restoreSuspendedTreeContext,
+} from './ReactFiberTreeContext.old';
 
 // The deepest Fiber on the stack involved in a hydration context.
 // This may have been an insertion or a hydration.
@@ -96,6 +101,7 @@ function enterHydrationState(fiber: Fiber): boolean {
 function reenterHydrationStateFromDehydratedSuspenseInstance(
   fiber: Fiber,
   suspenseInstance: SuspenseInstance,
+  treeContext: TreeContext | null,
 ): boolean {
   if (!supportsHydration) {
     return false;
@@ -105,6 +111,9 @@ function reenterHydrationStateFromDehydratedSuspenseInstance(
   );
   hydrationParentFiber = fiber;
   isHydrating = true;
+  if (treeContext !== null) {
+    restoreSuspendedTreeContext(fiber, treeContext);
+  }
   return true;
 }
 
@@ -287,6 +296,7 @@ function tryHydrate(fiber, nextInstance) {
         if (suspenseInstance !== null) {
           const suspenseState: SuspenseState = {
             dehydrated: suspenseInstance,
+            treeContext: getSuspendedTreeContext(),
             retryLane: OffscreenLane,
           };
           fiber.memoizedState = suspenseState;
diff --git a/packages/react-reconciler/src/ReactFiberLane.new.js b/packages/react-reconciler/src/ReactFiberLane.new.js
index c1f34e1052fc4..7e1461c7a7226 100644
--- a/packages/react-reconciler/src/ReactFiberLane.new.js
+++ b/packages/react-reconciler/src/ReactFiberLane.new.js
@@ -23,6 +23,7 @@ import {
 } from 'shared/ReactFeatureFlags';
 import {isDevToolsPresent} from './ReactFiberDevToolsHook.new';
 import {ConcurrentUpdatesByDefaultMode, NoMode} from './ReactTypeOfMode';
+import {clz32} from './clz32';
 
 // Lane values below should be kept in sync with getLabelForLane(), used by react-devtools-scheduling-profiler.
 // If those values are changed that package should be rebuilt and redeployed.
@@ -791,17 +792,3 @@ export function movePendingFibersToMemoized(root: FiberRoot, lanes: Lanes) {
     lanes &= ~lane;
   }
 }
-
-const clz32 = Math.clz32 ? Math.clz32 : clz32Fallback;
-
-// Count leading zeros. Only used on lanes, so assume input is an integer.
-// Based on:
-// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/clz32
-const log = Math.log;
-const LN2 = Math.LN2;
-function clz32Fallback(lanes: Lanes | Lane) {
-  if (lanes === 0) {
-    return 32;
-  }
-  return (31 - ((log(lanes) / LN2) | 0)) | 0;
-}
diff --git a/packages/react-reconciler/src/ReactFiberLane.old.js b/packages/react-reconciler/src/ReactFiberLane.old.js
index c81191f6a07e5..6b4be15e649f1 100644
--- a/packages/react-reconciler/src/ReactFiberLane.old.js
+++ b/packages/react-reconciler/src/ReactFiberLane.old.js
@@ -23,6 +23,7 @@ import {
 } from 'shared/ReactFeatureFlags';
 import {isDevToolsPresent} from './ReactFiberDevToolsHook.old';
 import {ConcurrentUpdatesByDefaultMode, NoMode} from './ReactTypeOfMode';
+import {clz32} from './clz32';
 
 // Lane values below should be kept in sync with getLabelForLane(), used by react-devtools-scheduling-profiler.
 // If those values are changed that package should be rebuilt and redeployed.
@@ -791,17 +792,3 @@ export function movePendingFibersToMemoized(root: FiberRoot, lanes: Lanes) {
     lanes &= ~lane;
   }
 }
-
-const clz32 = Math.clz32 ? Math.clz32 : clz32Fallback;
-
-// Count leading zeros. Only used on lanes, so assume input is an integer.
-// Based on:
-// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/clz32
-const log = Math.log;
-const LN2 = Math.LN2;
-function clz32Fallback(lanes: Lanes | Lane) {
-  if (lanes === 0) {
-    return 32;
-  }
-  return (31 - ((log(lanes) / LN2) | 0)) | 0;
-}
diff --git a/packages/react-reconciler/src/ReactFiberSuspenseComponent.new.js b/packages/react-reconciler/src/ReactFiberSuspenseComponent.new.js
index 5ad7ae650249a..9dbaf7fb76efd 100644
--- a/packages/react-reconciler/src/ReactFiberSuspenseComponent.new.js
+++ b/packages/react-reconciler/src/ReactFiberSuspenseComponent.new.js
@@ -11,6 +11,8 @@ import type {ReactNodeList, Wakeable} from 'shared/ReactTypes';
 import type {Fiber} from './ReactInternalTypes';
 import type {SuspenseInstance} from './ReactFiberHostConfig';
 import type {Lane} from './ReactFiberLane.new';
+import type {TreeContext} from './ReactFiberTreeContext.new';
+
 import {SuspenseComponent, SuspenseListComponent} from './ReactWorkTags';
 import {NoFlags, DidCapture} from './ReactFiberFlags';
 import {
@@ -40,6 +42,7 @@ export type SuspenseState = {|
   // here to indicate that it is dehydrated (flag) and for quick access
   // to check things like isSuspenseInstancePending.
   dehydrated: null | SuspenseInstance,
+  treeContext: null | TreeContext,
   // Represents the lane we should attempt to hydrate a dehydrated boundary at.
   // OffscreenLane is the default for dehydrated boundaries.
   // NoLane is the default for normal boundaries, which turns into "normal" pri.
diff --git a/packages/react-reconciler/src/ReactFiberSuspenseComponent.old.js b/packages/react-reconciler/src/ReactFiberSuspenseComponent.old.js
index 51bef1df3a568..726f0ca52005f 100644
--- a/packages/react-reconciler/src/ReactFiberSuspenseComponent.old.js
+++ b/packages/react-reconciler/src/ReactFiberSuspenseComponent.old.js
@@ -11,6 +11,8 @@ import type {ReactNodeList, Wakeable} from 'shared/ReactTypes';
 import type {Fiber} from './ReactInternalTypes';
 import type {SuspenseInstance} from './ReactFiberHostConfig';
 import type {Lane} from './ReactFiberLane.old';
+import type {TreeContext} from './ReactFiberTreeContext.old';
+
 import {SuspenseComponent, SuspenseListComponent} from './ReactWorkTags';
 import {NoFlags, DidCapture} from './ReactFiberFlags';
 import {
@@ -40,6 +42,7 @@ export type SuspenseState = {|
   // here to indicate that it is dehydrated (flag) and for quick access
   // to check things like isSuspenseInstancePending.
   dehydrated: null | SuspenseInstance,
+  treeContext: null | TreeContext,
   // Represents the lane we should attempt to hydrate a dehydrated boundary at.
   // OffscreenLane is the default for dehydrated boundaries.
   // NoLane is the default for normal boundaries, which turns into "normal" pri.
diff --git a/packages/react-reconciler/src/ReactFiberTreeContext.new.js b/packages/react-reconciler/src/ReactFiberTreeContext.new.js
new file mode 100644
index 0000000000000..0725ba577e647
--- /dev/null
+++ b/packages/react-reconciler/src/ReactFiberTreeContext.new.js
@@ -0,0 +1,273 @@
+/**
+ * Copyright (c) Facebook, Inc. and its affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ * @flow
+ */
+
+// Ids are base 32 strings whose binary representation corresponds to the
+// position of a node in a tree.
+
+// Every time the tree forks into multiple children, we add additional bits to
+// the left of the sequence that represent the position of the child within the
+// current level of children.
+//
+//      00101       00010001011010101
+//      ╰─┬─╯       ╰───────┬───────╯
+//   Fork 5 of 20       Parent id
+//
+// The leading 0s are important. In the above example, you only need 3 bits to
+// represent slot 5. However, you need 5 bits to represent all the forks at
+// the current level, so we must account for the empty bits at the end.
+//
+// For this same reason, slots are 1-indexed instead of 0-indexed. Otherwise,
+// the zeroth id at a level would be indistinguishable from its parent.
+//
+// If a node has only one child, and does not materialize an id (i.e. does not
+// contain a useId hook), then we don't need to allocate any space in the
+// sequence. It's treated as a transparent indirection. For example, these two
+// trees produce the same ids:
+//
+// <>                          <>
+//   <Indirection>               <A />
+//     <A />                     <B />
+//   </Indirection>            </>
+//   <B />
+// </>
+//
+// However, we cannot skip any node that materializes an id. Otherwise, a parent
+// id that does not fork would be indistinguishable from its child id. For
+// example, this tree does not fork, but the parent and child must have
+// different ids.
+//
+// <Parent>
+//   <Child />
+// </Parent>
+//
+// To handle this scenario, every time we materialize an id, we allocate a
+// new level with a single slot. You can think of this as a fork with only one
+// prong, or an array of children with length 1.
+//
+// It's possible for the the size of the sequence to exceed 32 bits, the max
+// size for bitwise operations. When this happens, we make more room by
+// converting the right part of the id to a string and storing it in an overflow
+// variable. We use a base 32 string representation, because 32 is the largest
+// power of 2 that is supported by toString(). We want the base to be large so
+// that the resulting ids are compact, and we want the base to be a power of 2
+// because every log2(base) bits corresponds to a single character, i.e. every
+// log2(32) = 5 bits. That means we can lop bits off the end 5 at a time without
+// affecting the final result.
+
+import {getIsHydrating} from './ReactFiberHydrationContext.new';
+import {clz32} from './clz32';
+import {Forked, NoFlags} from './ReactFiberFlags';
+
+export type TreeContext = {
+  id: number,
+  overflow: string,
+};
+
+// TODO: Use the unified fiber stack module instead of this local one?
+// Intentionally not using it yet to derisk the initial implementation, because
+// the way we push/pop these values is a bit unusual. If there's a mistake, I'd
+// rather the ids be wrong than crash the whole reconciler.
+const forkStack: Array<any> = [];
+let forkStackIndex: number = 0;
+let treeForkProvider: Fiber | null = null;
+let treeForkCount: number = 0;
+
+const idStack: Array<any> = [];
+let idStackIndex: number = 0;
+let treeContextProvider: Fiber | null = null;
+let treeContextId: number = 1;
+let treeContextOverflow: string = '';
+
+export function isForkedChild(workInProgress: Fiber): boolean {
+  warnIfNotHydrating();
+  return (workInProgress.flags & Forked) !== NoFlags;
+}
+
+export function getForksAtLevel(workInProgress: Fiber): number {
+  warnIfNotHydrating();
+  return treeForkCount;
+}
+
+export function getTreeId(): string {
+  const overflow = treeContextOverflow;
+  const idWithLeadingBit = treeContextId;
+  const id = idWithLeadingBit & ~getLeadingBit(idWithLeadingBit);
+  return id.toString(32) + overflow;
+}
+
+export function pushTreeFork(
+  workInProgress: Fiber,
+  totalChildren: number,
+): void {
+  // This is called right after we reconcile an array (or iterator) of child
+  // fibers, because that's the only place where we know how many children in
+  // the whole set without doing extra work later, or storing addtional
+  // information on the fiber.
+  //
+  // That's why this function is separate from pushTreeId — it's called during
+  // the render phase of the fork parent, not the child, which is where we push
+  // the other context values.
+  //
+  // In the Fizz implementation this is much simpler because the child is
+  // rendered in the same callstack as the parent.
+  //
+  // It might be better to just add a `forks` field to the Fiber type. It would
+  // make this module simpler.
+
+  warnIfNotHydrating();
+
+  forkStack[forkStackIndex++] = treeForkCount;
+  forkStack[forkStackIndex++] = treeForkProvider;
+
+  treeForkProvider = workInProgress;
+  treeForkCount = totalChildren;
+}
+
+export function pushTreeId(
+  workInProgress: Fiber,
+  totalChildren: number,
+  index: number,
+) {
+  warnIfNotHydrating();
+
+  idStack[idStackIndex++] = treeContextId;
+  idStack[idStackIndex++] = treeContextOverflow;
+  idStack[idStackIndex++] = treeContextProvider;
+
+  treeContextProvider = workInProgress;
+
+  const baseIdWithLeadingBit = treeContextId;
+  const baseOverflow = treeContextOverflow;
+
+  // The leftmost 1 marks the end of the sequence, non-inclusive. It's not part
+  // of the id; we use it to account for leading 0s.
+  const baseLength = getBitLength(baseIdWithLeadingBit) - 1;
+  const baseId = baseIdWithLeadingBit & ~(1 << baseLength);
+
+  const slot = index + 1;
+  const length = getBitLength(totalChildren) + baseLength;
+
+  // 30 is the max length we can store without overflowing, taking into
+  // consideration the leading 1 we use to mark the end of the sequence.
+  if (length > 30) {
+    // We overflowed the bitwise-safe range. Fall back to slower algorithm.
+    // This branch assumes the length of the base id is greater than 5; it won't
+    // work for smaller ids, because you need 5 bits per character.
+    //
+    // We encode the id in multiple steps: first the base id, then the
+    // remaining digits.
+    //
+    // Each 5 bit sequence corresponds to a single base 32 character. So for
+    // example, if the current id is 23 bits long, we can convert 20 of those
+    // bits into a string of 4 characters, with 3 bits left over.
+    //
+    // First calculate how many bits in the base id represent a complete
+    // sequence of characters.
+    const numberOfOverflowBits = baseLength - (baseLength % 5);
+
+    // Then create a bitmask that selects only those bits.
+    const newOverflowBits = (1 << numberOfOverflowBits) - 1;
+
+    // Select the bits, and convert them to a base 32 string.
+    const newOverflow = (baseId & newOverflowBits).toString(32);
+
+    // Now we can remove those bits from the base id.
+    const restOfBaseId = baseId >> numberOfOverflowBits;
+    const restOfBaseLength = baseLength - numberOfOverflowBits;
+
+    // Finally, encode the rest of the bits using the normal algorithm. Because
+    // we made more room, this time it won't overflow.
+    const restOfLength = getBitLength(totalChildren) + restOfBaseLength;
+    const restOfNewBits = slot << restOfBaseLength;
+    const id = restOfNewBits | restOfBaseId;
+    const overflow = newOverflow + baseOverflow;
+
+    treeContextId = (1 << restOfLength) | id;
+    treeContextOverflow = overflow;
+  } else {
+    // Normal path
+    const newBits = slot << baseLength;
+    const id = newBits | baseId;
+    const overflow = baseOverflow;
+
+    treeContextId = (1 << length) | id;
+    treeContextOverflow = overflow;
+  }
+}
+
+function getBitLength(number: number): number {
+  return 32 - clz32(number);
+}
+
+function getLeadingBit(id: number) {
+  return 1 << (getBitLength(id) - 1);
+}
+
+export function popTreeContext(workInProgress: Fiber) {
+  // Restore the previous values.
+
+  // This is a bit more complicated than other context-like modules in Fiber
+  // because the same Fiber may appear on the stack multiple times and for
+  // different reasons. We have to keep popping until the work-in-progress is
+  // no longer at the top of the stack.
+
+  while (workInProgress === treeForkProvider) {
+    treeForkProvider = forkStack[--forkStackIndex];
+    forkStack[forkStackIndex] = null;
+    treeForkCount = forkStack[--forkStackIndex];
+    forkStack[forkStackIndex] = null;
+  }
+
+  while (workInProgress === treeContextProvider) {
+    treeContextProvider = idStack[--idStackIndex];
+    idStack[idStackIndex] = null;
+    treeContextOverflow = idStack[--idStackIndex];
+    idStack[idStackIndex] = null;
+    treeContextId = idStack[--idStackIndex];
+    idStack[idStackIndex] = null;
+  }
+}
+
+export function getSuspendedTreeContext(): TreeContext | null {
+  warnIfNotHydrating();
+  if (treeContextProvider !== null) {
+    return {
+      id: treeContextId,
+      overflow: treeContextOverflow,
+    };
+  } else {
+    return null;
+  }
+}
+
+export function restoreSuspendedTreeContext(
+  workInProgress: Fiber,
+  suspendedContext: TreeContext,
+) {
+  warnIfNotHydrating();
+
+  idStack[idStackIndex++] = treeContextId;
+  idStack[idStackIndex++] = treeContextOverflow;
+  idStack[idStackIndex++] = treeContextProvider;
+
+  treeContextId = suspendedContext.id;
+  treeContextOverflow = suspendedContext.overflow;
+  treeContextProvider = workInProgress;
+}
+
+function warnIfNotHydrating() {
+  if (__DEV__) {
+    if (!getIsHydrating()) {
+      console.error(
+        'Expected to be hydrating. This is a bug in React. Please file ' +
+          'an issue.',
+      );
+    }
+  }
+}
diff --git a/packages/react-reconciler/src/ReactFiberTreeContext.old.js b/packages/react-reconciler/src/ReactFiberTreeContext.old.js
new file mode 100644
index 0000000000000..a4ba3c3ddb931
--- /dev/null
+++ b/packages/react-reconciler/src/ReactFiberTreeContext.old.js
@@ -0,0 +1,273 @@
+/**
+ * Copyright (c) Facebook, Inc. and its affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ * @flow
+ */
+
+// Ids are base 32 strings whose binary representation corresponds to the
+// position of a node in a tree.
+
+// Every time the tree forks into multiple children, we add additional bits to
+// the left of the sequence that represent the position of the child within the
+// current level of children.
+//
+//      00101       00010001011010101
+//      ╰─┬─╯       ╰───────┬───────╯
+//   Fork 5 of 20       Parent id
+//
+// The leading 0s are important. In the above example, you only need 3 bits to
+// represent slot 5. However, you need 5 bits to represent all the forks at
+// the current level, so we must account for the empty bits at the end.
+//
+// For this same reason, slots are 1-indexed instead of 0-indexed. Otherwise,
+// the zeroth id at a level would be indistinguishable from its parent.
+//
+// If a node has only one child, and does not materialize an id (i.e. does not
+// contain a useId hook), then we don't need to allocate any space in the
+// sequence. It's treated as a transparent indirection. For example, these two
+// trees produce the same ids:
+//
+// <>                          <>
+//   <Indirection>               <A />
+//     <A />                     <B />
+//   </Indirection>            </>
+//   <B />
+// </>
+//
+// However, we cannot skip any node that materializes an id. Otherwise, a parent
+// id that does not fork would be indistinguishable from its child id. For
+// example, this tree does not fork, but the parent and child must have
+// different ids.
+//
+// <Parent>
+//   <Child />
+// </Parent>
+//
+// To handle this scenario, every time we materialize an id, we allocate a
+// new level with a single slot. You can think of this as a fork with only one
+// prong, or an array of children with length 1.
+//
+// It's possible for the the size of the sequence to exceed 32 bits, the max
+// size for bitwise operations. When this happens, we make more room by
+// converting the right part of the id to a string and storing it in an overflow
+// variable. We use a base 32 string representation, because 32 is the largest
+// power of 2 that is supported by toString(). We want the base to be large so
+// that the resulting ids are compact, and we want the base to be a power of 2
+// because every log2(base) bits corresponds to a single character, i.e. every
+// log2(32) = 5 bits. That means we can lop bits off the end 5 at a time without
+// affecting the final result.
+
+import {getIsHydrating} from './ReactFiberHydrationContext.old';
+import {clz32} from './clz32';
+import {Forked, NoFlags} from './ReactFiberFlags';
+
+export type TreeContext = {
+  id: number,
+  overflow: string,
+};
+
+// TODO: Use the unified fiber stack module instead of this local one?
+// Intentionally not using it yet to derisk the initial implementation, because
+// the way we push/pop these values is a bit unusual. If there's a mistake, I'd
+// rather the ids be wrong than crash the whole reconciler.
+const forkStack: Array<any> = [];
+let forkStackIndex: number = 0;
+let treeForkProvider: Fiber | null = null;
+let treeForkCount: number = 0;
+
+const idStack: Array<any> = [];
+let idStackIndex: number = 0;
+let treeContextProvider: Fiber | null = null;
+let treeContextId: number = 1;
+let treeContextOverflow: string = '';
+
+export function isForkedChild(workInProgress: Fiber): boolean {
+  warnIfNotHydrating();
+  return (workInProgress.flags & Forked) !== NoFlags;
+}
+
+export function getForksAtLevel(workInProgress: Fiber): number {
+  warnIfNotHydrating();
+  return treeForkCount;
+}
+
+export function getTreeId(): string {
+  const overflow = treeContextOverflow;
+  const idWithLeadingBit = treeContextId;
+  const id = idWithLeadingBit & ~getLeadingBit(idWithLeadingBit);
+  return id.toString(32) + overflow;
+}
+
+export function pushTreeFork(
+  workInProgress: Fiber,
+  totalChildren: number,
+): void {
+  // This is called right after we reconcile an array (or iterator) of child
+  // fibers, because that's the only place where we know how many children in
+  // the whole set without doing extra work later, or storing addtional
+  // information on the fiber.
+  //
+  // That's why this function is separate from pushTreeId — it's called during
+  // the render phase of the fork parent, not the child, which is where we push
+  // the other context values.
+  //
+  // In the Fizz implementation this is much simpler because the child is
+  // rendered in the same callstack as the parent.
+  //
+  // It might be better to just add a `forks` field to the Fiber type. It would
+  // make this module simpler.
+
+  warnIfNotHydrating();
+
+  forkStack[forkStackIndex++] = treeForkCount;
+  forkStack[forkStackIndex++] = treeForkProvider;
+
+  treeForkProvider = workInProgress;
+  treeForkCount = totalChildren;
+}
+
+export function pushTreeId(
+  workInProgress: Fiber,
+  totalChildren: number,
+  index: number,
+) {
+  warnIfNotHydrating();
+
+  idStack[idStackIndex++] = treeContextId;
+  idStack[idStackIndex++] = treeContextOverflow;
+  idStack[idStackIndex++] = treeContextProvider;
+
+  treeContextProvider = workInProgress;
+
+  const baseIdWithLeadingBit = treeContextId;
+  const baseOverflow = treeContextOverflow;
+
+  // The leftmost 1 marks the end of the sequence, non-inclusive. It's not part
+  // of the id; we use it to account for leading 0s.
+  const baseLength = getBitLength(baseIdWithLeadingBit) - 1;
+  const baseId = baseIdWithLeadingBit & ~(1 << baseLength);
+
+  const slot = index + 1;
+  const length = getBitLength(totalChildren) + baseLength;
+
+  // 30 is the max length we can store without overflowing, taking into
+  // consideration the leading 1 we use to mark the end of the sequence.
+  if (length > 30) {
+    // We overflowed the bitwise-safe range. Fall back to slower algorithm.
+    // This branch assumes the length of the base id is greater than 5; it won't
+    // work for smaller ids, because you need 5 bits per character.
+    //
+    // We encode the id in multiple steps: first the base id, then the
+    // remaining digits.
+    //
+    // Each 5 bit sequence corresponds to a single base 32 character. So for
+    // example, if the current id is 23 bits long, we can convert 20 of those
+    // bits into a string of 4 characters, with 3 bits left over.
+    //
+    // First calculate how many bits in the base id represent a complete
+    // sequence of characters.
+    const numberOfOverflowBits = baseLength - (baseLength % 5);
+
+    // Then create a bitmask that selects only those bits.
+    const newOverflowBits = (1 << numberOfOverflowBits) - 1;
+
+    // Select the bits, and convert them to a base 32 string.
+    const newOverflow = (baseId & newOverflowBits).toString(32);
+
+    // Now we can remove those bits from the base id.
+    const restOfBaseId = baseId >> numberOfOverflowBits;
+    const restOfBaseLength = baseLength - numberOfOverflowBits;
+
+    // Finally, encode the rest of the bits using the normal algorithm. Because
+    // we made more room, this time it won't overflow.
+    const restOfLength = getBitLength(totalChildren) + restOfBaseLength;
+    const restOfNewBits = slot << restOfBaseLength;
+    const id = restOfNewBits | restOfBaseId;
+    const overflow = newOverflow + baseOverflow;
+
+    treeContextId = (1 << restOfLength) | id;
+    treeContextOverflow = overflow;
+  } else {
+    // Normal path
+    const newBits = slot << baseLength;
+    const id = newBits | baseId;
+    const overflow = baseOverflow;
+
+    treeContextId = (1 << length) | id;
+    treeContextOverflow = overflow;
+  }
+}
+
+function getBitLength(number: number): number {
+  return 32 - clz32(number);
+}
+
+function getLeadingBit(id: number) {
+  return 1 << (getBitLength(id) - 1);
+}
+
+export function popTreeContext(workInProgress: Fiber) {
+  // Restore the previous values.
+
+  // This is a bit more complicated than other context-like modules in Fiber
+  // because the same Fiber may appear on the stack multiple times and for
+  // different reasons. We have to keep popping until the work-in-progress is
+  // no longer at the top of the stack.
+
+  while (workInProgress === treeForkProvider) {
+    treeForkProvider = forkStack[--forkStackIndex];
+    forkStack[forkStackIndex] = null;
+    treeForkCount = forkStack[--forkStackIndex];
+    forkStack[forkStackIndex] = null;
+  }
+
+  while (workInProgress === treeContextProvider) {
+    treeContextProvider = idStack[--idStackIndex];
+    idStack[idStackIndex] = null;
+    treeContextOverflow = idStack[--idStackIndex];
+    idStack[idStackIndex] = null;
+    treeContextId = idStack[--idStackIndex];
+    idStack[idStackIndex] = null;
+  }
+}
+
+export function getSuspendedTreeContext(): TreeContext | null {
+  warnIfNotHydrating();
+  if (treeContextProvider !== null) {
+    return {
+      id: treeContextId,
+      overflow: treeContextOverflow,
+    };
+  } else {
+    return null;
+  }
+}
+
+export function restoreSuspendedTreeContext(
+  workInProgress: Fiber,
+  suspendedContext: TreeContext,
+) {
+  warnIfNotHydrating();
+
+  idStack[idStackIndex++] = treeContextId;
+  idStack[idStackIndex++] = treeContextOverflow;
+  idStack[idStackIndex++] = treeContextProvider;
+
+  treeContextId = suspendedContext.id;
+  treeContextOverflow = suspendedContext.overflow;
+  treeContextProvider = workInProgress;
+}
+
+function warnIfNotHydrating() {
+  if (__DEV__) {
+    if (!getIsHydrating()) {
+      console.error(
+        'Expected to be hydrating. This is a bug in React. Please file ' +
+          'an issue.',
+      );
+    }
+  }
+}
diff --git a/packages/react-reconciler/src/ReactFiberUnwindWork.new.js b/packages/react-reconciler/src/ReactFiberUnwindWork.new.js
index ba8bd4b573957..bb002b9e71b3a 100644
--- a/packages/react-reconciler/src/ReactFiberUnwindWork.new.js
+++ b/packages/react-reconciler/src/ReactFiberUnwindWork.new.js
@@ -50,8 +50,14 @@ import {
   popCachePool,
 } from './ReactFiberCacheComponent.new';
 import {transferActualDuration} from './ReactProfilerTimer.new';
+import {popTreeContext} from './ReactFiberTreeContext.new';
 
 function unwindWork(workInProgress: Fiber, renderLanes: Lanes) {
+  // Note: This intentionally doesn't check if we're hydrating because comparing
+  // to the current tree provider fiber is just as fast and less error-prone.
+  // Ideally we would have a special version of the work loop only
+  // for hydration.
+  popTreeContext(workInProgress);
   switch (workInProgress.tag) {
     case ClassComponent: {
       const Component = workInProgress.type;
@@ -164,6 +170,11 @@ function unwindWork(workInProgress: Fiber, renderLanes: Lanes) {
 }
 
 function unwindInterruptedWork(interruptedWork: Fiber, renderLanes: Lanes) {
+  // Note: This intentionally doesn't check if we're hydrating because comparing
+  // to the current tree provider fiber is just as fast and less error-prone.
+  // Ideally we would have a special version of the work loop only
+  // for hydration.
+  popTreeContext(interruptedWork);
   switch (interruptedWork.tag) {
     case ClassComponent: {
       const childContextTypes = interruptedWork.type.childContextTypes;
diff --git a/packages/react-reconciler/src/ReactFiberUnwindWork.old.js b/packages/react-reconciler/src/ReactFiberUnwindWork.old.js
index ad8e479700db0..7f161513a4afa 100644
--- a/packages/react-reconciler/src/ReactFiberUnwindWork.old.js
+++ b/packages/react-reconciler/src/ReactFiberUnwindWork.old.js
@@ -50,8 +50,14 @@ import {
   popCachePool,
 } from './ReactFiberCacheComponent.old';
 import {transferActualDuration} from './ReactProfilerTimer.old';
+import {popTreeContext} from './ReactFiberTreeContext.old';
 
 function unwindWork(workInProgress: Fiber, renderLanes: Lanes) {
+  // Note: This intentionally doesn't check if we're hydrating because comparing
+  // to the current tree provider fiber is just as fast and less error-prone.
+  // Ideally we would have a special version of the work loop only
+  // for hydration.
+  popTreeContext(workInProgress);
   switch (workInProgress.tag) {
     case ClassComponent: {
       const Component = workInProgress.type;
@@ -164,6 +170,11 @@ function unwindWork(workInProgress: Fiber, renderLanes: Lanes) {
 }
 
 function unwindInterruptedWork(interruptedWork: Fiber, renderLanes: Lanes) {
+  // Note: This intentionally doesn't check if we're hydrating because comparing
+  // to the current tree provider fiber is just as fast and less error-prone.
+  // Ideally we would have a special version of the work loop only
+  // for hydration.
+  popTreeContext(interruptedWork);
   switch (interruptedWork.tag) {
     case ClassComponent: {
       const childContextTypes = interruptedWork.type.childContextTypes;
diff --git a/packages/react-reconciler/src/ReactInternalTypes.js b/packages/react-reconciler/src/ReactInternalTypes.js
index e971828233c10..9dac4f40aad53 100644
--- a/packages/react-reconciler/src/ReactInternalTypes.js
+++ b/packages/react-reconciler/src/ReactInternalTypes.js
@@ -44,6 +44,7 @@ export type HookType =
   | 'useMutableSource'
   | 'useSyncExternalStore'
   | 'useOpaqueIdentifier'
+  | 'useId'
   | 'useCacheRefresh';
 
 export type ContextDependency<T> = {
@@ -317,6 +318,7 @@ export type Dispatcher = {|
     getServerSnapshot?: () => T,
   ): T,
   useOpaqueIdentifier(): any,
+  useId(): string,
   useCacheRefresh?: () => <T>(?() => T, ?T) => void,
 
   unstable_isNewReconciler?: boolean,
diff --git a/packages/react-reconciler/src/clz32.js b/packages/react-reconciler/src/clz32.js
new file mode 100644
index 0000000000000..80a9cfb911482
--- /dev/null
+++ b/packages/react-reconciler/src/clz32.js
@@ -0,0 +1,25 @@
+/**
+ * Copyright (c) Facebook, Inc. and its affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ * @flow
+ */
+
+// TODO: This is pretty well supported by browsers. Maybe we can drop it.
+
+export const clz32 = Math.clz32 ? Math.clz32 : clz32Fallback;
+
+// Count leading zeros.
+// Based on:
+// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/clz32
+const log = Math.log;
+const LN2 = Math.LN2;
+function clz32Fallback(x: number): number {
+  const asUint = x >>> 0;
+  if (asUint === 0) {
+    return 32;
+  }
+  return (31 - ((log(asUint) / LN2) | 0)) | 0;
+}
diff --git a/packages/react-server/src/ReactFizzHooks.js b/packages/react-server/src/ReactFizzHooks.js
index 2596632652b43..bf1a2d56e0f93 100644
--- a/packages/react-server/src/ReactFizzHooks.js
+++ b/packages/react-server/src/ReactFizzHooks.js
@@ -17,8 +17,10 @@ import type {
 } from 'shared/ReactTypes';
 
 import type {ResponseState, OpaqueIDType} from './ReactServerFormatConfig';
+import type {Task} from './ReactFizzServer';
 
 import {readContext as readContextImpl} from './ReactFizzNewContext';
+import {getTreeId} from './ReactFizzTreeContext';
 
 import {makeServerID} from './ReactServerFormatConfig';
 
@@ -45,12 +47,15 @@ type Hook = {|
 |};
 
 let currentlyRenderingComponent: Object | null = null;
+let currentlyRenderingTask: Task | null = null;
 let firstWorkInProgressHook: Hook | null = null;
 let workInProgressHook: Hook | null = null;
 // Whether the work-in-progress hook is a re-rendered hook
 let isReRender: boolean = false;
 // Whether an update was scheduled during the currently executing render pass.
 let didScheduleRenderPhaseUpdate: boolean = false;
+// Counts the number of useId hooks in this component
+let localIdCounter: number = 0;
 // Lazily created map of render-phase updates
 let renderPhaseUpdates: Map<UpdateQueue<any>, Update<any>> | null = null;
 // Counter to prevent infinite loops.
@@ -163,18 +168,22 @@ function createWorkInProgressHook(): Hook {
   return workInProgressHook;
 }
 
-export function prepareToUseHooks(componentIdentity: Object): void {
+export function prepareToUseHooks(task: Task, componentIdentity: Object): void {
   currentlyRenderingComponent = componentIdentity;
+  currentlyRenderingTask = task;
   if (__DEV__) {
     isInHookUserCodeInDev = false;
   }
 
   // The following should have already been reset
   // didScheduleRenderPhaseUpdate = false;
+  // localIdCounter = 0;
   // firstWorkInProgressHook = null;
   // numberOfReRenders = 0;
   // renderPhaseUpdates = null;
   // workInProgressHook = null;
+
+  localIdCounter = 0;
 }
 
 export function finishHooks(
@@ -203,6 +212,14 @@ export function finishHooks(
   return children;
 }
 
+export function checkDidRenderIdHook() {
+  // This should be called immediately after every finishHooks call.
+  // Conceptually, it's part of the return value of finishHooks; it's only a
+  // separate function to avoid using an array tuple.
+  const didRenderIdHook = localIdCounter !== 0;
+  return didRenderIdHook;
+}
+
 // Reset the internal hooks state if an error occurs while rendering a component
 export function resetHooksState(): void {
   if (__DEV__) {
@@ -210,6 +227,7 @@ export function resetHooksState(): void {
   }
 
   currentlyRenderingComponent = null;
+  currentlyRenderingTask = null;
   didScheduleRenderPhaseUpdate = false;
   firstWorkInProgressHook = null;
   numberOfReRenders = 0;
@@ -495,6 +513,24 @@ function useOpaqueIdentifier(): OpaqueIDType {
   return makeServerID(currentResponseState);
 }
 
+function useId(): string {
+  const task: Task = (currentlyRenderingTask: any);
+  const treeId = getTreeId(task.treeContext);
+
+  // Use a captial R prefix for server-generated ids.
+  let id = 'R:' + treeId;
+
+  // Unless this is the first id at this level, append a number at the end
+  // that represents the position of this useId hook among all the useId
+  // hooks for this fiber.
+  const localId = localIdCounter++;
+  if (localId > 0) {
+    id += ':' + localId.toString(32);
+  }
+
+  return id;
+}
+
 function unsupportedRefresh() {
   throw new Error('Cache cannot be refreshed during server rendering.');
 }
@@ -524,6 +560,7 @@ export const Dispatcher: DispatcherType = {
   useDeferredValue,
   useTransition,
   useOpaqueIdentifier,
+  useId,
   // Subscriptions are not setup in a server environment.
   useMutableSource,
   useSyncExternalStore,
diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js
index 6692b92648643..f06cbc8fb61a3 100644
--- a/packages/react-server/src/ReactFizzServer.js
+++ b/packages/react-server/src/ReactFizzServer.js
@@ -25,6 +25,7 @@ import type {
 } from './ReactServerFormatConfig';
 import type {ContextSnapshot} from './ReactFizzNewContext';
 import type {ComponentStackNode} from './ReactFizzComponentStack';
+import type {TreeContext} from './ReactFizzTreeContext';
 
 import {
   scheduleWork,
@@ -78,12 +79,14 @@ import {
 import {
   prepareToUseHooks,
   finishHooks,
+  checkDidRenderIdHook,
   resetHooksState,
   Dispatcher,
   currentResponseState,
   setCurrentResponseState,
 } from './ReactFizzHooks';
 import {getStackByComponentStackNode} from './ReactFizzComponentStack';
+import {emptyTreeContext, pushTreeContext} from './ReactFizzTreeContext';
 
 import {
   getIteratorFn,
@@ -134,7 +137,7 @@ type SuspenseBoundary = {
   fallbackAbortableTasks: Set<Task>, // used to cancel task on the fallback if the boundary completes or gets canceled.
 };
 
-type Task = {
+export type Task = {
   node: ReactNodeList,
   ping: () => void,
   blockedBoundary: Root | SuspenseBoundary,
@@ -142,6 +145,7 @@ type Task = {
   abortSet: Set<Task>, // the abortable set that this task belongs to
   legacyContext: LegacyContext, // the current legacy context that this task is executing in
   context: ContextSnapshot, // the current new context that this task is executing in
+  treeContext: TreeContext, // the current tree context that this task is executing in
   componentStack: null | ComponentStackNode, // DEV-only component stack
 };
 
@@ -265,6 +269,7 @@ export function createRequest(
     abortSet,
     emptyContextObject,
     rootContextSnapshot,
+    emptyTreeContext,
   );
   pingedTasks.push(rootTask);
   return request;
@@ -302,6 +307,7 @@ function createTask(
   abortSet: Set<Task>,
   legacyContext: LegacyContext,
   context: ContextSnapshot,
+  treeContext: TreeContext,
 ): Task {
   request.allPendingTasks++;
   if (blockedBoundary === null) {
@@ -317,6 +323,7 @@ function createTask(
     abortSet,
     legacyContext,
     context,
+    treeContext,
   }: any);
   if (__DEV__) {
     task.componentStack = null;
@@ -497,6 +504,7 @@ function renderSuspenseBoundary(
     fallbackAbortSet,
     task.legacyContext,
     task.context,
+    task.treeContext,
   );
   if (__DEV__) {
     suspendedFallbackTask.componentStack = task.componentStack;
@@ -564,7 +572,7 @@ function renderWithHooks<Props, SecondArg>(
   secondArg: SecondArg,
 ): any {
   const componentIdentity = {};
-  prepareToUseHooks(componentIdentity);
+  prepareToUseHooks(task, componentIdentity);
   const result = Component(props, secondArg);
   return finishHooks(Component, props, result, secondArg);
 }
@@ -671,6 +679,7 @@ function renderIndeterminateComponent(
   }
 
   const value = renderWithHooks(request, task, Component, props, legacyContext);
+  const hasId = checkDidRenderIdHook();
 
   if (__DEV__) {
     // Support for module components is deprecated and is removed behind a flag.
@@ -742,7 +751,21 @@ function renderIndeterminateComponent(
     }
     // We're now successfully past this task, and we don't have to pop back to
     // the previous task every again, so we can use the destructive recursive form.
-    renderNodeDestructive(request, task, value);
+    if (hasId) {
+      // This component materialized an id. We treat this as its own level, with
+      // a single "child" slot.
+      const prevTreeContext = task.treeContext;
+      const totalChildren = 1;
+      const index = 0;
+      task.treeContext = pushTreeContext(prevTreeContext, totalChildren, index);
+      try {
+        renderNodeDestructive(request, task, value);
+      } finally {
+        task.treeContext = prevTreeContext;
+      }
+    } else {
+      renderNodeDestructive(request, task, value);
+    }
   }
   popComponentStackInDEV(task);
 }
@@ -827,7 +850,22 @@ function renderForwardRef(
 ): void {
   pushFunctionComponentStackInDEV(task, type.render);
   const children = renderWithHooks(request, task, type.render, props, ref);
-  renderNodeDestructive(request, task, children);
+  const hasId = checkDidRenderIdHook();
+  if (hasId) {
+    // This component materialized an id. We treat this as its own level, with
+    // a single "child" slot.
+    const prevTreeContext = task.treeContext;
+    const totalChildren = 1;
+    const index = 0;
+    task.treeContext = pushTreeContext(prevTreeContext, totalChildren, index);
+    try {
+      renderNodeDestructive(request, task, children);
+    } finally {
+      task.treeContext = prevTreeContext;
+    }
+  } else {
+    renderNodeDestructive(request, task, children);
+  }
   popComponentStackInDEV(task);
 }
 
@@ -1122,12 +1160,7 @@ function renderNodeDestructive(
     }
 
     if (isArray(node)) {
-      for (let i = 0; i < node.length; i++) {
-        // Recursively render the rest. We need to use the non-destructive form
-        // so that we can safely pop back up and render the sibling if something
-        // suspends.
-        renderNode(request, task, node[i]);
-      }
+      renderChildrenArray(request, task, node);
       return;
     }
 
@@ -1138,18 +1171,23 @@ function renderNodeDestructive(
       }
       const iterator = iteratorFn.call(node);
       if (iterator) {
+        // We need to know how many total children are in this set, so that we
+        // can allocate enough id slots to acommodate them. So we must exhaust
+        // the iterator before we start recursively rendering the children.
+        // TODO: This is not great but I think it's inherent to the id
+        // generation algorithm.
         let step = iterator.next();
         // If there are not entries, we need to push an empty so we start by checking that.
         if (!step.done) {
+          const children = [];
           do {
-            // Recursively render the rest. We need to use the non-destructive form
-            // so that we can safely pop back up and render the sibling if something
-            // suspends.
-            renderNode(request, task, step.value);
+            children.push(step.value);
             step = iterator.next();
           } while (!step.done);
+          renderChildrenArray(request, task, children);
           return;
         }
+        return;
       }
     }
 
@@ -1191,6 +1229,21 @@ function renderNodeDestructive(
   }
 }
 
+function renderChildrenArray(request, task, children) {
+  const totalChildren = children.length;
+  for (let i = 0; i < totalChildren; i++) {
+    const prevTreeContext = task.treeContext;
+    task.treeContext = pushTreeContext(prevTreeContext, totalChildren, i);
+    try {
+      // We need to use the non-destructive form so that we can safely pop back
+      // up and render the sibling if something suspends.
+      renderNode(request, task, children[i]);
+    } finally {
+      task.treeContext = prevTreeContext;
+    }
+  }
+}
+
 function spawnNewSuspendedTask(
   request: Request,
   task: Task,
@@ -1214,6 +1267,7 @@ function spawnNewSuspendedTask(
     task.abortSet,
     task.legacyContext,
     task.context,
+    task.treeContext,
   );
   if (__DEV__) {
     if (task.componentStack !== null) {
@@ -1257,6 +1311,7 @@ function renderNode(request: Request, task: Task, node: ReactNodeList): void {
       if (__DEV__) {
         task.componentStack = previousComponentStack;
       }
+      return;
     } else {
       // Restore the context. We assume that this will be restored by the inner
       // functions in case nothing throws so we don't use "finally" here.
diff --git a/packages/react-server/src/ReactFizzTreeContext.js b/packages/react-server/src/ReactFizzTreeContext.js
new file mode 100644
index 0000000000000..c9a47e5af72a6
--- /dev/null
+++ b/packages/react-server/src/ReactFizzTreeContext.js
@@ -0,0 +1,168 @@
+/**
+ * Copyright (c) Facebook, Inc. and its affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ * @flow
+ */
+
+// Ids are base 32 strings whose binary representation corresponds to the
+// position of a node in a tree.
+
+// Every time the tree forks into multiple children, we add additional bits to
+// the left of the sequence that represent the position of the child within the
+// current level of children.
+//
+//      00101       00010001011010101
+//      ╰─┬─╯       ╰───────┬───────╯
+//   Fork 5 of 20       Parent id
+//
+// The leading 0s are important. In the above example, you only need 3 bits to
+// represent slot 5. However, you need 5 bits to represent all the forks at
+// the current level, so we must account for the empty bits at the end.
+//
+// For this same reason, slots are 1-indexed instead of 0-indexed. Otherwise,
+// the zeroth id at a level would be indistinguishable from its parent.
+//
+// If a node has only one child, and does not materialize an id (i.e. does not
+// contain a useId hook), then we don't need to allocate any space in the
+// sequence. It's treated as a transparent indirection. For example, these two
+// trees produce the same ids:
+//
+// <>                          <>
+//   <Indirection>               <A />
+//     <A />                     <B />
+//   </Indirection>            </>
+//   <B />
+// </>
+//
+// However, we cannot skip any node that materializes an id. Otherwise, a parent
+// id that does not fork would be indistinguishable from its child id. For
+// example, this tree does not fork, but the parent and child must have
+// different ids.
+//
+// <Parent>
+//   <Child />
+// </Parent>
+//
+// To handle this scenario, every time we materialize an id, we allocate a
+// new level with a single slot. You can think of this as a fork with only one
+// prong, or an array of children with length 1.
+//
+// It's possible for the the size of the sequence to exceed 32 bits, the max
+// size for bitwise operations. When this happens, we make more room by
+// converting the right part of the id to a string and storing it in an overflow
+// variable. We use a base 32 string representation, because 32 is the largest
+// power of 2 that is supported by toString(). We want the base to be large so
+// that the resulting ids are compact, and we want the base to be a power of 2
+// because every log2(base) bits corresponds to a single character, i.e. every
+// log2(32) = 5 bits. That means we can lop bits off the end 5 at a time without
+// affecting the final result.
+
+export type TreeContext = {
+  +id: number,
+  +overflow: string,
+};
+
+export const emptyTreeContext = {
+  id: 1,
+  overflow: '',
+};
+
+export function getTreeId(context: TreeContext): string {
+  const overflow = context.overflow;
+  const idWithLeadingBit = context.id;
+  const id = idWithLeadingBit & ~getLeadingBit(idWithLeadingBit);
+  return id.toString(32) + overflow;
+}
+
+export function pushTreeContext(
+  baseContext: TreeContext,
+  totalChildren: number,
+  index: number,
+): TreeContext {
+  const baseIdWithLeadingBit = baseContext.id;
+  const baseOverflow = baseContext.overflow;
+
+  // The leftmost 1 marks the end of the sequence, non-inclusive. It's not part
+  // of the id; we use it to account for leading 0s.
+  const baseLength = getBitLength(baseIdWithLeadingBit) - 1;
+  const baseId = baseIdWithLeadingBit & ~(1 << baseLength);
+
+  const slot = index + 1;
+  const length = getBitLength(totalChildren) + baseLength;
+
+  // 30 is the max length we can store without overflowing, taking into
+  // consideration the leading 1 we use to mark the end of the sequence.
+  if (length > 30) {
+    // We overflowed the bitwise-safe range. Fall back to slower algorithm.
+    // This branch assumes the length of the base id is greater than 5; it won't
+    // work for smaller ids, because you need 5 bits per character.
+    //
+    // We encode the id in multiple steps: first the base id, then the
+    // remaining digits.
+    //
+    // Each 5 bit sequence corresponds to a single base 32 character. So for
+    // example, if the current id is 23 bits long, we can convert 20 of those
+    // bits into a string of 4 characters, with 3 bits left over.
+    //
+    // First calculate how many bits in the base id represent a complete
+    // sequence of characters.
+    const numberOfOverflowBits = baseLength - (baseLength % 5);
+
+    // Then create a bitmask that selects only those bits.
+    const newOverflowBits = (1 << numberOfOverflowBits) - 1;
+
+    // Select the bits, and convert them to a base 32 string.
+    const newOverflow = (baseId & newOverflowBits).toString(32);
+
+    // Now we can remove those bits from the base id.
+    const restOfBaseId = baseId >> numberOfOverflowBits;
+    const restOfBaseLength = baseLength - numberOfOverflowBits;
+
+    // Finally, encode the rest of the bits using the normal algorithm. Because
+    // we made more room, this time it won't overflow.
+    const restOfLength = getBitLength(totalChildren) + restOfBaseLength;
+    const restOfNewBits = slot << restOfBaseLength;
+    const id = restOfNewBits | restOfBaseId;
+    const overflow = newOverflow + baseOverflow;
+    return {
+      id: (1 << restOfLength) | id,
+      overflow,
+    };
+  } else {
+    // Normal path
+    const newBits = slot << baseLength;
+    const id = newBits | baseId;
+    const overflow = baseOverflow;
+    return {
+      id: (1 << length) | id,
+      overflow,
+    };
+  }
+}
+
+function getBitLength(number: number): number {
+  return 32 - clz32(number);
+}
+
+function getLeadingBit(id: number) {
+  return 1 << (getBitLength(id) - 1);
+}
+
+// TODO: Math.clz32 is supported in Node 12+. Maybe we can drop the fallback.
+const clz32 = Math.clz32 ? Math.clz32 : clz32Fallback;
+
+// Count leading zeros.
+// Based on:
+// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/clz32
+const log = Math.log;
+const LN2 = Math.LN2;
+function clz32Fallback(x: number): number {
+  const asUint = x >>> 0;
+  if (asUint === 0) {
+    return 32;
+  }
+  return (31 - ((log(asUint) / LN2) | 0)) | 0;
+}
diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js
index 7bd7a95c25611..bba34065cc268 100644
--- a/packages/react-server/src/ReactFlightServer.js
+++ b/packages/react-server/src/ReactFlightServer.js
@@ -846,6 +846,7 @@ const Dispatcher: DispatcherType = {
   useImperativeHandle: (unsupportedHook: any),
   useEffect: (unsupportedHook: any),
   useOpaqueIdentifier: (unsupportedHook: any),
+  useId: (unsupportedHook: any),
   useMutableSource: (unsupportedHook: any),
   useSyncExternalStore: (unsupportedHook: any),
   useCacheRefresh(): <T>(?() => T, ?T) => void {
diff --git a/packages/react-suspense-test-utils/src/ReactSuspenseTestUtils.js b/packages/react-suspense-test-utils/src/ReactSuspenseTestUtils.js
index a3fe873729828..731124ea51593 100644
--- a/packages/react-suspense-test-utils/src/ReactSuspenseTestUtils.js
+++ b/packages/react-suspense-test-utils/src/ReactSuspenseTestUtils.js
@@ -43,6 +43,7 @@ export function waitForSuspense<T>(fn: () => T): Promise<T> {
     useDeferredValue: unsupported,
     useTransition: unsupported,
     useOpaqueIdentifier: unsupported,
+    useId: unsupported,
     useMutableSource: unsupported,
     useSyncExternalStore: unsupported,
     useCacheRefresh: unsupported,
diff --git a/packages/react/index.classic.fb.js b/packages/react/index.classic.fb.js
index c5854f8f6d398..a2e678c580b2e 100644
--- a/packages/react/index.classic.fb.js
+++ b/packages/react/index.classic.fb.js
@@ -41,6 +41,7 @@ export {
   unstable_getCacheForType,
   unstable_useCacheRefresh,
   unstable_useOpaqueIdentifier,
+  unstable_useId,
   useCallback,
   useContext,
   useDebugValue,
diff --git a/packages/react/index.experimental.js b/packages/react/index.experimental.js
index 24fc9782595d4..20c22828583f5 100644
--- a/packages/react/index.experimental.js
+++ b/packages/react/index.experimental.js
@@ -37,6 +37,7 @@ export {
   unstable_getCacheForType,
   unstable_useCacheRefresh,
   unstable_useOpaqueIdentifier,
+  unstable_useId,
   useCallback,
   useContext,
   useDebugValue,
diff --git a/packages/react/index.js b/packages/react/index.js
index 9a6a99ee52189..3108c06c55284 100644
--- a/packages/react/index.js
+++ b/packages/react/index.js
@@ -62,6 +62,7 @@ export {
   unstable_getCacheForType,
   unstable_useCacheRefresh,
   unstable_useOpaqueIdentifier,
+  unstable_useId,
   useCallback,
   useContext,
   useDebugValue,
diff --git a/packages/react/index.modern.fb.js b/packages/react/index.modern.fb.js
index 8d08a43b90946..eef99fdabf2a4 100644
--- a/packages/react/index.modern.fb.js
+++ b/packages/react/index.modern.fb.js
@@ -40,6 +40,7 @@ export {
   unstable_getCacheForType,
   unstable_useCacheRefresh,
   unstable_useOpaqueIdentifier,
+  unstable_useId,
   useCallback,
   useContext,
   useDebugValue,
diff --git a/packages/react/index.stable.js b/packages/react/index.stable.js
index 867980fa5389d..517dc3f8fb2db 100644
--- a/packages/react/index.stable.js
+++ b/packages/react/index.stable.js
@@ -30,6 +30,7 @@ export {
   memo,
   startTransition,
   unstable_useOpaqueIdentifier,
+  unstable_useId,
   useCallback,
   useContext,
   useDebugValue,
diff --git a/packages/react/src/React.js b/packages/react/src/React.js
index d29858c9b07fd..868538c83f59b 100644
--- a/packages/react/src/React.js
+++ b/packages/react/src/React.js
@@ -53,6 +53,7 @@ import {
   useTransition,
   useDeferredValue,
   useOpaqueIdentifier,
+  useId,
   useCacheRefresh,
 } from './ReactHooks';
 import {
@@ -127,5 +128,6 @@ export {
   // enableScopeAPI
   REACT_SCOPE_TYPE as unstable_Scope,
   useOpaqueIdentifier as unstable_useOpaqueIdentifier,
+  useId as unstable_useId,
   act,
 };
diff --git a/packages/react/src/ReactHooks.js b/packages/react/src/ReactHooks.js
index 1892f926c59cf..1f987de1671ba 100644
--- a/packages/react/src/ReactHooks.js
+++ b/packages/react/src/ReactHooks.js
@@ -174,6 +174,11 @@ export function useOpaqueIdentifier(): OpaqueIDType | void {
   return dispatcher.useOpaqueIdentifier();
 }
 
+export function useId(): string {
+  const dispatcher = resolveDispatcher();
+  return dispatcher.useId();
+}
+
 export function useMutableSource<Source, Snapshot>(
   source: MutableSource<Source>,
   getSnapshot: MutableSourceGetSnapshotFn<Source, Snapshot>,
diff --git a/packages/react/unstable-shared-subset.experimental.js b/packages/react/unstable-shared-subset.experimental.js
index a663ca8a5a89d..d556400750b4c 100644
--- a/packages/react/unstable-shared-subset.experimental.js
+++ b/packages/react/unstable-shared-subset.experimental.js
@@ -28,6 +28,7 @@ export {
   unstable_getCacheSignal,
   unstable_getCacheForType,
   unstable_useOpaqueIdentifier,
+  unstable_useId,
   useCallback,
   useContext,
   useDebugValue,