Reducing our reliance on legacy React APIs #799
Replies: 1 comment 1 reply
-
Just following up from a conversation I had with @rezrah outside of this discussion where we discussed whether context could be used to replace much of our use of After reflecting on this, I think that there are two different scenarios for which we rely on these legacy APIs. To avoid burying the lede, here are the two scenarios along with, in my opinion, the patterns that can be used to achieve them.
Modifying the props of provided childrenIn this scenario, we don't really care what children are passed, we're just going to modify their props slightly and render them. We do this in a handful of places, but I'm going to take
This can be achieved nicely using both context and render props. // Context
<ButtonGroup>
<Button>1</Button>
<Button>2</Button>
<Button>3</Button>
</ButtonGroup>
// Render props
<ButtonGroup
renderButtons={props => (
<Button {...props}>1</Button>
<Button {...props}>2</Button>
<Button {...props}>3</Button>
)}
/> In this scenario (where there is only ever a single slot into which the children are rendered) there's not really much difference in the approaches; whatever you can do with one, you can do with the other.
Rendering certain children into certain locationsIn this scenario, we need to modify the behaviour of the component itself based on the children that are passed, or fundamentally treat one child differently compared to another based on its type, for example rendering one child into one location and another child into another. Some good examples of this are
Context doesn't really help here. Context is useful for providing props from a parent component to children (direct children, or deeply-nested children). It doesn't concern itself with rendering, or deciding where specific components can be rendered. It can't be used to solve this problem. Render props, on the other hand, function like slots. You can use a render prop to decide where, how, and with what props something gets rendered. // Render props
<Accordion
renderHeading={props => <Accordion.Heading {...props}>Heading</Accordion.Heading>}
renderContent={props => (
<Accordion.Content {...props}>
<p>Some description</p>
<p>Some description</p>
</Accordion.Content>
)}
/>
// Context
// ???
SummaryAs soon as we start inspecting the type of components — which is something we do quite often — context is no longer useful to us in solving the underlying issue. We'll still have to inspect the children and branch based on the type of each child, which will require us to use the legacy APIs that I'm proposing we avoid. On the other hand, render props are flexible enough to work well in either scenario; they can provide a single slot into which children can be rendered (eg in As it stands, I believe render props are the only option which will allow us to render specific children into specific locations, but I'd love to explore any options I may have missed. If we're really tied to the familiarity that context can provide for components which support a single child slot, then maybe there is a situation where we implement a convention that states that we use context for components which only have a single child slot, and render props when there are multiple child slots. This could, however, be perceived as inconsistent, even if there is a clear rule which denotes which approach is used. Also, if a component were to move from having one child slot to multiple, then it would require a refactor (and a breaking change) to migrate it from context to render props. As context doesn't allow us handle both scenarios (without relying on these legacy APIs) then it may be simpler and more consistent to adopt render props whole-hog, and give a primer (excuse the pun) on render props in our docs for those who haven't used them before. As always, I welcome feedback, corrections, and questions on all of the above 💖 |
Beta Was this translation helpful? Give feedback.
-
Current state
According to the React documentation, some of the React APIs that we leverage in Primer Brand are considered legacy. Below I've listed all of APIs which are currently considered legacy by React, alongside the number of times each API is used in the Primer Brand codebase, the number of components using the API, and the RegEx used to gather this data, in case you want to verify this yourself.
Children
(React\.|\w)Children\.
cloneElement
cloneElement
Component
Component
createElement
createElement
createRef
createRef
isValidElement
isValidElement
PureComponent
PureComponent
At this point in time, these APIs aren't considered deprecated, meaning there are no plans to remove them from React itself, but they are considered legacy.
I propose that we stop relying on these APIs when building new components, and instead leverage more modern alternatives. I believe that this will have the added benefit of making our code easier to reason about, less fragile, and more flexible.
Replacing
createElement
This function is only used in two places in our codebase and can be easily removed. Both of these occurrences can be replaced by replacing the call to
createElement
with the equivalent JSX.Replacing
Children
,cloneElement
,isValidElement
These three APIs tend to be used hand-in-hand — every file which uses
isValidElement
also usesChildren
and/orcloneElement
— so it makes sense to consider them holistically.These APIs are commonly used to solve a set of related problems around component composition and child manipulation. In Primer Brand, they're primarily used to:
However, these approaches come with several drawbacks:
cloneElement
call creates a new elementProposed solution
Instead of relying on these legacy APIs, I propose an approach which utilises render props, which are the React documentation's recommended alternative to these legacy APIs.
We already support render props in a few places within the codebase, so the core pattern is one that's already established within Primer Brand. At the moment though, we don't use render props to their full potential and so still end up supporting these legacy APIs alongside them.
By embracing in the functional nature of render props we can replace the vast majority of our usage of these deprecated APIs.
Example
Lets take our current Card component as an example.
Along with the core Card component, we also export
Card.Image
,Card.Label
,Card.Icon
,Card.Heading
, andCard.Description
. A typical card might look something like this.The Card component uses the legacy
Children
,cloneElement
, andisValidElement
APIs to iterate through each child and filter out invalid children (view code). It then clones theCard.Heading
and applies a few additional props (view code).This same behaviour can be achieved using render props.
You can see what the implementation of this modified
Card
component looks like in thejoshfarrant/render-props
branch, along with an example of the working component in Storybook.In the example above, the
Card
component no longer accepts children, and instead utilises render props to create slots for child components to fit into. Each of those slots is passed a function returns the component to be rendered into that slot.The benefit of using functions (
renderIcon={() => <Card.Icon />
), rather than just passing the component straight in as a prop (renderIcon={<Card.Icon />
) is that theCard
component can provide additional props to be spread onto the child component, as we do above with therenderHeading
function. Theprops
provided byrenderHeading
contains the same props which were previously added to theCard.Heading
using the deprecated APIs.Another benefit of this approach is that it's easier to apply more rigorous typings to the component. For example, in our current
Card
implementation a consumer can render aCard
without aCard.Heading
. This not only goes against our interface guidelines, but it also breaks the core behaviour of the component and stops theCard
from being clickable. There is no feedback to the consumer that this is an issue.Using the render props approach, we can mark the
renderHeading
function as a required prop, meaning that consumers will get immediate feedback from TypeScript that they're missing a required prop.This approach still allows us to do runtime checks to ensure that the components passed to the render props are of the type they should be. For example we can check that
renderIcon().type === Card.Icon
and take some action if that evaluates to false.This approach will also allow us to have more control over how and where individual child components get rendered. We have some components where the order that the consumer provides the children in matters, such as the Checkbox component, and switching to using render props would remove that category of issue entirely.
Additionally, explicit render props will remove the need for manual checks to identify the type of child components, as in this example, since children will no longer be mixed together when they're provided to the component, and instead will already be separated out by the consumer.
Summary
My recommendation is that we start moving away from legacy React APIs when building new components and embrace render props going forward. Render props are the React-recommended approach and should provide the same functionality as our current approach.
I'd appreciate feedback and input on all of the above 🙂
Beta Was this translation helpful? Give feedback.
All reactions