Components: Delegate often and well.
Delegation is a tried and true pattern for building UI libraries. For purposes of this article, I'm going to use the term delegation to refer to both OOP "self" assignment and callback functions interchangeably. The only real difference is the interface and number of callbacks.AppKit and UIKit use delegation (OOP delegation, and now callbacks) extensively, and the React community affectionally refers to component rendering delegation as Render Props. An excellent introduction to Render Props can be found within the React documentation. In short, a Render Prop is a prop functionOr, if properly called via JSX, any component.that we call within our own render logic, passing in any relevant arguments to the delegate. Presentational components will often provide a default, which allows for a default usecase while still providing a hook to override rendering when necessary.
Libraries that use Render Props often just use one to share common functionality, such as Apollo Client’s Query
component or Reach Router’s Match
component. Of course, there is no limit to the number of Render Props—UI libraries often benefit from using multiple Render Props and extensive delegation.
I encourage developers to liberally use delegation in presentational components they write, both internally and externally.At an extreme, this can lean uncomfortably against the notion of YAGNI. Anecdotally I can say that adding anticipated delegates has nearly always proven to be a time saver when team members seek to reuse your components. This is probably due to the fact that adding delegates costs very little, but I imagine there is some breaking point based on the likelihood of component reuse.It can take a little bit of time to get used to but the inversion of control and flexibility they provide is very valuable, even on small teams. This pattern proved itself over and over again when building an internal UI library that needed to accommodate the needs of several input-heavy products. Rather than bake in assumptions, providing hooks such as renderBefore
, renderAfter
, and renderInput
allows consumers to replace and extend our UI library whenever the need arises. I’d like to share some suggestions on how to improve your Render Props to yield a better experience to your component’s users.
Perfect Render Props
Providing delegation in the first place is already a huge boon to making a UI library more flexible. With some care on how we implement these Render Props, we can provide an API that is easy for consumers to use and which minimizes the coupling of our implementation to callers. To achieve these goals, I recommend following the rules below when implementing Render Props.
Always call delegates as JSX
I often see Render Prop implementations where only functions may be passed in. This is due to how the prop is called within the Component.
// Bad example: we are forcing users of this component to
// only use functional components.
function MyComponent() {
return (
<div>
{this.props.render({ foo: "bar" })}
</div>
);
}
// Better: calling as JSX allows the consumer to render an
// inline function or any component.
function MyComponent() {
return (
<div>
<this.props.render foo="bar" />
</div>
);
}
There is nothing wrong with using functions of course, but let your consumers decide! Remember that JSX transforms the call to create an element in React, so this simple change extends your prop to render anything React already can. This can help with performance too when using conditions (allowing React to call and avoid wasteful calls when not rendered) and when using React.memo
or PureComponent
. Your delegating component can avoid re-renders when a constant function or class is passed in.
Be explicit with what you pass in
This is a basic reminder, but it is a good idea to only pass down to delegates what you want to publicly expose. Otherwise you may have callers that rely on state keys you don't want to support.
// Bad example: passing internals down
class MyComponent extends Component {
state = {
secretCounter: 0,
internalState: "foo",
open: false
};
render() {
return (
<div>
<this.props.render {...this.state} />
</div>
);
}
}
// Better: explicitly sharing only what the
// delegate should know
class MyComponent extends Component {
state = {
secretCounter: 0,
internalState: "foo",
open: false
};
render() {
return (
<div>
<this.props.render open={this.state.open} />
</div>
);
}
}
Pass along any Default
s
Being about to override default rendering via renderProps is great, but sometimes you’ll want to conditionally override delegating over data structures such as a list. For example, if we have a delegate called renderItem
, we may want the Default
most of the time but need to replace a couple special rows.
We could export the DefaultItem
and have consumers import to use it, but it can be a pain to remember which default to use with multiple delegates, and it forces callers to figure out how to call it properly. Instead, I suggest passing in the Default to every delegate. This should be passed as a component to avoid needlessly making unused elements every render.This example is kept simple to allow for a short illustration, but ideally we would adjust the implementation to avoid passing Default
to the DefaultItem
.
function DropdownList({ items, onClick, renderItem }) {
const Item = renderItem || DefaultItem;
return (
<div style={{ overflow: scroll }}>
{items.map(item => (
<Item
key={item.id}
item={item}
onClick={onClick}
Default={DefaultItem} />
))}
</div>
);
}
This allows a caller to choose what they want to render. For example, we can change what we render depending on the type.
<DropdownList
items={[
{ id: 1, text: "Cut" },
{ id: 2, text: "Copy" },
{ id: 3, text: "Paste" },
{ id: 4, text: "Format on Paste", type: "checkbox" }
]}
renderItem={({ item, Default }) => (
item.type === "checkbox" ? <CheckboxItem item={item} onClick={onToggle} /> : <Default item={item} onClick={onAction} /> )}
/>
Apply props to Default
to hide internals
The previous example allowed us to conveniently render the Default
without worrying about any imports and selecting the correct item to override. However, it still exposes the internals of DefaultItem
. Ideally, consumers would not need to know how to pass props into the Default. If we added a new prop that was required for DefaultItem
, we would then break all callers. Instead, we can improve our implementation once again and all apply all props (while still allowing the caller to override if necessary).
function DropdownList({ items, onClick, renderItem }) {
const Item = renderItem || DefaultItem;
return (
<div style={{ overflow: scroll }}>
{items.map(item => {
return (
<Item
key={item.id}
item={item}
onClick={onClick}
Default={(overridingProps) => ( <DefaultItem item={item} onClick={onClick} {...overridingProps} /> )} />
);
})}
</div>
);
}
<DropdownList
items={[
{ id: 1, text: "Cut" },
{ id: 2, text: "Copy" },
{ id: 3, text: "Paste" },
{ id: 4, text: "Format on Paste", type: "checkbox" }
]}
renderItem={({ item, Default }) => (
item.type === "checkbox" ?
<CheckboxItem item={item} onClick={onToggle} /> :
<Default onClick={onAction} /> )}
/>
This is better! We now update our caller to avoid passing in applied props to the Default
. Because we have provided the overridingProps
, we can continue to override any individual props as necessary, such as onClick
in the above example.
The only concern now is performance—since we are generating a function on every render tick, we would re-render every time even if renderItem
happened to be a PureComponent
. If this is a concern I recommend using react-delegate-component
which will create a new component only when props change.
Using react-delegate-component
to make this easy and performant
The above rules make it much nicer for consumers of your components to use your API, but they can start to become a hassle to write frequently, especially if you are implementing a lot of delegates. I've implemented a simple package containing a single Delegate
component to make this easier (and work better with PureComponent
).
The package makes it trivial to respect the above rules with no extra effort. Simply import the component and let the Delegate
do the work to properly call as JSX, explicitly pass in props, and efficiently provide a Default
with all props bound.
import Delegate from "react-delegate-component";
function DropdownList({ items, onClick, renderItem }) {
return (
<div style={{ overflow: scroll }}>
{items.map(item => (
<Delegate
to={renderItem}
default={DefaultItem}
props={{ item }}
key={item.id}
/>
))}
</div>
);
}
The react-delegate-component
package can be found on GitHub and on npm. I hope you'll give it a try and work on making React libraries more flexible and convenient to use.