docs/react-v9/contributing/rfcs/react-components/convergence/as-prop.md
as prop@layershifter @levithomason @ling1726
Restrict as prop to accept only HTML elements due issues with typings and problems with developer experience.
Fluent UI components should support as prop to allow consumers to customize HTML tags, it's especially useful for components like Text:
<>
<Text />
<Text as="p" />
</>
However, currently components can accept React.ElementType in as prop:
<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:
// ✅ 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:
// 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:
<>
<Sidebar as={Menu} />
<Sidebar>
<Menu />
</Sidebar>
</>
On Fluent side we don't have such restrictions from styling system.
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:
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:
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.
Another problem is related to accessibility handling as we need to override attributes or key handling based on a passed element, for example:
<>
<Button />
<Button as="div" />
</>
// 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:
// 🤔 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" />:
Restrict as to HTML tags:
- as?: React.ElementType;
+ as?: keyof JSX.IntrinsicElements;
With this change it will only be possible to pass HTML tags to the as prop:
<>
<Text as="span" />
<Button as={Link} />
</>
Scenarios with React elements are not so frequent, in this case is proposed to compose components instead:
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);
}
👍 No need to memoize callbacks
👍 Simplify typings
👎 Makes <Button as={Link} /> scenario harder
NA
NA