Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

React Aria Components: rendering as custom components #5476

Open
chaance opened this issue Nov 24, 2023 · 20 comments
Open

React Aria Components: rendering as custom components #5476

chaance opened this issue Nov 24, 2023 · 20 comments
Labels
documentation Improvements or additions to documentation RAC

Comments

@chaance
Copy link

chaance commented Nov 24, 2023

Provide a general summary of the feature here

We should have a simpler way to render components other than what is returned from the React Aria component by default. This would be either some sort of polymorphic component rendering or a higher level hook.

In the MenuItem component (and possibly some others), the current way to render a link is to pass an href prop. This simply renders an a link instead of a div. For a library like React Aria, which is typically used as a primitive to create design-system level components, this is quite limiting. There should be some API for rendering another component without dropping down to the lowest-level APIs with React Aria hooks.

🤔 Expected Behavior?

TBD (exact behavior depends on the solution)

😯 Current Behavior

Currently I can't get the functionality of a component like MenuItem without either rendering that component or recreating it entirely with the much lower-level React Aria hooks.

There are a few challenges with the current implementation:

TypeScript. Particularly when using forwardRef, the ref's type is always assumed to be HTMLDivElement. This is a common theme in pretty much every component lib that embraces the "polymorphic" component pattern, and it's difficult to solve without introducing a number of other tradeoffs.

Custom link components. React Aria basically assumes you either want a regular a for external links or you simply want to navigate using your app's client-side router, in which case it uses the navigator provided to RouterProvider. I think this leaves a lot to be desired, as router and/or framework link components often bake in other features we lose if rendered in the context of a RA component.

💁 Possible Solution

  1. Allow for some sort of "slot" based solution that allows you to directly render a specific component, similar to asChild in Radix UI. This is probably not a great one for React Aria, since its interaction event props (onPress and family) heavily abstract and hide the underlying DOM props which a lot of components (including RR links) rely on. Leaving React Aria to decide how to resolve likely conflicts is probably going to be a mess.
  2. Utility hooks with a higher level of abstraction. I think this would basically look like the lower level hooks in React Aria, but you get all of the props that React Aria would otherwise pass to its own components. This would allow consumers to decide how to resolve any conflicts and render whatever they'd like. This would be a sharp knife potentially but would allow us to drop down to a lower level without having to completely rewrite all of the component's functionality using the base React Aria hooks.
function MenuItemLink({ to, prefetch, ...props }) {
	// this would give you all of the props that are
	// passed into `ElementType` in your `MenuItemInner`
	// component, but leaves rendering to the consumer.
	const ref = React.useRef();
	const menuItemProps = useMenuItemProps(ref, props);
	return <Link prefetch={prefetch} to={to} {...menuItemProps} />;
}

🔦 Context + Examples

Assume here we're talking about rendering a link in MenuItem.

When the Link component in React Router resolves its to prop it can determine whether or not a URL value is internal (for client-side routing) or external (leave it to the browser). To do this well it needs to use internal context for apps with a basename. Recreating this behavior requires that you import non-public context modules and dupe their validation code, which isn't ideal.

Another example in React Router is NavLink. This component also relies on internal context to determine the link's current status and supports functional props so you can switch on it.

And in Remix, it's pretty much impossible to support their Link's prefetch functionality without actually rendering their Link.

🧢 Your Company/Team

Me personally

🕷 Tracking Issue

Couldn't find one!

@devongovett
Copy link
Member

Pasting what I said on Twitter for posterity:

It’s very difficult if not impossible to correctly compose components with built in behaviors like this, as I’m sure you know. For example Remix links have a built in click handler, but when used in another component, like a combobox or even a menu, that might not be the only/right interaction. That’s why we haven’t exposed an as prop, for example. Doing so would only work correctly 80% of the time, not 100%. This can have downsides for accessibility and cause hard to spot bugs.

We think there are usually better ways to solve individual issues that come up than a blunt instrument like as or asChild. In the case of pre-fetch, using a hook in your own wrapper component would probably be a better approach. We’ll definitely add more features to the API as they come up though!

The ideal solution in my view for this case in particular would be if Remix exported their internal usePrefetchBehavior hook, or something like it. Then you could just do something like this:

function MyMenuItem({prefetch, ...props}) {
  let [ref, prefetchProps] = usePrefetch(ref);
  return <ReactAriaMenuItem {...props} {...prefetchProps} ref={ref} />;
}

This separation of behavior from elements makes sense especially because pre-fetching has nothing to do with the underlying element that gets rendered. Sometimes we actually have to render a different element than a native <a> due to ARIA/HTML spec limitations, and separating the behavior out would make those work with prefetch too. Remix/React Router normally does this well, e.g. exporting a useLinkClickHandler that you can use to build custom links, but unfortunately not in this case.

Aside from this one in particular, I think we are planning on adding some additional options to pass to the router's navigate function (e.g. preventScrollReset) and support base path (#5395) in the short term. Longer term we can see what other use cases come up, but I'll definitely discuss your ideas with the team next week. Would be amazing to come up with a solution without the downsides of as. Thanks for writing this up, I really appreciate it! 😃

@jamesopstad
Copy link

I've been struggling to integrate React Router with React Aria Components and agree with the points raised in this issue. I have encountered most of the examples given and they are all quite common use-cases. Another issue I have come up against is working with different kinds of router e.g. hash router or memory router. With a hash router, for example, the RAC Link component doesn't include the hash in the URL. If I use the useHref hook from React Router to resolve the URL then the href is correct but the link no longer functions correctly because the navigate function is called with two hashes.

Ultimately, I think the way client side routing works in React Aria needs a bit of a rethink. Simply providing the navigate function to the RouterProvider without any control over how it is called is too simplistic. Adding support for additional props at the library level (e.g. preventScrollReset), however, will always lag behind the features provided by individual routing libraries that users need to access. I am not sure what the best solution is but feel that it's important to be able to easily access all the features that the underlying routing library offers. If it helps, these are some examples of use cases that I feel need elegant solutions:

  • Adding additional router specific props to individual links e.g. preventScrollReset
  • Using the NavLink component from React Router
  • Using different routers e.g. HashRouter, MemoryRouter
  • Correctly resolving relative paths (needs knowledge of the route hierarchy)

@devongovett
Copy link
Member

Current plan for router props is to have a single object that gets passed through to the underlying router, rather than individual props. That way it isn't specific to one router and you don't need to wait when new props are added by your framework.

<MenuItem href="..." routerOptions={{preventScrollReset: true}} />

There is also a way to configure TypeScript to support autocomplete on these that we'll provide docs for.

Something like NavLink I think should be handled by React Aria instead. For example, depending on the component it's used within, the accessibility properties should be different (ie aria-selected in Tabs rather than aria-current). We had discussed adding a feature at some point for React Aria link components to know whether they are selected automatically based on the current URL as well, but haven't gotten around to implementing it yet. For now, you should set the selectedKey prop yourself based on the current url so that the item gets the right states. There's an example in the docs of that.

The href prop needs to be set to whatever the native URL would be. The hash issue is sort of related to the basepath one (#5395). Somehow the native URL needs to be backwards resolved to the router one. Still thinking about this.

The prefetching one is definitely more complex because it needs actual behavior from the router link, not just settings for the native URL. Somehow we need to separate the individual behaviors from the rendering so that React Aria can do the event handling, and delegate the routing. Without the router separating this out into a hook, it's a bit of a challenge.

@jamesopstad
Copy link

Current plan for router props is to have a single object that gets passed through to the underlying router, rather than individual props. That way it isn't specific to one router and you don't need to wait when new props are added by your framework.

This sounds like a good solution and would help a lot.

The href prop needs to be set to whatever the native URL would be. The hash issue is sort of related to the basepath one (#5395). Somehow the native URL needs to be backwards resolved to the router one. Still thinking about this.

Yes, I think resolving back to the router URL is the important bit.

How would you suggest using React Aria links so that you can provide relative URLs in the same way that you can with the routing library. For React Router, would it be to wrap the components that have an href prop with custom components that internally use the useHref hook to determine the URL? My worry is that you still end up having to recreate a lot of the implementation that exists in the router components.

It seems like adding more documentation for how to integrate deeper router functionality with React Aria would be really useful. This may also highlight what can already be achieved in a straightforward way and what needs changes at the library level. Thanks for thinking about this.

@jamesopstad
Copy link

Just a thought about resolving the URLs. What if the RouterProvider had a useHref prop that takes a hook provided by the router? This hook would resolve the href prop that the user provides to the native URL in all React Aria components. The user provided href would be passed through as is to the navigate function, however, without being transformed. With React Router it would look like this:

import { RouterProvider } from 'react-aria-components';
import { useNavigate, useHref } from 'react-router-dom';

function App() {
  const navigate = useNavigate()

  return (
    <RouterProvider navigate={navigate} useHref={useHref}>
      {/* ... */}
    </RouterProvider>
  );
}

It's a bit unusual to pass a hook as a prop like this but it would resolve the issues with relative paths, base paths and hash routers.

@devongovett
Copy link
Member

I like that idea! Do you know if next.js has a similar API to resolve URLs? I couldn't find one in their docs.

@jamesopstad
Copy link

I like that idea! Do you know if next.js has a similar API to resolve URLs? I couldn't find one in their docs.

I don't think they do. It's worth considering TanStack Router too as the usage will likely grow now that it's reached 1.0 (https://tanstack.com/router/v1). That has the added challenge of providing the same level of type inference via the React Aria link components. It's hard to think of a single approach that will integrate fully with different routing libraries.

@devongovett
Copy link
Member

FYI I implemented the useHref idea as well as routerOptions in #5864. This should solve most of the issues with links here. The preload case is a remaining problem, until Remix offers hooks or some other programmatic way to trigger preloads.

@Ruslan-I7
Copy link

Ruslan-I7 commented Aug 6, 2024

Just a thought about resolving the URLs. What if the RouterProvider had a useHref prop that takes a hook provided by the router? This hook would resolve the href prop that the user provides to the native URL in all React Aria components. The user provided href would be passed through as is to the navigate function, however, without being transformed. With React Router it would look like this:

import { RouterProvider } from 'react-aria-components';
import { useNavigate, useHref } from 'react-router-dom';

function App() {
  const navigate = useNavigate()

  return (
    <RouterProvider navigate={navigate} useHref={useHref}>
      {/* ... */}
    </RouterProvider>
  );
}

It's a bit unusual to pass a hook as a prop like this but it would resolve the issues with relative paths, base paths and hash routers.

To not wrap RouterProvider, do we have a chance just use from react router useNavigate and Link component from react-spectrum? As far as I see, if we do, then in this case we will have reloading and you can't prevent this behavior using onPress

@ingro
Copy link

ingro commented Aug 19, 2024

Maybe I'm doing something wrong but relative paths are not resolved correctly using react-router.

I see the correct link when hovering the tab but when I click it brings me to the wrong path (relative to the root).

For example I have a route: path/to/:id/history

From the path path/to/:id I have a tab with a relative link to history.

I see the correct link on the element, but when I click it navigates to http://localhost/history

If in the same component I insert a <NavLink> with the same relative path it works as expected.

I have used the useRef prop as suggested.

@theMosaad
Copy link
Contributor

Maybe I'm doing something wrong but relative paths are not resolved correctly using react-router.

You are not doing anything wrong. react-router links call the useNavigate inside the route so they're able to resolve relative paths. With the current RouterProvider integration, you always call useNavigate from root so all relative paths are resolved accordingly.

Aside from not using relative paths where possible, the only workaround I've found so far is using the useResolvedPath('history') hook to construct the correct absolute path then pass it to RAC's href.

@ingro
Copy link

ingro commented Aug 21, 2024

Thanks @theMosaad I will try it out!

@LFDanLu LFDanLu moved this from ✏️ To Groom to 🔬 To Investigate / Verify in RSP Component Milestones Sep 18, 2024
@musjj
Copy link

musjj commented Oct 19, 2024

I'm also struggling with integrating React Aria's links in Next.js.

The documentation suggests using RouterProvider which allows client-side routing methods (like router.push) to be triggered by any React Aria components with the href prop.

But you will lose all the nice prefetching behavior that the Next.js <Link /> component has.

Features like asChild/Slot has potential downsides in terms of correctness, but IMO it's a totally necessary evil for cases like this. Otherwise every project has to re-implement a huge chunk of functionality that something like asChild would give you for free (e.g. see the source code for the <Link /> component).

@devongovett
Copy link
Member

Reiterating my prior comment, the problem is that the behavior of the Link component is not right in some cases. For example, links in tables and grids cannot be rendered as <a> elements due to accepting interactive children (which would be invalid html), and differences in events that should trigger links between components (sometimes on mouse down or up, different keyboard interactions, etc.).

Prefetching behavior really needs to be separated from link behavior so it can be applied independently from how the link is rendered or triggered. Perhaps there's a way to integrate that into RouterProvider or maybe a separate wrapper component to make this easier. For now you could do something like this to programmatically prefetch on hover or focus:

let router = useRouter();

<MenuItem
  href="..."
  onHoverStart={() => router.prefetch("...")}
  onFocus={() => router.prefetch("...")} />

@musjj
Copy link

musjj commented Oct 20, 2024

Thank you for providing an alternative solution. But unfortunately you also need to re-implement the prefetch behavior when the element becomes visible: https://github.com/vercel/next.js/blob/d62627cecfcfcf6f03362be3be369725170a3876/packages/next/src/client/link.tsx#L560-L596. It's not a gargantuan task, but it's really annoying and trial-error-prone.

I feel like complementing the asChild-like functionality with a well-written page documenting how to use them properly would eliminate most mis-uses. It's not like people are spamming these everywhere, they just need it in a few critical places.

@dnaploszek
Copy link

You are not doing anything wrong. react-router links call the useNavigate inside the route so they're able to resolve relative paths. With the current RouterProvider integration, you always call useNavigate from root so all relative paths are resolved accordingly.

I think this should be (at least) well documented in https://react-spectrum.adobe.com/react-aria/routing.html#routerprovider. Currently the solutions is actually buggy, as the urls are showing properly, yet the navigation itself is wrong (treating relative paths as absolute navigations) - this is probably going to be encountered by many different users of react-aria-components.

We had to implement a wrapper that makes sure our relative hrefs (sometimes [unfortunately] we're using them) work properly. The wrapper is based on useHref(useResolvedPath(href)).

@musjj
Copy link

musjj commented Oct 31, 2024

I just realized that React Spectrum has elementType for Button and other elements, which allows you to render them as different elements (e.g. render Button as <a>).

I'm baffled why this behavior isn't included for React Aria. The only way to mimic this behavior is to drop down to raw hooks and recreate the entirety of react-aria-components yourself. It gets worst because the library has a massive amount of private utilities that you have to rewrite yourself. Is there a reason why react-aria-components isn't blessed with this behavior?

Trying to manage complex composition without this has been really, really frustrating, to the point that I've been reconsidering going back to Radix. Is there any chance that this could change in the future?

@devongovett
Copy link
Member

devongovett commented Oct 31, 2024

I just realized that React Spectrum has elementType for Button and other elements, which allows you to render them as different elements (e.g. render Button as ).

This was a mistake from a long time ago that we have since reverted in our new version based on React Aria Components. Instead, we simply have two components: Button and LinkButton (which uses the RAC Link component with the same styles applied as Button).

Is there a reason why react-aria-components isn't blessed with this behavior?

Pretty sure it's explained if you read the thread here.

Trying to manage complex composition without this has been really, really frustrating, to the point that I've been reconsidering going back to Radix.

What are you trying to accomplish that you cannot achieve at the moment? Are there examples other than prefetching?

@musjj
Copy link

musjj commented Oct 31, 2024

After banging my head a bit, I decided to mimic the MenuItem behavior with Button:

import { Button as AriaButton, Link as AriaLink } from "react-aria-components";
import type { ButtonProps as AriaButtonProps } from "react-aria-components";
import { forwardRef } from "react";
import type { LinkDOMProps } from "@react-types/shared";

export interface ButtonProps extends AriaButtonProps, LinkDOMProps {}

export const Button = forwardRef<React.ElementRef<typeof AriaButton>, ButtonProps>(
  (props, ref) => {
    const Element: React.ElementType =
      props.href !== undefined ? AriaLink : AriaButton;

    return <Element ref={ref} {...props} />;
  },
);
Button.displayName = "Button";

The React.ElementRef<typeof AriaButton> kinda bothers me, but I guess there's no way around it.

What are you trying to accomplish that you cannot achieve at the moment? Are there examples other than prefetching?

My usecase is that I'm writing a collection of button components, and I want it make it so that you can transparently use any of them as links. The Button <-> LinkButton approach would double up the number of components that I'd have to write. Fortunately the approach above is good enough for me, for now.

@jrmyio
Copy link

jrmyio commented Nov 1, 2024

What are you trying to accomplish that you cannot achieve at the moment? Are there examples other than prefetching?

Here is a use case not supported as far as I know:
#4813 (reply in thread)

My suggestion is to have an API like this:

<Button onPress={console.log} asChild>
     {(renderProps, buttonProps) => 
         <motion.button animate={{ 
                 // something based on renderProps
          }} {...buttonProps}>Click me</motion.button>
      }
</Button>

Note: asChild could also be named something like skipDom, passProps.

As far as I know this would be non-breaking and works fine with typescript because if <motion.button/> would not support any of the buttonProps, it would throw a typescript error once you spread it. Note that:

  • we are already familiar to the function as child-pattern in RAC so that is nothing new
  • we are not relying on React clone methods like Radix does, nor are we implying with this api that any child is valid.
  • we are not giving an as prop on Typescript level that would allow misuse of passing an invalid element while typescript things its fine.

We are just giving the developer back some more control.

Yes, this gives some responsibility to the developer, but it prevents the developer having to fight RAC, or potentially quit it over other headless libraries that do support customization the element.

@devongovett You mentioned a couple times one could fallback to react-aria hooks, but as @musjj mentioned, that will end up you having to recreate the entirety of react-aria-components yourself. Wiring up react-aria and react-stately is very tricky, RAC really is super helpful. Adding to the problem is the lack of DX in customizing small parts of RAC, as many context providers or utility functions aren't exported. You can’t simply copy a snippet of RAC's component code (for example, <CalendarCell />) and overwrite just that component.

Maybe, being able to control the root element of a component removes the need to having to copy an internal RAC component, as you would be able to overwrite/customize any props passed to the element (just like you can with react-aria).

Somewhat related is an issue I ran into where I couldn't control the rendering part:
#4749 (comment)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
documentation Improvements or additions to documentation RAC
Projects
Status: 🔬 To Investigate / Verify
Development

No branches or pull requests

10 participants