DEV Community

Martin
Martin

Posted on • Edited on

1

What's wrong with useId()? (React and Preact)

TLDR: useId will produce duplicated IDs when you have more than one app root. Read on and find out what can be done about it.

Introduction

Let's start at the beginning: why do we even have a useId hook? You can skip the introduction if you are familiar with the hook itself and the reason for its existence.

DOM IDs in Components

When we need to associate a DOM element with another one, like in the following snippet the <label> and the <input>, we usually need to set an HTML id attribute.

<span>Do you want to receive exciting news about all our products?</span>
<input type="checkbox" id="spam-galore" />
<label htmlFor="spam-galore">Yes, please send more spam my way</label>
Enter fullscreen mode Exit fullscreen mode

Nesting the <input> inside the <label> is an alternative way to associate the two elements without using an ID, but this is not always feasible; and there are other element types where nesting is not possible, which is the case in the following snippet with <input> and associated <datalist>.

<input list="preferred-conference-swag" />
<datalist id="preferred-conference-swag">
  <option value="Hoodie"></option>
  <option value="Ballpoint Pen"></option>
  <option value="Fidget Toy"></option>
  <option value="Stickers"></option>
</datalist>
Enter fullscreen mode Exit fullscreen mode

A hard-coded ID only works correctly if there is never more than one instance of the component in the DOM.
So for all other cases we need to solve this by generating a dynamic ID; ideally one that stays the same as long as the component instance exists (i.e. as long as it is mounted).
This can be done a hundred different ways and isn't exactly rocket science (see section Roll your own for inspiration).
So every project sooner or later had its own custom hook implementation to provide dynamic and lifetime-stable IDs.

With release 18.0.0 (March 29, 2022) React introduced the useId hook.

useId is a new hook for generating unique IDs on both the client and server, while avoiding hydration mismatches.
(from the release notes)

While implementing our own random-ID-generating hook isn't much of a challenge, we would run into problems when our app were to use server-side rendered (SSR) parts and the IDs generated on the server would not match the IDs generated on the client.
The useId hook generates a deterministic ID that would be the same client-side and server-side.

This would make all those custom hooks obsolete and a thing of the past. Or so I thought.

A Discovery of Trouble

I was using the new hook from time to time without noticing any issues for a while. But a few days ago I ran into a strange bug: a label associated with a checkbox would not correctly toggle the checkbox when clicked. Following a suspicion I quickly verified that the association was working fine on the very first input-and-label pair in my DOM, and discovered that all later pairs used the very same ID; so all the other labels were all associated with the very first checkbox.

My application is not the classical SPA; it's a collection of smart components that can be placed in a regular HTML web page; and each placed component constitutes its own app root. (Aside: A while ago I read somewhere that this is nowadays called islands.)
And the same component might be placed multiple times into the same page.

As it turns out, the useId hook will produce duplicated IDs when used in a multi root scenario.
And this is an issue for both implementations: React and Preact.
If you are interested to see the bug demonstrated: I created a small reproduction scenario.

An Attempt at an Explanation

As far as I understand, the requirement to produce the same ID for any given component instance in the virtual DOM when rendered client-side or when rendered server-side bars the useId implementation from using any randomness; only the component position in the virtual DOM and/or the calling order (so anything that is stable w.r.t. client-side vs server-side rendering) can be used to generate an ID that is stable but also unique within the same app.

An Attempt at a Solution

Both the React team and the Preact team are aware of the issue.

React

React provides a way to resolve the problem manually by specifying a shared prefix for all generated IDs.

If you render multiple independent React applications on a single page, pass identifierPrefix as an option to your createRoot or hydrateRoot calls. This ensures that the IDs generated by the two different apps never clash because every identifier generated with useId will start with the distinct prefix you’ve specified.

const root1 = createRoot(document.getElementById("root1"), {
  identifierPrefix: "my-first-app-",
});
root1.render(<App />);

const root2 = createRoot(document.getElementById("root2"), {
  identifierPrefix: "my-second-app-",
});
root2.render(<App />);
Enter fullscreen mode Exit fullscreen mode

It is certainly not ideal that the issue has to be resolved manually, but I'm sure given the requirement mentioned earlier that there really isn't any other way without ditching stable between client-rendered and server-rendered.

Preact

The Preact team is aware of the issue, but has not yet mirrored the identifierPrefix feature.

Luckily for me the server-side rendering aspect is irrelevant as my components are only ever rendered client-side.
So my solution to the problem currently is to just use a custom hook instead of useId, to generate dynamic and lifetime-stable IDs and using randomness to my heart's content; see section Custom Hook with Randomness for implementation details.

But what if you are not so lucky? When your setup uses Preact with client-side and server-sider rendering?
In this case I would recommend to emulate the identifierPrefix feature until it is provided by Preact out of the box; see section identifierPrefix in Preact for implementation details.

Roll your own

This section provides code examples for both approaches:

  • a custom hook with randomness (client-side-only approach)
  • identifierPrefix in Preact (client-side and server-side)

Custom Hook with Randomness

One implementation of my homebrewn useStableID could look like this:

import { getRandomID } from "any-random-ID-thing-would-work-here";

const ids = new Map();
export const useStableID = () => {
  const ref = useRef({});
  if (!ids.has(ref.current)) {
    const existingIDs = [...ids.values()];
    let newID;
    do {
      newID = getRandomID();
    } while (existingIDs.includes(newID));
    ids.set(ref.current, newID);
  }
  return ids.get(ref.current);
};
Enter fullscreen mode Exit fullscreen mode

You could even use Math.random() here because every new ID is checked against the existing IDs to prevent clashes.

export const getRandomID = () => `id-${Math.random()}`;
Enter fullscreen mode Exit fullscreen mode

Another, shorter implementation using GUIDs could look like this:

import { v4 as uuidv4 } from "uuid";

export const useStableID = () => {
  const [id] = useState(() => uuidv4());
  return id;
};
Enter fullscreen mode Exit fullscreen mode

Here checking each newly generated ID against the existing IDs can be omitted because GUIDs / UUIDs are unique with sufficient probability (that's really the whole point of them).

identifierPrefix in Preact

Disclaimer: I haven't tried this out yet; but to the best of my knowledge this should work just as expected.

You can emulate the identifierPrefix feature by wrapping your root in a context provider, passing down a prefix to your patched useId hook.

The context:

import { createContext } from "preact";

export const IdentifierPrefix = createContext(null);
Enter fullscreen mode Exit fullscreen mode

Providing a prefix:

import { IdentifierPrefix } from "./identifierPrefixContext";

const root1 = document.getElementById("root1");
render(
  <IdentifierPrefix.Provider value="my-first-app-">
    <App />
  </IdentifierPrefix.Provider>,
  root1
);

const root2 = document.getElementById("root2");
render(
  <IdentifierPrefix.Provider value="my-second-app-">
    <App />
  </IdentifierPrefix.Provider>,
  root2
);
Enter fullscreen mode Exit fullscreen mode

The patched useId hook:

import { useContext } from "preact/hooks";
import { IdentifierPrefix } from "./identifierPrefixContext";

export const usePatchedUseId = () => {
  const identifierPrefix = useContext(IdentifierPrefix);
  const id = useId();
  return `${identifierPrefix}${id}`;
};
Enter fullscreen mode Exit fullscreen mode

Using usePatchedUseId everywhere instead of useId should now solve your problem.
You'll need to provide the same prefix values on the client and on the server.

Future Updates and Further Reading

I will try to keep this article updated when further information comes to my notice or when there is some progress on the Preact issue.

My initial research also turned up the following links:

  • mirroring react's useId hook in preact was discussed here
  • the initial preact implementation of the useId hook had some issues, which were reported here

DevCycle image

OpenFeature Multi-Provider: Enabling New Feature Flagging Use-Cases

DevCycle is the first feature management platform with OpenFeature built in. We pair the reliability, scalability, and security of a managed service with freedom from vendor lock-in, helping developers ship faster with true OpenFeature-native feature flagging.

Watch Full Video 🎥

Top comments (0)