See Our Public Workshops:
tl;dr
- Use
useRef()
instead of hand-made IDs if you want DOM access from JS - Use
useId()
instead of hand-made IDs to link two DOM nodes for accessibility.
We think you should be using the useId()
hook more often. If you find that you're not using it, there's a very good chance your app or site is either inaccessible (a11y) or you're creating code that is more prone to errors. Let me explain.
ID's are needed to create associations in our DOM for accessibility:
<div><label for="firstName">First Name</label><input type="text" id="firstName" /><button aria-controls="modal">Open something</button><div id="modal"></div></div>
If you're not using aria attributes and roles, then unfortunately your site is probably not nearly as accessible as you might hope.
The other popular use-case for IDs is for referencing the DOM. You might have a div
or an input
that you need direct access to and you use document.getElementByID()
for that access:
useEffect(() => {document.getElementById('firstName').focus()}, [])
IDs need to be unique. You probably new that
The problem with hand-making IDs is that you have no way to certainly know you're making something unique. If your JavaScript accesses IDs via document.getElementByID()
, you're creating situations where it's possible for one component to access the wrong DOM node because of duplicate IDs.
Devs will sometimes try to "namespace" their IDs to help ensure uniqueness, like login-form-username
in this case:
function LoginForm() {useEffect(() => {document.getElementById('login-form-username').focus()}, [])return (<form onSubmit={onSubmit}><label htmlFor="login-form-username">Username</label><input type="text" id="login-form-username" name="username" />{/* ... */}</form>)}
This is what I call a name-spaced ID because the dev is trying to create uniqueness by creating a longer ID that includes something about the page or component built into the ID. It might feel fine at first, but this idea doesn't work for a lot of situations in React where a component is used twice in the DOM. You could never use a component library that has tabs, dropdown menus, and other highly re-usable components and depend on hand-made IDs because they would certainly fail when that <DropDown />
component is being used twice in the page.
Use a ref instead
For accessing the DOM from JavaScript, use useRef()
instead. It's designed to be a safe way to access the DOM that scales to large apps with zero possibility of creating overlap such that your components might access the wrong DOM.
A11y
Using refs instead of IDs is a great idea, but that only works for situations where you want to access the DOM from JavaScript. What about linking two DOM nodes for a11y? Refs don't solve that problem for us so we'll need to make an ID. To ensure uniqueness, we could try to use some strategy that makes a unique string? What if we used uuid()
or some other hashing or unique string algorithm?
function Comp() {const uniqueId = uuid()return (<div><button aria-controls={uniqueId}>Open something</button><div id={uniqueId}></div></div>)}
At first you might think the problem is re-rendering, that we might want to memoize this value to keep it the same throughout our renders. That's actually not a problem in this case because we only need to link two DOM nodes and we don't really care if this value changes.
The problem with these strategies is re-hydration. If you were using SSR, this component would generate one id on the server and another id on the client. When it re-hydrates, it will be problematic.
The "we don't use SSR" fallacy
Maybe your project doesn't use SSR right now, but I talk to lots of companies in workshops and most of them are making some sort of internal "component re-use" strategy for several projects. I tell them it's only a matter of time before those re-usable components end up being imported into a project that does SSR (like Remix and Next) so you might as well follow SSR best practices from the start.
Unique, Hydrate-Safe IDs
The reason useID()
exists is to create unique IDs that will hydrate from SSR to client. When you call useId()
numerous times in your app, it is creating an auto-incrementing number internally:
function MyComp() {const first = useId()console.log(first) // :r0:const second = useId()console.log(second) // :r1:}
If your component is getting :r0:
and :r1:
now, but tomorrow there's a re-factor and other components before this one use useId()
, then maybe you'll get :r4:
and :r5:
later. That's fine, we don't need a predictable string because we're just linking two things.
function Comp() {const uniqueId = useId()return (<div><button aria-controls={uniqueId}>Open something</button><div id={uniqueId}></div></div>)}
This strategy also works when it comes to your App code importing someone's library code. If you import some UI library component that uses useId()
, it will play nicely with all your calls to useId()
.
Why is my app "inaccessible"?
The reason that I stated your app is inaccessible or error prone is simply because you're either embracing a11y and aria or you're not. If you're not embracing it, well then it's inaccessible or at the very least, much less accessible. If you are embracing a11y but with hand-made ID's, well then it's likely that you have error prone code.
Not on React 18?
If you want to use useId()
, it's only available in React 18. However, years ago Ryan Florence made https://reach.tech which actually created it's own useId()
way before React 18. Ryan's also uses super fancy tricks to re-hydrate correctly. If you're not on React 18 yet, you can import the one from Reach:
// npm install @reach/auto-idimport { useId } from '@reach/auto-id'
Even though Reach isn't actively maintained, this custom hook is well-written and will serve your purposes while you get to React 18 later.
Instead of having comments here in our blog, we've tweeted about this post so you can comment there if you wish.
View on Twitter