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

[POC] Slots using render props #38362

Closed

Conversation

michaldudak
Copy link
Member

@michaldudak michaldudak commented Aug 7, 2023

Let's discuss the render props API for slots.
The implementation is quick and dirty and is there only to enable us to play with the new API.

Following up on the discussion we had on the Monday meeting, I prepared a version of the MenuButton with the added renderRoot prop. It's called with the props the root component should receive and the ownerState. Developers can override the component or modify the props in one place (so it's like slot and slotProps combined).

Codesandbox playground: https://codesandbox.io/s/heuristic-hodgkin-74mlzx?file=/Demo.tsx

🥇 The upsides:

  • Props are strongly typed and depend on the type of the slot component (unlike slotProps).
  • The need for polymorphism is essentially eliminated, leading to much more performant code (at design time).
  • It's possible to add arbitrary data attributes to elements.
  • Prevents issues like [system] component prop lost when wrapping with styled #29875

👎 The downsides:

  • Custom event handlers or class names overwrite the built-in ones (whereas slotProps merge them). This could be solved by a function that merges both sets of props.
  • Simple cases (such as adding a class name) require more code.

Original discussion about the topic: #21453

@michaldudak michaldudak added package: base-ui Specific to @mui/base proof of concept Studying and/or experimenting with a to be validated approach labels Aug 7, 2023
@michaldudak michaldudak requested a review from a team August 7, 2023 14:42
@mui-bot
Copy link

mui-bot commented Aug 7, 2023

Netlify deploy preview

https://deploy-preview-38362--material-ui.netlify.app/

Bundle size report

Details of bundle changes (Toolpad)
Details of bundle changes

Generated by 🚫 dangerJS against e067d9d

@DiegoAndai
Copy link
Member

Is the render prop option different from this?:

<MenuButton
  slots={{
    root: (props) => (
      <IconButton data-testid="hamburger-menu" {...props}>
        <MenuIcon />
      </IconButton>
    ),
  }}
/>

@mnajdova
Copy link
Member

mnajdova commented Aug 7, 2023

I remember we had a similar discussion when working on Fleunt UI (apparently 5 years ago 🤯). Some of the things discussed are very well summarized in microsoft/fluent-ui-react#199 (comment). It is definitely not a silver bullet. We must be careful around performance with this one too or at least to check if it will regress compared to plain components.

One more thing, if this component is later exported from a UI library like a component, the merging of all props and event handlers would be nightmare. It would be great if we can create a fully featured introduction demo and see how it would look like.

@michaldudak
Copy link
Member Author

Is the render prop option different from this

@DiegoAndai, yes, there is a subtle but very important difference. A render prop is a function that returns JSX (it's called by us), while slots.* are components (called by React). Defining a component during rendering can have pretty bad performance implications and cause unexpected behavior (such as state being lost between renders).

I remember we had a similar discussion when working on Fleunt UI (apparently 5 years ago 🤯). Some of the things discussed are very well summarized in microsoft/fluent-ui-react#199 (comment). It is definitely not a silver bullet.

Thanks for referencing this. I'll analyze what you found back then.
I'm aware this solution has drawbacks. I've listed the ones I could think of. Let's focus on finding others and comparing them to the advantages this would give us.

We must be careful around performance with this one too or at least to check if it will regress compared to plain components.

Is there something in particular that you are concerned with? I don't think performance will be much different between these solutions. When using slots and slotProps, we read the slots, read slotProps, detect whether it's a function and if so, call it with the ownerState. In the case of render props, we'd always call them with ownerState. Since render props are functions, not components, no new instances will be created on each render.
But then, I may be missing something, so I'll profile both solutions.

One more thing, if this component is later exported from a UI library like a component, the merging of all props and event handlers would be nightmare

For creating reusable libraries with Base UI, we recommend using hooks. Even today it's hard to create components that can be customized further with Base UI's components (merging ownerStates of different shapes is one example of why it's hard).

It would be great if we can create a fully featured introduction demo and see how it would look like.

Do you mean a demo of the MenuButton integrated with a Dropdown and a Menu?

@mnajdova
Copy link
Member

mnajdova commented Aug 8, 2023

For creating reusable libraries with Base UI, we recommend using hooks. Even today it's hard to create components that can be customized further with Base UI's components (merging ownerStates of different shapes is one example of why it's hard).

I thought the point of the hooks is to have more freedom in how the DOM tree is rendered & structured. If we truly think that the unstyled components are not suitable for creating UI libraries, then what's the use-case for them? I mean even if I am not creating a UI library, but I need to have two buttons in my app, and I wrap the ButtonUnstyled once, for sure I would like to reuse this component in the other place - this doesn't mean that I shouldn't have access to slots & slotProps in this other instance. I don't think we should box ourselves like this honestly, otherwise, we may be wasting time with the components.

It would be great if we can create a fully featured introduction demo and see how it would look like.

Do you mean a demo of the MenuButton integrated with a Dropdown and a Menu?

It's connected to the previous point, maybe our components are not tested well enough for us to know if the API works. The hooks are used in both Joy UI and Material UI so we can tell if they work or not, the demos are the only way how we can test the unstyled components. This is why I said, maybe we should have a fully featured Introduction demo - create a component that can be used in multiple places based on the unstyled component - this will show us if the API works.

@DiegoAndai
Copy link
Member

My concern is that we're making the slots pattern more difficult to understand with the introduction of render props. Instead of supporting all the possible ways of achieving something, should we be more opinionated?

Radix's composition API has one pattern to achieve this and have very specific rules on how to do so. This makes the documentation easier to understand.

Another point regarding components and hooks: React Aria went the hook route and stuck with it, Radix went the component route and stuck with it. Having the components, and the hooks, and slots, and renderProp we might be creating decision hell for our users. If we are more opinionated, we might lose the users for whom our API design doesn't make sense, but we increase our ability and resources to deliver a great product for those who do.

@samuelsycamore
Copy link
Contributor

samuelsycamore commented Aug 9, 2023

Instead of supporting all the possible ways of achieving something, should we be more opinionated?

FWIW, this is a common complaint with regards to customization in Material UI. Because there are so many different ways to do it (and our docs don't explicitly teach one way as the best option), devs feel major "analysis paralysis" and have a hard time deciding which to choose—and then what they take away from the experience is that "Material UI is too difficult to customize." 🫠 I could imagine similar situations here.

@michaldudak
Copy link
Member Author

I don't think we should box ourselves like this honestly, otherwise, we may be wasting time with the components.

Components can be easily customized as long as you don't add anything to ownerState - then it becomes cumbersome. We can discuss use cases for both separately, not to divert from the original topic of this discussion.

This is why I said, maybe we should have a fully featured Introduction demo - create a component that can be used in multiple places based on the unstyled component - this will show us if the API works.

All right, I'll work on it.

Having the components, and the hooks, and slots, and renderProp we might be creating decision hell for our users.

@DiegoAndai, @samuelsycamore, I'm not considering adding render props to the existing features but replacing slots and slotProps with them.

@samuelsycamore
Copy link
Contributor

samuelsycamore commented Aug 10, 2023

@DiegoAndai, @samuelsycamore, I'm not considering adding render props to the existing features but replacing slots and slotProps with them.

That makes more sense. In that case, I agree with @DiegoAndai 's other point, that it kind of muddies the concept of slots. It's not necessarily a bad thing, but it would require a shift in how we present the mental model of component structure and customization. Maybe it's just a matter of naming, but if I'm understanding it correctly, it seems to add an extra layer of abstraction on top of our already abstracted concept of slots. Or would this replace the concept of slots entirely?

@DiegoAndai
Copy link
Member

TL;DR: Consider not having slots nor render props, instead encourage using the hook as soon as the user needs more customization. (Sorry, this came out longer than expected 😅)

Making our users' life harder

I think the underlying discussion here is the narrative we will have regarding components vs. hooks. When should the user use the component? When should the user use the hook? Where's the line between the two? If we don't have that clear, our users will be confused, and we will make the DX worse from the start.

IMO, having the render prop (or the slots) makes the line blurry. We add overlap between the component and hook use cases, delegating the decision to the user who's not an expert on the product. We're the experts on the product, we should aim to make the most decisions so the way to use it is as clear as possible.

Our current narrative looks like this:

  1. Need to just add styles? Use the component
  2. Need to customize further? Use either the slots or the hook, here are the pros and cons of each

In point 2. we introduce the "analysis paralysis" @samuelsycamore mentioned. The user's goal is to create their custom component and we're adding complexity to that goal.

Tailwind

One of Tailwind's strong points is that they chose one simple API: if you need a style, add this class to your element. They didn't provide abstracted classes, instead they encourage extracting components (or templates). They even discourage CSS abstractions. I would say they simplify the user decision to:

  1. Need to add styles? Add these classes
  2. Need to reuse styles? Extract a component

There's a clear answer for each need, and they provide guides on how to move from 1. to 2. Sure, there are people who are against using utility classes like this, but most users just want to style their project and don't care about the nuances between using a utility class or plain CSS.

Making our users' life easier

I say there's a third option regarding slots / render props: not having either. Maybe I'm missing something, but the value the component provides is the structure, so why would we have a prop to override that structure?

So our narrative for the users could be:

  1. Need to just add styles? Use the component
  2. Need to customize further? Use the hook

And we should provide a guide on how to move from 1. to 2. Sure, there will be people who care about the difference between using slots, a render prop, or a hook, but most won't, most just want to build their component and move on to other things.

There's even a fourth option, which was the one Radix took: just having components. For me that's not the best solution, hooks are way better for total flexibility. Maybe people are not used to using hooks for these things, but as it says on Tailwind's landing: "If you can suppress the urge to retch long enough to give it a chance, I really think you’ll wonder how you ever worked with CSS headless components any other way"

Benefits

Summarizing the options:

  1. Slots pattern
  2. Render prop pattern
  3. Neither of the above, move to hooks as soon as you need more customization
  4. Only components (Radix)

I think if we're more opinionated and choose option 3. we will:

  • Improve DX making the user's life easier
  • Reduce the work we have to do supporting and maintaining slots or render props
  • Simplify the documentation and overarching narrative about Base
  • Differentiate from Radix

@mnajdova
Copy link
Member

React Aria went the hook route and stuck with it, Radix went the component route and stuck with it.

@DiegoAndai React Aria is adding unstyled components, they are in alpha.


Two more things to be considered for the callback scenario:

  1. Base UI's unstyled components are higher level components. This means that if we go with the render props paradigm, on some components we may have multiple render* props, which may make the usage of the component a bit awkward. I feel like the render* paradigm works much better with lower level components, where mostly the child can be a render callback, for e.g. for the input we would have:
<Input 
  renderRoot={() => {}}
  renderInput={() => {}}
  renderTextarea={() => {}}
 />
  1. Render callbacks makes the components pretty much not usable in RSC as the functions won't be serializable

@github-actions github-actions bot added the PR: out-of-date The pull request has merge conflicts and can't be merged label Sep 20, 2023
<CssVarsProvider>
<WithRenderProp />
<WithSlotsAndSlotProps />
<WithSlots />
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have accidentally noticed that the WithSlots demo doesn't behave like the other 2 examples:

Screen.Recording.2023-10-03.at.18.29.40.mov
Screen.Recording.2023-10-03.at.18.29.26.mov

The first 2 examples render the Menu in a popper, while the 3rd one doesn't.
triggerElement seems to be null for some reason:

if (open === true && triggerElement == null) {

@michaldudak any ideas?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch! The issue is that MenuIconButton does not forward the ref. I'll fix it.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ahh, thanks for the explanation!

@github-actions github-actions bot removed the PR: out-of-date The pull request has merge conflicts and can't be merged label Oct 6, 2023
@siriwatknp
Copy link
Member

I'm not considering adding render props to the existing features but replacing slots and slotProps with them.

The drawbacks I see:

  • Passing extra props to the inner slot would be a lot harder.
  • It's no longer easy to replace the inner slot's component. e.g. <Menu slotProps={{ list: { component: 'div' } }}>

I would stay with the current API (slot, slotProps) and live with the trade-off (maybe 10% of the use cases which is fine to me).

What we need is to document them and teach the community when to use those props.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
package: base-ui Specific to @mui/base proof of concept Studying and/or experimenting with a to be validated approach
Projects
None yet
Development

Successfully merging this pull request may close these issues.

7 participants