adobe / react-spectrum

A collection of libraries and tools that help you build adaptive, accessible, and robust user experiences.
https://react-spectrum.adobe.com
Apache License 2.0
12.9k stars 1.12k forks source link

Consider upgrading to a component-based API #2331

Closed ianstormtaylor closed 3 years ago

ianstormtaylor commented 3 years ago

This library is awesome for the breath it provides for components and interactions and accessibility. Everything is clearly very well considered. And the goal of it being completely customizable is admirable and why I'd use it.

But as a user, the majority of my time using it is spent in very frustrating to debug "glue code" between the many hooks. It may not feel confusing as maintainers because you know the ins and outs of every hook, but let me tell you as a user, this is an extremely confusing library.

A big part of this is because of the decision to only provide hooks as the main API. The existing hooks are great for low-level use cases where some hyper-specific customization is required. But 90% of use cases don't need this.

I think this library would greatly benefit from taking an approach similar to Headless UI, which is based around components. With smart use of internal context to connect things together they are able to eliminate all of the tedious glue code, allowing you to focus purely on the components, state, and events. It's wonderfully simple to reason about. (And the existing hooks API could still be available for the 10% of cases that need extreme customization.)


For context, take a look at how implementing a Listbox differs between React ARIA's current API, and how HeadlessUI does it...

React ARIA ```tsx import React from "react"; import { useSelect, HiddenSelect, mergeProps, useFocusRing, useButton, useListBox, useOption, useOverlay, DismissButton, FocusScope } from "react-aria"; import type { AriaListBoxOptions } from "@react-aria/listbox"; import type { AriaSelectProps } from "@react-types/select"; import type { Node } from "@react-types/shared"; import { ListState, Item, useSelectState } from "react-stately"; const animals = [ { id: "red panda", name: "Red Panda" }, { id: "cat", name: "Cat" }, { id: "dog", name: "Dog" }, { id: "aardvark", name: "Aardvark" }, { id: "kangaroo", name: "Kangaroo" }, { id: "snake", name: "Snake" } ]; export function ReactAria() { return ( ); } function Select(props: AriaSelectProps) { let state = useSelectState(props); let ref = React.useRef(null); let { labelProps, triggerProps, valueProps, menuProps } = useSelect( props, state, ref ); let { buttonProps } = useButton(triggerProps, ref); let { focusProps, isFocusVisible } = useFocusRing(); return (
{props.label}
{state.isOpen && ( )}
); } function ListBox( props: { listBoxRef?: React.RefObject; state: ListState; } & AriaListBoxOptions ) { const ref = React.useRef(null); const { listBoxRef = ref, state } = props; const { listBoxProps } = useListBox(props, state, listBoxRef); return (
    {[...state.collection].map((item) => (
); } function Option(props: { item: Node; state: ListState }) { const { item, state } = props; const ref = React.useRef(null); const { optionProps, isDisabled, isSelected, isFocused } = useOption( { key: item.key }, state, ref ); let text = "text-gray-700"; if (isFocused || isSelected) { text = "text-pink-600"; } else if (isDisabled) { text = "text-gray-200"; } return (
  • {item.rendered}
  • ); } function Popover(props: { popoverRef?: React.RefObject; children: React.ReactNode; isOpen?: boolean; onClose: () => void; }) { const ref = React.useRef(null); const { popoverRef = ref, isOpen, onClose, children } = props; const { overlayProps } = useOverlay( { isOpen, onClose, shouldCloseOnBlur: true, isDismissable: false }, popoverRef ); return (
    {children}
    ); } ```
    HeadlessUI ```tsx import React, { useState } from "react"; import { Listbox } from "@headlessui/react"; const animals = [ { id: "red panda", name: "Red Panda" }, { id: "cat", name: "Cat" }, { id: "dog", name: "Dog" }, { id: "aardvark", name: "Aardvark" }, { id: "kangaroo", name: "Kangaroo" }, { id: "snake", name: "Snake" } ]; export function HeadlessUi() { const [selectedAnimal, setSelectedAnimal] = useState< typeof animals[number] | null >(null); return ( Favorite Animal
    {selectedAnimal?.name ?? "Choose…"} {animals.map((animal) => ( ` cursor-default select-none relative py-2 pl-3 pr-9 ${active ? "text-white bg-indigo-600" : "text-gray-900"} ` } > {({ selected, active }) => ( <> {animal.name} )} ))}
    ); } ```

    The HeadlessUI code is much easier to reason about because all of the glue code is handled under the covers by the library using context. Whereas the React ARIA code is full of hooks and refs and state that needs to be connected together just right to get things to work.


    Edit: Unfortunately I wasn't super clear, but I'm advocating for both hooks and components APIs. Obviously the hooks are low-level and useful for edge cases. But I think adding components alongside them would be nicer to use for 90% of cases.

    devongovett commented 3 years ago

    Hey, thanks for the feedback! React Aria is certainly low level. Our goal when releasing it was that it could accommodate the widest possible number of use cases. This includes the ability to fully customize the appearance, DOM structure, behavior, etc. We aim to let you have just as much control as if you were building the component from scratch with the raw DOM APIs as you might have before, but also provide a lot of behavior and a11y for you. Our hope was that higher level libraries like Headless UI would be built on top of React Aria. It's only possible to do this one way: the lower level hooks have to come first.

    We did look at component and render-props based APIs when designing React Aria. In fact, we started thinking about this problem long before Hooks even existed. However, we never found the right way to do this that offered enough customization potential. Our goal was to build something that we could build React Spectrum (our design system) off of, and I don't think we could have done that without lower level access in some areas.

    A few considerations:

    That being said, I do think there is a lot we can do to improve here without compromising on these goals. We've started at the lowest level layer possible, and are slowing working our way up the stack to provide higher level abstractions. Here are some ideas we have been thinking about:

    Overall, the goal should be to reduce the number of different hooks you need to call for common cases, while retaining flexibility to drop down to lower level APIs where needed.

    We don't currently have plans to offer an official component-based wrapper around our hooks, mainly for lack of time, but we would encourage and assist the community in such an effort. Personally, I would love to see multiple libraries with different takes on such an API built on top of React Aria, e.g. integrating with or optimizing for specific styling methodologies (e.g. tailwind, css-in-js, etc.). If you or anyone else is interested in working on such a library, we'd be happy to assist in any way we can. 😄

    ianstormtaylor commented 3 years ago

    Hey @devongovett, thanks for the thoughtful response!

    I totally agree that hooks provide more control for certain edge cases where you need to get really custom (although some of those examples are solvable by components). I think hooks would still be key as the tiny building blocks, but an additional component-based layer would solve 90% of use cases without the current complexity.

    It's hard to overstate how much simpler the Headless UI approach is. Even if this library is for UI kits, I'd be willing to bet that a lot of people come to it when first implementing a specific component that isn't well handled by browser defaults. (In my case this was trying to add an autocomplete component to a fairly simple UI kit.)

    Totally makes sense if it's a lack of time concern though. Hopefully Headless UI adds more components over time and the mismatch in breadth will be reduced. I could definitely see a useful library that aimed to do what they're doing but with React ARIA under the hood—I'll hold out hope that it's React ARIA itself one day!

    sandren commented 3 years ago

    I think it's difficult to directly compare React Aria and Headless UI because to me it seems that they serve slightly different purposes (I personally use both). I would say that the purpose of React Aria—and @devongovett can correct me if I'm wrong—is to effectively create your own flavor of a Headless UI, either for your own internal design system or for an open source project built on top of it.

    The components that you create with React Aria can have any API you want. So instead of building a one-off components with React Aria like in your example, you can create a general purpose one with a reusable API. That means you could technically take your Headless UI example and change the import from import { Listbox } from "@headlessui/react" to import { Listbox } from "./listbox-built-with-react-aria" and use them the exact same way, render props included if you are so inclined! 😀

    So to me it's more about the level of control you have over the abstraction. Headless UI comes out of the box ready to go, but offers less customization. React Aria requires some assembly, but you can build any API you want.

    DaniGuardiola commented 3 years ago

    🙌 I'm glad this was raised, btw hi @ianstormtaylor, I'm currently building a text editor with Slate, great lib!

    I've been thinking about this for a long time. In our org, we are using a combination of react-aria and Radix UI, and it's been on my mind to build a bunch of Radix-like primitives on top of react-aria / react-stately. I think that, while nothing beats hooks when it comes to extensibility and customization, they have achieved a really good balance between having a high-level and easy to use abstraction, and making it extensible. I see two interesting patterns for this:

    I bet it wouldn't be too hard to build primitives with a similar approach, on top of react-aria and react-stately.

    One point where Radix and react-aria diverge is that Radix is designed around native HTML components like <button>, while react-aria sometimes wraps these elements with custom APIs like the press events API. In fact, this has been a little troublesome for us when combining Radix and react-aria. I guess it'd be a challenge to make something that works well for both react-aria based components and native HTML ones.

    Anyway, that's just my two cents. If I had more time I'd definitely try to start a project like this! :)

    PD: besides this, I think the improvements @devongovett mentions are also pretty awesome, and as a user I can't wait for them to become a reality.

    DaniGuardiola commented 3 years ago

    PD: this thread by @diegohaz (reakit creator) seems relevant too: https://twitter.com/diegohaz/status/1229499924062048260

    Seems like Reakit is the most similar to what a react-aria based component library would look like. It has low-level hooks and high-level components, and you can use whatever you want depending on your needs. It basically bridges the gap between the two worlds, and it's similar to how I'd envision this kind of lib.

    Relevant Reakit docs: https://reakit.io/docs/composition/#props-hooks

    ianstormtaylor commented 3 years ago

    @sandren thanks for the response. I totally get that, I definitely appreciate the flexibility of the hooks from React ARIA, and I'm very glad this library exists. I know you could theoretically build all of Headless UI on top of React ARIA—I'm definitely not arguing for eliminating them.

    My point is more that I'd bet the majority of users of this library don't require the granularity (and added complexity) that hooks provide. Just look at the difference in imports between the two examples:

    // Headless UI
    import { Listbox } from "@headlessui/react";
    
    // React ARIA
    import {
      useSelect,
      HiddenSelect,
      mergeProps,
      useFocusRing,
      useButton,
      useListBox,
      useOption,
      useOverlay,
      DismissButton,
      FocusScope
    } from "react-aria";
    import type { AriaListBoxOptions } from "@react-aria/listbox";
    import type { AriaSelectProps } from "@react-types/select";
    import type { Node } from "@react-types/shared";
    import { ListState, Item, useSelectState } from "react-stately";

    With the current state of things... if it's ever possible for me to avoid React ARIA's APIs when adding a new component to my UI kit I will do that. And only when a nicer component-based package doesn't exist will I use React ARIA's hooks.

    So I opened this issue to bring this up, because I think the library would go from useful to amazing if it provided both hooks and components. (Imagine a @react-components or something to complement the existing packages.)

    @DaniGuardiola thank you! That is awesome. Radix UI seems like exactly what I was looking for—similar to Headless UI but with a larger collection of components. The asChild pattern is very smart. And looks like it can replace the majority of my uses of React ARIA—the only big one that's missing for my use case is autocomplete/combobox.

    DaniGuardiola commented 3 years ago

    @ianstormtaylor

    looks like it can replace the majority of my uses of React ARIA

    Yep, we're using Radix everywhere it fits for its simplicity, and Aria in places where we need more customization or for things that Radix hasn't covered yet, like the awesome press interactions. I think it's possible to have the best of both worlds, although it doesn't come without complications when you have to combine the two :laughing:

    Hit me up when you try and fail to use a react-aria based button as a Radix trigger, I'll help and save you a world of pain :)

    devongovett commented 3 years ago

    Hey all, thanks for your comments, this is a good discussion. I agree that having both higher level components and lower level hooks would be great. All of the libraries mentioned are good, but I think they are optimized for different use cases. As far as I can tell, all of them could be built on top of React Aria internally if their authors desired.

    Here are some tradeoffs with various component-based APIs:

    These also tend to get complicated very quickly as soon as you want to do something even a little custom. Animated tabs is a good example. In order to do this, you need to take ownership of the state so you have access to the selection beyond just DOM attributes. Here's how Reach UI shows to do it: https://reach.tech/tabs/#animation. And here's an example using React Aria: https://codesandbox.io/s/practical-monad-punzo?file=/src/App.js. Because you already own the state, it's trivial to add a useEffect and an extra DOM element to render the selection.

    I think it will be hard to design a single component-based API around React Aria that works for a wide number of use cases. That's why I hope the community will begin to explore different APIs for this that are optimized for specific use cases, e.g. react-aria-tailwind, react-aria-styled-components, etc. We would love to help wherever we can here! 😄

    devongovett commented 3 years ago

    I briefly prototyped an example of what this could look like using the Select component in your example above, but moving all of the styling to be passed in with an API similar to what Headless UI offers: https://codesandbox.io/s/intelligent-wright-7wi39?file=/src/App.js.

    It uses functions passed to className for styling, which receive various states to apply conditional classes. It also uses render props to customize item elements and trigger contents. State is shared between all of the components via context, along with props returned from React Aria's useSelect hook.

    This type of API does have a couple limitations though. Rather than passing <Item> elements as children, as React Aria currently expects, this type of API needs to allow passing any elements you want. Normally, React Aria uses the Item children to determine what items are in the collection. These are used to implement keyboard navigation, typeahead, etc. When there are arbitrary children, and the items could be at any nesting level, this is more challenging.

    Headless UI and other libraries solve this using either a registration system using context, or by querying the DOM tree. Both of these require all of the items to be rendered to the DOM to work. You can't implement virtualized scrolling because keyboard navigation wouldn't be able to access items that are out of view and not rendered. That's why React Aria queries the JSX tree for Item elements instead, but it can't do this at arbitrary nesting.

    For this demo, I instead relied on an items prop being an array of objects with a specific shape (id and name properties). This provides React Aria enough information to implement keyboard navigation, and we can pass those objects back to a render prop to render the actual Option elements. It's a little less ergonomic of an API than providing <Item> children, but it's the only other way to work with React Aria's existing hooks and support virtualized scrolling.

    Anyway, hopefully this example shows that it's possible to build an API like Headless UI on top of React Aria, and what the implementation could look like. Anyone is welcome to use and extend this code. 😉

    ianstormtaylor commented 3 years ago

    @devongovett thanks for the follow ups!

    Thing is though, those examples are sort of proving my point. The underlying logic in the <Select> example is very convoluted to follow. I can't imagine anyone (but a maintainer of React ARIA themself) writing that code without lots of time spent debugging things they hooked up incorrectly. And if you modified it to include TypeScript typings—like many would need—the confusion would increase another 2-3x.

    It just reinforces my belief that React ARIA is great because it can do all these things, but I personally hope to touch it as little as possible, because its current abstractions just don't match what most people need. I'd feel irresponsible recommending people base a new UI Kit on it because of the complexity it would force on them, and instead I'd recommend it as a way to fill the gaps as a last resort.