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

Render prop API? #10476

Closed
1 task done
mbrowne opened this issue Feb 28, 2018 · 27 comments
Closed
1 task done

Render prop API? #10476

mbrowne opened this issue Feb 28, 2018 · 27 comments
Assignees

Comments

@mbrowne
Copy link

mbrowne commented Feb 28, 2018

There are missing props in the TypeScript type declaration file for ListItem, and also these types should ideally be generics.

  • I have searched the issues of this repository and believe that this is not a duplicate.

Expected Behavior

It should be possible to use the ContainerComponent and ContainerProps props of the ListItem component from TypeScript. Also, I expected that ListItem and MenuItem would be generic types to account for the fact that any additional props are passed to the component specified by the component prop, but they're not. To be specific, in this file, the interface is currently defined like this:

export interface MenuListProps ...

whereas I would have expected to see something like this:

export interface MenuListProps<TContainerComponentProps = {}> ...

Current Behavior

This TypeScript (.tsx) code does not compile:

<ListItem ContainerComponent="div" ContainerProps={{className: 'demo'}}>an item</ListItem>

Steps to Reproduce (for bugs)

Create the following test component in TypeScript:

import React from 'react'
import { ListItem, ListItemProps } from 'material-ui/List'

const Test: React.SFC<{}> = () => {
    return <ListItem ContainerComponent="div" ContainerProps={{className: 'demo'}}>an item</ListItem>
}
export default Test

Notes

The priority here is adding ContainerComponent and ContainerProps to the type declaration. Making it generic (e.g. MenuListProps<TContainerComponentProps = {}>) is arguably a best practice and would be nice, but might not be so practically useful in this case. If it were a class component it might be more helpful, but as far as I can tell, TypeScript does not provide a way to alias generic functions that would be any more concise than my current solution of using a typecast:

const MyMenuItem = MenuItem as React.ComponentType<MenuItemProps & MyComponentProps>
...
    render() {
        return <MyMenuItem component={MyComponent} somePropToMyComponent="demo">an item</MyMenuItem>
    }

Your Environment

Tech Version
Material-UI 1.0.0-beta.32
React 16.2.0
browser Chrome 64
TypeScript 2.7.1
@mbrowne
Copy link
Author

mbrowne commented Mar 2, 2018

Thanks! But does it make sense to limit ContainerProps to React.HTMLAttributes<HTMLDivElement> (and ContainerComponent to React.ReactType<React.HTMLAttributes<HTMLDivElement>>)? The default isn't even a 'div', it's 'li'. Maybe just make it React.HTMLAttributes<any>?

@oliviertassinari
Copy link
Member

But does it make sense to limit

@mbrowne This is the simplest solution I can think of. @pelotom any idea around how we could improve the situation?

@mbrowne
Copy link
Author

mbrowne commented Mar 2, 2018

Maybe my suggestion of using a generic for the component prop would work for the ContainerComponent and ContainerProps props too...for example:

export interface ListItemProps<TComponentProps = React.HTMLAttributes<HTMLDivElement>, TContainerComponentProps = React.HTMLAttributes<HTMLLiElement>> ...

That way, the component and ContainerComponent props would default to prop types of HTMLDivElement and HTMLLiElement, respectively, but if the user wanted they could override the types so type-checking would work for additional props for some other component.

(BTW, in my initial bug report, I meant to refer to using a generic for MenuItemProps; I don't know why I wrote MenuListProps.)

@pelotom
Copy link
Member

pelotom commented Mar 3, 2018

Maybe my suggestion of using a generic for the component prop would work for the ContainerComponent and ContainerProps props too

For generic props to be useful you also need generic components, and unfortunately those aren't well supported currently; see microsoft/TypeScript#6395.

I think this is fine, albeit not as type safe as one could hope:

ContainerComponent?: React.ReactType<React.HTMLAttributes<any>>;
ContainerProps?: React.HTMLAttributes<any>;

@oliviertassinari as a larger design note, the FooComponent/FooProps pattern that's common in Material UI presents a tricky problem for making type safe. It would be much easier and more powerful if we just used render props for these situations, e.g.

renderContainer?: (props: { className: string; children: ReactNode }) => ReactNode

Here you can be very specific about the props ({ className: string; children: ReactNode }) because you know that's exactly what will be passed, and it's up to the provider of the render prop to render that however they see fit. They can use whatever root element they want, whatever attributes they want, and even add more children. It allows complete customization while remaining perfectly type safe.

@oliviertassinari
Copy link
Member

oliviertassinari commented Mar 4, 2018

the FooComponent/FooProps pattern that's common in Material UI presents a tricky problem for making type safe. It would be much easier and more powerful if we just used render props for these situations

@pelotom I have added the render prop API as an investigation area before releasing v1. Right now, the main use case for the XXXProps is to simplify the property injection. People can always provide their own component but it's cumbersome. Is this change equivalent to just removing the ContainerProps property? There is a wide range of possible API combination. I'm not sure what's the best one.

@pelotom
Copy link
Member

pelotom commented Mar 4, 2018

Is this change equivalent to just removing the ContainerProps property? There is a wide range of possible API combination. I'm not sure what's the best one.

The reason I suggested a render function instead of a component is that when you want to use an anonymous function to e.g. decorate with additional props,

<Foo
  ContainerComponent={({ className, children }) =>
    <Bar className={className} someAdditionalFlag />
      {children}
    </Bar>
  }
>
  ...
</Foo>

you explicitly don't want that function to be treated as a component, because it will be a new "component" on every render, and therefore Bar and everything below it will constantly get unmounted and remounted. That is, if what Foo renders is

<ContainerComponent className={className}>{children}</ContainerComponent>

then it's bad if ContainerComponent is an anonymous function. However if you had some logic to determine if ContainerComponent is actually just a simple function and in that case render

ContainerComponent({ className, children })

I think it would be fine.

@pelotom
Copy link
Member

pelotom commented Mar 4, 2018

@oliviertassinari TL;DR I'm basically just recapping this twitter thread which you were a part of 😄
https://twitter.com/donavon/status/954788277416005632

@oliviertassinari
Copy link
Member

oliviertassinari commented Mar 4, 2018

you explicitly don't want that function to be treated as a component, because it will be a new "component" on every render

@pelotom Right. On the other hand, you can create an intermediary variable to hold the reference to your custom component, then you can add extra properties. I think that it's really about how we can make the API the simplest to play with.

const C = ({ className, children }) =>
    <Bar className={className} someAdditionalFlag />
      {children}
    </Bar>
 
<Foo ContainerComponent={C}>
  ...
</Foo>

@pelotom
Copy link
Member

pelotom commented Mar 4, 2018

Right. On the other hand, you can create an intermediary variable to hold the reference with the CI.

That often requires you to do painful refactorings like turning this:

const MyComponent = props => {
  const x = ...;
  const y = ...;
  const z = ...;
  return (
    <Foo
      ContainerComponent={({ className, children }) => (
        <Bar className={className} x={x} y={y} z={z} />
          {children}
        </Bar>
      )}
    >
      x={x}, y={y}, z={z}
    </Foo>
  )
}

into this:

class MyComponent {

  ContainerComponent = ({ className, children }) => (
    <Bar className={className} x={this.x} y={this.y} z={this.z} />
      {children}
    </Bar>
  )

  render() {
    return (
      <Foo ContainerComponent={this.ContainerComponent}>
        x={this.x}, y={this.y}, z={this.z}
      </Foo>
    )
  }

  get x() {
    return ...;
  }

  get y() {
    return ...;
  }

  get z() {
    return ...;
  }
}

Not to mention that most people won't even be aware they need to do that refactoring, and will be confused when their app is extremely inefficient.

I think that it's really about how we can make the API the simplest to play with.

Agreed, I think the simplest and friendliest thing for users would be to take a component prop but inline SFCs when rendering.

@pelotom
Copy link
Member

pelotom commented Mar 4, 2018

const C = ({ className, children }) =>
    <Bar className={className} someAdditionalFlag />
      {children}
    </Bar>
 
<Foo ContainerComponent={C}>
  ...
</Foo>

If you just extract it to a variable in the render method, you haven't saved anything; it will still be a new component on every render. And if someAdditionalFlag is something that had to be computed from the props, you can't define C at the module level. You're forced to make your component into a class if it wasn't already, with C a field on it (see example above).

@oliviertassinari
Copy link
Member

@pelotom I agree, I definitely see value in the render prop API. My concern is much more about the execution. Who do you think we should move forward?

Not to mention that most people won't even be aware they need to do that refactoring, and will be confused when their app is extremely inefficient.

It's a good point.

@pelotom
Copy link
Member

pelotom commented Mar 4, 2018

I agree, I definitely see value in the render prop API. My concern is much more about the execution. Who do you think we should move forward?

I guess for the sake of backwards compatibility I would leave the props as they are, and just have special logic to render ContainerComponent inline if it's a function instead of a class component. And then the typings should be something like

ContainerComponent?: React.ReactType<{ className: string; children: ReactNode }>;
ContainerProps?: { className: string; children: ReactNode; [k: string]: any };

Then TypeScript users wanting maximal type safety should avoid usingContainerProps and do everything through ContainerComponent.

@mbrowne
Copy link
Author

mbrowne commented Mar 4, 2018

This sounds like a good plan. You may have considered this already, but I just wanted to note that function components are allowed to have static properties like defaultProps and propTypes, and those functions should in fact be mounted rather than just called in order to work properly. I'm not sure what would be the best way to distinguish actual function components from other functions at runtime...perhaps just checking for those properties specifically.

@oliviertassinari
Copy link
Member

oliviertassinari commented Mar 4, 2018

just have special logic to render ContainerComponent inline if it's a function instead of a class component.

@pelotom We can take inspiration from the now removed recompose referential transparency optimization. But this is not exactly what the render prop is about. I will give more thoughts about it. At first sight, it sounds like a nice tradeoff.

@pelotom
Copy link
Member

pelotom commented Mar 4, 2018

You may have considered this already, but I just wanted to note that function components are allowed to have static properties like defaultProps and propTypes, and those functions should in fact be mounted rather than just called in order to work properly. I'm not sure what would be the best way to distinguish actual function components from other functions at runtime...perhaps just checking for those properties specifically.

That's a good point. Probably want to check for at least

  • C.prototype.render
  • C.propTypes
  • C.contextTypes
  • C.defaultProps
  • C.displayName

@mbrowne
Copy link
Author

mbrowne commented Mar 4, 2018

@oliviertassinari FYI - and I mention this because it wasn't mentioned in the release notes, although you might already be aware - in React 16, function components do have optimizations that class components don't have (I have an open PR to the React docs about this). That may be why recompose removed that feature. But if I understand correctly (and I may not), it would still be significantly more expensive to mount a function component than to just call a function directly.

@oliviertassinari
Copy link
Member

@mbrowne Thanks for linking the pull request. I can understand why recompose removed this behavior. In our case, it's not about performance. It's about getting a broken behavior when the component unmounts/mounts while the component is stateful. I think that the referential transparency is technically a good approach. However, I'm not 💯 sold on the usage aspect of it. It can potentially be confusing for our users. I'm not aware of any library using the hybrid approach between render prop and component injection.

@pelotom
Copy link
Member

pelotom commented Mar 4, 2018

However, I'm not 💯 sold on the usage aspect of it. It can potentially be confusing for our users. I'm not aware of any library using the hybrid approach between render prop and component injection.

Yeah, I'm not either. It feels a little weird. Personally I would much prefer the simplicity of a render prop, that way there's no room for confusion. If we don't mind the breaking change, that's what I'd advocate.

@oliviertassinari oliviertassinari changed the title Issues with type declarations for ListItem and MenuItem Render prop API? Mar 4, 2018
@mbrowne
Copy link
Author

mbrowne commented Mar 5, 2018

Render props are becoming increasingly common, and often preferred over other alternatives. I'm not saying they're always the best solution, but they're a good solution here. Of course backward compatibility also has to be considered, but I wonder how many people are already using the containerElement prop anyway. And the new component prop would be introducing another similar prop, and with it more Flow/TypeScript issues, which a render prop would solve. IMO if this change is ever going to be made, the time to do it is now, especially since people shouldn't expect pre-1.0 versions to be fully future-compatible.

On the other hand, the concise syntax is nice, e.g. for something like this which is what I actually used the component prop for in my app:

<MenuItem component={Link} to="/contact">Contact</MyMenuItem>

...so a dual API (allowing the user to pass either a component or render function) could be nice for that reason... However, if it's decided that only supporting render props is the better API going forward, there are other approaches that could be taken in scenarios like this, e.g.:

const MenuItemLink = ({to, children, ...menuItemProps}) => (
    <MenuItem {...menuItemProps} renderComponent={children => (
        <Link to={to}>{children}</Link>
    )}>{children}</MenuItem>
)
export default MenuItemLink

Creating separate components would make sense at least for frequently reused MenuItem-component combinations, and would be another way of solving the typing issues.

@mbrowne
Copy link
Author

mbrowne commented Mar 5, 2018

(Note: I just edited my above comment; I didn't write the code I meant to write when I first sent it.)

@mbrowne
Copy link
Author

mbrowne commented Mar 8, 2018

I had another thought...if you want to support both render props and passing components, you could actually have two separate props, e.g. ContainerComponent and renderContainer, and throw an error if both are supplied (since you should only supply one or the other). I'm not sure I like this solution, especially for components like ListItem that would have multiple such pairs of props, but it might have certain advantages in terms of clarity and type signatures.

Also, another alternative (to support a more concise syntax like the first version of my MenuItem Link example): use a helper function that takes any component class/function and returns a function that can be passed as a render prop. Then only the render prop API is needed. The helper function should probably just be a recommendation in the docs though, rather than part of material-ui.

@mbrowne
Copy link
Author

mbrowne commented Mar 18, 2018

@oliviertassinari I just came across a library using a "hybrid approach between render prop and component injection":
https://github.com/donavon/render-props

I did discover one issue with it, but it should be easily solved.

However, I don't know if anyone is using this library "in the wild" yet. FYI I found the library via this article: https://blog.kentcdodds.com/answers-to-common-questions-about-render-props-a9f84bb12d5d

@oliviertassinari
Copy link
Member

@mbrowne Thanks for the links. I'm gonna work on this issue today and propose a plan of action.

@oliviertassinari
Copy link
Member

oliviertassinari commented Apr 22, 2018

Let's benchmark

Let's start with a benchmark. Most of my job with Material-UI is to copy the good stuff while ignoring the other "bad" stuff.

Method How it is rendered
component prop return React.createElement(this.props.component, props)
render prop return this.props.render(props)
a render function as children return this.props.children(props)

(This table is by order of precedence)

  • react-router from @mjackson. They are using the same approach as react-final-form.
  • downshift from @kentcdodds. They are using the same approach as react-final-form at the exception where the component property isn't available.
  • react-toolbox from @javivelasco. They are using a factory pattern. It's almost like the react-select v2 approach with the exception that you perform the component injection at the component creation time, not at the element creation time. To simplify, react-toolbox's approach is static when react-select v2's approach is dynamic. For instance with the Dialog.

The problems we are trying to solve:

We want to provide a unified solution to the following problems:

Possible solutions

I believe the styles customization problem has been nailed by the introduction of the classes property 9 months ago. Now, let's try to find an elegant solution to the React component side of the customization problem with minimum breaking changes.

I'm proposing the following API. I have used the previous benchmark and the slot feature of vue as inspiration:

import Fade from 'material-ui/Fade'

<Snackbar
  // A regular property
  open
  // A property to change the root component
  component="div"
  // An object to override a whitelist of nested components.
  // Why having a `component` and `components` property?
  // Because `component` should be enough 80% of the time.
  components={{
    transition: Fade,
    snackbarContent: props => <span {...props} />,
  }}
  // Similarly to the theme.props object feature,
  // we can use it to inject properties on a whitelist of nested elements,
  // the same whitelist than for the `components` property.
  props={{
    transition: {
      appear: false,
    },
    snackbarContent: {
      'e2e-test': 'snackbar-content',
    },
  }}
/>

Regarding the component and components behavior. I think that we can try to be smart. I can't think of any unattended wrong side effect.
We can make the properties behave like a render property as long as users are providing a function with only regular ownPropertyNames. But anything like propTypes, displayName, foo or a class will make us consider the property a component and not a render property.

Regarding the implementation. I think that we can minimize the overhead by using a whitelist of customization point and by building some helper functions. The whitelist will also enable us to automatically document it for each component.

This is a breaking change. If we move forward in this direction, it means we can provide a systematic answer to the problems. We can remove the xxxProps properties and the custom customizartion ones.

@sebald
Copy link
Member

sebald commented Apr 23, 2018

That's a though one. I'll try to write down my 2 cents about this.

(1) IMHO the problem we want to solve and render props are orthogonal. Render props are more "higher level" (= more abstract). They will not let you change some specific part of the rendering process (e.g. change the transition from Fade to Grow) and leave the rest untouched, but rather will give you full control of what is rendered. This means that you have to specify everything you want to keep, too. Basically, render props is the "binary version" of @oliviertassinari proposal.

(2) Using a slot-like API is really really clever 🙇‍♂️ But Using more props than we already have, might complicate things. Especially for people that do not want to use the API.

What about using a more component-oriented approach? For example using compound components like this:

import Fade from 'material-ui/Fade'

<Snackbar
  open
  component="div"
>
  <Snackbar.Transition>
    {props => <Fade appear={false}/>}
  </Snackbar.Transition>
  <Snackbar.Content>
    {props => <span {...prop} e2eTest="snackbar-content"/>}
  </Snackbar.Content>
</Snackbar>

This is just a quick shot about an alternative API. What I don't really like at the props-based API is that it is MUI-specific and the props and components relation is not coherent enough.

Using something like compound components could solve this. It is a (more) familiar API for React developer and most importantly it is not a MUI-specific API.

Another nice side effect is that there are less props on the "host" component and less people will walk into the typo trap (component <> components) ... but of course they all should use TypeScript anyway and then this wouldn't happen 😛

My only concern is performance. I am not sure if it is a good idea to have components share context here. I wonder if there are any widespread React implementations of the <slot> mechanism. (If found this one with a quick Google search.)

I would also suggest to think about the misuse of the new API. It would be nice to have it only work if you're using it the way it should be.

PS: The slots also could be using render props, just to feed the hype! render-props


tl;dr I really like the idea of slots. I think that if @oliviertassinari estimate is correct and only 20% of the users need this feature, we should make sure that the API for the other 80% will not get more complex.

@kentcdodds
Copy link

I don't have a whole lot of time to dedicate to looking into this issue. But I just want to say that if you build the base component with a render prop + prop getters you can easily build other abstractions on top. In addition, there's less of an API to learn. I talk about this a bit in "Compose Render Props"

So you could build a base component that uses a render prop + prop getters for any props that need to be applied to certain elements rendered, then build a few different components on top to cater to common use cases. This is one thing that has made downshift so flexible and simple.

I'm going to have to unsubscribe from this issue now I'm afraid. I get way too many notifications. Feel free to ping me though if you want further clarification.

@sebald
Copy link
Member

sebald commented Apr 24, 2018

Render props together with prop getters would make the API very powerful. E.g.

import Fade from 'material-ui/Fade';

<Snackbar
  open
  component="div"
  render={(children, getTransitionProps, getContentProps) => (
    <Fade {...getTransitionProps()} appear={false}>
      <span {...getContentProps()} e2eTest="snackbar-content">
        {children}
      </span>
    </Fade>
  )}
/>;

But I wonder if this isn't to much abstraction. The downside of this approach is that people need to know the internal structure of the component. For example, if they only want to change the transition to <Fade> and set some additional props, they would also recontstruct the whole <Snackbar>.

That said, the API obviously would give the users the power to do whatever the fudge they want. I guess it depends on the use case what is better?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

5 participants