Back to Fluentui

RFC: Simplify handling of `as` prop

docs/react-v9/contributing/rfcs/react-components/convergence/as-prop.md

4.40.2-hotfix25.0 KB
Original Source

RFC: Simplify handling of as prop


@layershifter @levithomason @ling1726

Summary

Restrict as prop to accept only HTML elements due issues with typings and problems with developer experience.

Typings problem

Fluent UI components should support as prop to allow consumers to customize HTML tags, it's especially useful for components like Text:

tsx
<>
  <Text />
  <Text as="p" />
</>

However, currently components can accept React.ElementType in as prop:

tsx
<Button as={Link} /> // renders <Link /> + additional props

These usages are a nightmare for typings since we must use really complicated generics in order to guarantee type safety (ComponentWithAs) and support polymorphism. There are few similar implementations:

Anyway, with these generics our component can still throw on unsupported props:

tsx
// ✅ TS compiler will throw an error because "someProp" is not supported by "Button" and "Link"
<Button as={Link} someProp />

But, it still creates problems for customers when they will decide to compose our components:

tsx
// Uses a React HOC to control rendering
// https://reactjs.org/docs/react-api.html#reactmemo
const MemoButton = React.memo(Button);

// ⚠ It should be enough for customers to use "ButtonProps", but "ComponentWithAs" requires different typings
function PrimaryButton(props: ButtonProps) {
  return <Button {...props} primary />;
}

function App() {
  return (
    <>
      <Button as="div" href="#" />
      <MemoButton as="div" href="#" />
      <PrimaryButton as="div" />
      <PrimaryButton type="submit" />
    </>
  );
}

Retrospective note: It was required to support React elements in as prop for Semantic UI React due requirements to HTML markup:

tsx
<>
  <Sidebar as={Menu} />
  <Sidebar>
    <Menu />
  </Sidebar>
</>

On Fluent side we don't have such restrictions from styling system.

Event handlers problem

It's a real scenario from Teams.

Without this change we know that in as we can get a memoized component that forces us memoize event handlers:

tsx
function Button() {
  // 💣 Oops, this will break memoized input, should be wrapped with React.useCallback()
  const handleClick = () => {};

  return <Element onClick={handleClick} />;
}

<Button as={MemoEl} />;

This can be extremely tricky for us and customers with hooks approach as they may not be aware of these requirements:

tsx
function useButton(state) {
  state.onClick = React.useCallback(/* some code */);
}
function useCustomButton(state) {
  // 💣 this is not memoized anymore
  state.onClick = () => {
    state.onClick();
    /* some custom interactions */
  };
}

If as cannot accept React elements, there will be no need to memoize callbacks since it does not matter for primitive elements.

Accessibility problem

Another problem is related to accessibility handling as we need to override attributes or key handling based on a passed element, for example:

tsx
<>
  <Button />
  <Button as="div" />
</>
ts
// A pseudocode to show possible logic
if (props.as !== 'button') {
  state.role = 'button';
}

However, if user passes a React component we won't know the HTML tag before rendering it:

tsx
// 🤔 What does "SomeComponent" render?!
<Button as={SomeComponent} />

To solve this we can use tag detection in effects (like Reakit does), but it will cause a double render for <Button as="div" />:

  • render a component
  • resolve effects: check tagName and apply roles if needed, trigger an update
  • render a component again

Detailed Design or Proposal

Restrict as to HTML tags:

diff
-  as?: React.ElementType;
+  as?: keyof JSX.IntrinsicElements;

With this change it will only be possible to pass HTML tags to the as prop:

tsx
<>
  <Text as="span" />
  <Button as={Link} />
</>

Scenarios with React elements are not so frequent, in this case is proposed to compose components instead:

tsx
function LinkButton(props: ButtonProps & LinkProps) {
  // ⚠️ "components" are not support in hooks API yet, this will be covered in a separate RFC
  const { state, render } = useButton({
    components: { root: Link },
  });

  return render(state);
}

Pros and Cons

  • 👍 No need to memoize callbacks

  • 👍 Simplify typings

  • 👎 Makes <Button as={Link} /> scenario harder

Discarded Solutions

NA

Open Issues

NA