- What you're looking at is an project dashboard demo app with an account selector. This is based on a real feature I built for my current project, the inspiration behind this talk.
- Let’s pretend your designer gave this nice custom UI in Figma, with a custom account select that didn't exist in your component library. So you built it yourself. And all is well, right? Let's try this out.
- The keyboard navigation is incorrectly implemented, trying to use arrows, I have to use tabs when I should be using the arrow keys, does not close moving to next element. It does not close on escape click or on click outside.
- This account select allows me to switch account, get a loading state spinner, a toast, and see the updated dashboard. There was a strange interaction there, the loading state was not entirely in sync with the visual update.
- My toast is also out of sync, it shows the success message before the dashboard has updated. functionality (show with console).
- I have these challenges: I'm trying to build a custom UI component, yet I want it to be accessible. But I'm not an accessibility expert. I also want to smoothly handle async operations with a good UX. But I don't want to write lot's of code to get all this right.
- This situation was me not long ago.
- That's why, the goal of this demo: show you how to make this custom account selector interactive and accessible, and improve the unstable UX using certain tools: Ariakit and React 19. Let's get to the code!
- The setup is the Next.js App Router, Prisma ORM and an Prisma Postgres DB, and Tailwind CSS v4.
- I already completed all the data fetching here.
- Page.tsx: Since app Router, can use Server components fetching data through this data access layer, getting all accounts from the database, getCurrentAccount based on a cookie value simulating auth, getting that account and, caching everything with cache() to avoid duplicate requests. When loading the page, the server components are completed on the server then streamed in and suspense fallbacks are used to create loading states.
- Everything is dynamically rendered since we are using cookies. And it's artificially slowed down so we can see the loading states.
- (And a bonus: using Next.js unauthorized.tsx error boundary to catch a thrown 401 forbidden(9) error if the account cookie is not a valid account).
- All of this is not the focus of today and covered in detail in a previous talk of mine if you're interested! Or just check out the code on GitHub later.
- Now, thats the setup, but our problems are is the AccountSelector component. It's reading a promise from the server of the current account with React 19 use().
- Showcase the typical React code using isLoading, expanded states. Mutation through endpoint contains lots of boilerplate code. A sort of naive optimistic update here using setState. Quite a lot of code, probably prone to bugs. Let's say I just coded this up without thinking too much about it, and it works, but it could be better.
- For the select, it's hard to read the divs and spans, styled based on requirements, I even marked them so I can find them. I should maybe have extracted this to components. And probably I should have used different elements. I'm using state variables to define styles which is not optimal.
- Let's try to fix the accessibility issues. Let's try to implement the keyboard navigation correctly. It's not up to standard, though.
- Add snippet for escape key. Looks familiar?
- Add snippet for arrow keys and focus trap, don't complete it. Let's not go there.
- These are just one(two) of the many things we need to implement for a select. Don't even get me started on screen reader support, by adding roles and things like aria-expanded and aria-activedescendant, so that the screen reader user even knows that this is a select! Again, I'm not an a11y expert!
- (Who has tried to implement some of this stuff correctly? Who has failed? I did multiple times as well. It's a lot of work to get this right. And even if you do, it will be hard to maintain and extend).
- So. We don't want to do this. Delete the snippet code.
- Let's use Ariakit to solve this problem. Import ariakit.
- Ariakit is a React library that provides unstyled, primitive components and hooks for building interactive UIs. Provides declarative components that are accessible by default following WCAG-standard and have all the functionality we need built in, and can compose together.
- Lets step by step replace all the divs with Ariakit equivalents.
- Remove "relative" from parent div
- Provider: What is this component? Most similar a Select. Could be Menu. There is not 100% right in accessibility, 70% is good enough. Add ariakit Ariakit.SelectProvider between.
- Label: Replace label div with Ariakit.SelectLabel
- Select: Replace open button with Ariakit.Select and remove setExpanded
- SelectArrow: We can't use expanded state anymore, replace chevron icon inside with Ariakit.SelectArrow, add class "group" to the Ariakit.Select and use group-expanded for the icon rotate rather than the useState. Showcase.
- SelectButton: Replace all styles and render SelectButton and showcase aria-expanded. Flexible with render prop.
- SelectPopover: Open the popover, remove expanded wrapper, replace "absolute" div Ariakit.SelectPopover, remove top-20, and add gutter={8}, open the popover. Showcase click outside and escape close, viewport aware placement.
- SelectItem: Replace Icon item with Ariakit.SelectItem, replace hover: with data-active-item, the active item functionality is built in to Ariakit and stylable with data-active-item
- SelectItem: Replace item with Ariakit.SelectItem, and use data-active-item: rather than hover:, replace focus-visible with data-focus-visible to differentiate between the mouse and keyboard focus correctly, replace disabled: with aria-disabled, the disabled={} prop now is correctly implemented behind the scenes by Ariakit. Showcase keyboard navigation, focus trap.
- SelectItemCheck: Replace the selected item check with Ariakit.SelectItemCheck and add value={account.id}, Provider value={currentAccount?.id}
- Remove setExpanded from handleSwitchAccount, remove expanded useState.
- All of this is in the documentation! And there are docs for non-tailwind users as well. And so many ariakit primitives, not only for selects, but also for menus, dialogs, tooltips, and more. And they can be composed together, and the docs contain examples of how to combine them.
- No longer have nameless divs, rather used declarative components with Ariakit without compromising on the customizability.
- Now lets get to work on the async operation, the account switching. This is a lot of boilerplate code, and we need to fix the out of sync spinner.
- Fortunately, React has a API for this: transitions. Transitions allow react to coordinate async requests in events and render. Let's see how we can use transitions here to create a better experience, with no sync issues, and less code.
- To track the loading state, lets use the improved useTransition hook from React 19. It let's use mark a state update as non-urgent or deferred, and commits all of them once they are all done. Returns pending state for the transition and a startTransition function.
- Remove pending state useState.
- Creating an Action. An action is a function called in a transition, meaning we have a specific term for this type of lower priority behavior.
- Test that it works. The spinner is correctly synced to the UI update of the dashboard now.
- However, the toast is not yet in sync with the UI update. Edge case of UI after an await, we need another startTransition to ensure the toast is shown after the UI update. Will not be needed in future React versions.
- This is just one simple example, theres so much power in transitions.
- Now the API call. Let's replace this with a function call to the server, switchAccount.
- (Create new file auth.ts with "use server", copy the API code. Call the server function inside the onClick. Type safe.)
- Call the server function inside the onClick instead of API. Showcase server function in account.ts. We can with React 19 server functions call server code from the client as a function, it creates a hidden API endpoint. Here setting the cookie simulating switching accounts. Erroring if the account is not active.
- Type safe with RPC.
- Delete api code and api layer. No type safety here by the way, I just deleted the endpoint but there was no way to know.
- Update the toast code to use response.error. Test it. It still works!
- Replace router.refresh with revalidatePath inside the server function, so that the page is revalidated and the new account and data is fetched from the server. Remove router.
- What about the current setState optimistic update here. It adds this additional code with this manual rollback. Imagine if we had more logic, the rollback would get increasingly complex.
- Remove naive useState and use currentAccountResolved directly, rename to currentAccount. See delayed update on the select.
- I now have the UX problem of the select values not updating until the async operation is done. The select is not reflecting the user action immediately, it feels "stuck".
- To avoid the delayed update on the select depending on the server, let's use the new useOptimistic hook from React 19. It takes in a state to show when no transition is pending, which is our server truth of the currentAccount, and returns a optimistic account state and a function to update it.
- Call useOptimistic hook above the server function inside the transition. Use the optimistic value for all the existing account variables (remember inside handleSwitchAccount).
- Within a transition, we can create a temporary optimistic update. This state shows for as long as the transitions run, and when its done, settles to the passed value. Seamlessly merge with the new value.
- Showcase the optimistic update in the UI. The select updates immediately, and the loading state is shown in the background.
- Showcase failure state by removing the disabled prop. We get automatic "rollback" because the optimistic value is not the same as the server value, it's just a temporary state.
- UseOptimistic creates a temporary state that is shown while the transition is running, then throws it away and settles to the passed value.
- Notice how our handleSwitchAccount interaction is completely smooth. We fixed the out of sync loading state, we skipped the api layer, and have a more robust optimistic update that works with the transition, with less code, and no UX problems.
- Im already done with my improvements, but let's add some final features to this account selector to bring everything together.
- Let's add another custom UI element to the select. A logout button, showcasing the customizability of Ariakit.
- Styled with aria-disabled and not-aria-disabled:data-active-item underline.
- Showcase the result when focusing it and hovering it.
- We're gonna onClick call another Server Function, which deletes our account cookie (showcase). Track its loading state with another useTransition, creating a React Action. Add disabled= and "logging out" text.
- Log out and showcase the loading state and the styling with ariakit.
- Here logged out, let's complete the app with a functional login button. Let's use a React 19 improved form that let's us call a function in the action property. Need a parameter, instead of creating a client component with a callback function, let's use bind to directly bind the server function to the button.
- We also will have some interactivity on this button, because it's the React 19 useFormStatus hook to track the loading state of the nearest parent form. Composable interactive button that can be used in any form.
- Log in again and view the pending state.
- Alright, let's do a final demo. Fullscreen.
- Login, load page and view the UI right away, get this stable loading state with suspense fallback using server components.
- Navigate with tabs, open menu and use the menu with the arrow keys, all my styling is applied accordingly with hover or focus, open/close menu with enter with good focus, escape close, click outside. Popover automatic placement. And trust me the screen reader experience is good as well, provided by Ariakit. Everything you would expect from a select.
- Execute the switch, we have optimistic updates, and get an in sync loading state and an in-sync toast. Open menu and log out again with pending state and finally log back in.
- And the result, a maintainable, accessible, and user-friendly account selector with minimal boilerplate and modern best practices.
- What is the bottom line? What did we achieve?
- AccountSelector: With ariakit, we were able to build fully accessible UI, and rather than nameless divs we also got clean, declarative, composable component code without boilerplate.
- We were able to easily customize the UI and style it with our normal tailwind CSS flow using data- and aria-attributes provided.
- With React 19, we streamlined server communication using server functions, eliminating the need for a separate API layer.
- We utilized alternatives to common solutions like useState and useEffect and got smooth interactions and simplified loading states with Actions through useTransition and useFormStatus.
- And we achieved instant user feedback and "error rollback" with useOptimistic.
- The result: a maintainable, accessible, and user-friendly account selector with minimal boilerplate and modern best practices.