I've recently been working on a small React component that lets the user increase or decrease a set quantity. Nothing too flashy, just a couple of buttons that either add or subtract "1" from the current value, and a label that displays the result. As we're using React, managing the quantity value is easy enough: create a stateful variable using useState
and then add a few click event handlers to the buttons. Y'know, something like this:
const [quantity, setQuantity] = React.useState(1) return ( <> <button type="button" onClick={setQuantity(quantity - 1)}> Decrease Quantity </button> <p>Quantity: {quantity}</p> <button type="button" onClick={setQuantity(quantity + 1)}> Increase Quantity </button> </> )
This works perfectly well. Add a little bit of styling and we have a simple, reusable component with some self-contained functionality. Job done, we can all go home 🎉
Well, not quite.
See, this works fine for people browsing the web visually, but anyone else gets a suboptimal experience. We're using native HTML elements, so I can be confident that the buttons will be correctly interpreted, focusable, etc, and everything is labelled, so the intent, purpose, and meaning should be pretty well conveyed[1]. But only visual users will benefit from the quantity being dynamically updated; for everyone else, it isn't clear that interacting with a button is actually doing anything. Sure, most non-visual navigation methods will let you hop onto the text, and that should then be read out correctly, but that's not the user experience we're aiming for. I want the user to be immediately aware that the quantity has been changed, and what the new value is, and for that, we need a dash of ARIA.
It's ALIVE! 🌩
Luckily, the ARIA spec has exactly what I'm looking for: "live regions". A live region is a page section that can dynamically update, which tells assistive technologies and web browsers to track the contents and let the user know when they change. Live regions are specifically intended for use when a section is not in focus (i.e. you shouldn't make an input or text area a live region). As MDN puts it:
Live regions are sections of a web page that are updated, whether by user interaction or not, when user focus is elsewhere.
There are a few different configurations for live regions, but for our purposes, we're interested in aria-live
and aria-atomic
:
aria-live
is how you tell the browser which DOM nodes are "live". It can take a couple of different values, which let you define how important changes to this region should be, but generally you'll want to usepolite
.aria-atomic
provides a little more direct control over which parts of a live region are included in the update, and is useful here because we have some static text. By setting this to true, we force the entire contents of the<p>
element to be resent each time any change is noted. So every time the quantity is changed, the user will be updated with the whole string (e.g. "quantity 2") rather than just the part of the string that has changed (e.g. "2").
With these in place, we now have a much more inclusive component:
const [quantity, setQuantity] = React.useState(1) return ( <> <button type="button" onClick={setQuantity(quantity - 1)}> Decrease Quantity </button> <p aria-live="polite" aria-atomic="true"> Quantity: {quantity} </p> <button type="button" onClick={setQuantity(quantity + 1)}> Increase Quantity </button> </> )
If you're browsing the web via an audio tool like a screen reader or voice assistant, pressing a button will now relay the current quantity as well. So if you press the "increase quantity" button twice, a screen reader would announce something along the lines of: "increase quantity button, clicked, quantity 2, quantity 3". Nice!
But... when I actually implemented this, it didn't work 😬 What's going on?
React Breaks Reactivity
(Because of course it does)
To be fair, this isn't always the case. In Firefox and Chrome on Windows; in Chromium browsers on macOS; and in Chrome on Android, this code worked correctly. I could load up screen readers like NVDA or VoiceOver, and I'd hear the change in quantity announced as expected.
But with VoiceOver in Firefox or Safari on macOS (and, on iOS across the board, seeing as it's all Safari) I'd get nothing. Nada. Zip. No dice[2].
What gives?
Honestly, I'm still not really sure. Despite this being a fairly common UX pattern (in my opinion) I found very little online discussion around likely causes, possible solutions, or alternative methods. I couldn't even find any (relevant) bug tickets[3]. The closest I could find was (of course) on Stack Overflow, where user "Jake Loew" had put together two extremely useful minimal test cases showing how similar code in vanilla JavaScript worked fine, but React broke entirely. Unfortunately, the discussion on that question was not exactly useful, so I tested those MVPs myself and, sure enough, the vanilla implementation worked across all browser/AT combos I tried, whilst the React version broke in Safari and Firefox on macOS. It looks like React is the magic ingredient spoiling the broth. Sigh! 😑
Any Solutions?
Yes! 🥳
Well, sort of. It's a solution in that it works in my limited testing and solves my immediate problem, which was enough for us. (I'll update here if more thorough testing highlights any additional issues)
But it's not a solution I particularly like. Sure, it's based on some fairly robust prior art from the Deque team – and they generally know their stuff – but it still requires an additional dependency and – worst of all – it makes the compiled, client-side code fairly opaque and unclear in how it's working. But enough prattle, how does this work?
First, install the react-aria-live
package (as seen on npm). This works by abstracting all live regions across your site into a single, universal live region, injected near the root of the DOM tree. You implement this part of the puzzle by placing their <LiveAnnouncer>
React component as near to the root element in your site's structure as possible[4].
With that in place, you can now add a <LiveMessage>
component within your <p>
element. Confusingly, this doesn't actually render anything to the DOM itself, so you still need to keep your existing text node. Despite that, you should apply the ARIA attributes to the <LiveMessage>
component directly, and that takes care of the rest[5]. The end result looks a little like this:
import { LiveMessage } from 'react-aria-live' ... const [quantity, setQuantity] = React.useState(1) const message = `Quantity: ${quantity}` return ( <> <button type="button" onClick={setQuantity(quantity - 1)}> Decrease Quantity </button> <p> <LiveMessage message={message} aria-live="polite" aria-atomic="true" /> { message } </p> <button type="button" onClick={setQuantity(quantity + 1)}> Increase Quantity </button> </> )
There is some additional functionality available within the library if you need it, but for me, this was enough to fix our immediate issues and get live regions working across all (currently tested) combinations of browsers and assistive technology 🙌
As I say, I'm not exactly a huge fan of this solution (and I'll certainly be adding this to my list of "reasons React is probably not the best choice for new projects") but, if you're stuck in React-land, at least it provides a useable escape hatch so that you can use live regions with native browser behaviour, rather than re-implementing the functionality yourself (an idea that makes me shudder 😅). Of course, if anyone has any better solutions or knows of any reasons why this is a generally bad idea, please let me know and I'll leave a note here 👍