-
Notifications
You must be signed in to change notification settings - Fork 53
Alternative to 'renderX' set of props for slots #355
Description
Let this issue serve as a discussion hub for alternative approach to renderXXX
one that is currently used for Stardust components.
TL;DR Here is the list of main reasons for that:
- downsides of
renderXXX
- necessity to bloat API surface with
renderXXX
methods for each slot of each component - general incorrectness of shorthand evaluation for async scenarios
- it is easy to for the client to accidentally use this API improperly
- points of confusion related to API
- necessity to bloat API surface with
- suggested alternative approach
- is able to cover all the cases covered by the original one
- introduces safe API that could be easily explained to user
- is able to correctly handle async shorthand rendering cases
Please, note that scenario-based comparison examples for both approaches are also provided.
Contents
Motivation
There are several problems with current `renderItem` prop - let me mention them.1. "Do It Yourself" pattern provided by `renderXXX` to compose component
Lets consider the API we now have for renderItem
:
/**
* Its client responsibility now to not forget about:
* - consuming props as a second argument
* - spread these props to the component
*
* Failure to do these steps will result in the problems
* that could be hard to immediately detect
* - broken/incorrect styles
* - broken/incorrect component behavior/accessibility behavior
**/
<Menu items={items} renderItem={(Component, props) => { // <== don't forget 'props' !
...
return <Component {...props} /> // <===== oh, don't forget to expand 'props'!
}/>
As we might see, there are quite a lot of things that client should worry about now - thus, this approach is quite error-prone.
2. Shorthand is evaluated at different stage for async case
For synchronous case we've had that shorthand item was rendered after its descriptor object (i.e. element of items
array) has been fully initialized.
Now lets move to async case. For the sake of example lets suppose that we have only key
data being defined for each item, and that content
data should be fetched for it.
const items = [
{ key: 'a' }, // <----- no 'content' for these initially
{ key: 'b' }
...
]
With renderItem
approach we have shorthand (Component, props)
evaluation happened before data is fetched - while, to comply with synchronous semantics of shorthand, we need this evaluation to happen after necessary data is fetched. Note that this might be absolutely necessary if shorthand evaluation relies on some data that is fetched asynchronously.
<Menu
items={items}
renderItem={(Component, props) => { // <== note, shorthand is already evaluated!
<Async
getData={() => ...}
render={content =>
<Component {...{...props, ...{content: data} }} />} // <=== but should be here
/>
}/>
3. Bloat of additional props needed for each component
With renderXXX
approach we need to introduce new render
function for each slot of each component. With the alternative approach there won't be any need to support render
functionality on the component's side - once written it will be automatically reused by all components.
4. Points of general confusion for client
General question that immediately arises for renderXXX
approach is how shorthand's object data is passed to renderItem
? It turns out that all the item
's data is merged into component's props
provided in the callback - arguably, not something very intuitive for the client.
<Menu items={[
{ key: 'a', content: ..., },
{ key: 'b', content: ..., }
...
]}
renderItem={(Component, props) => ... how item's data is provided to callback? .. }
Proposed Solution
General Idea
TL;DR General idea is to support callback function for shorthand, where render
function as an argument.
Full Story
Stardust has a notion of shorthand
to provide a declarative way for specifying components that should be rendered - in a form of object or React Element. Specifically, for the following case we have a Menu
which items
array is declared by object shorthands used:
<Menu items={[
{ key: 'a', content: '....' }
{ key: 'b', content: '...' }
..
]} />
However, at the very end each object is evaluated to React Element when Menu
is rendered. Thus, the following one will provide identical results: here each object of items
array is transformed to MenuItem
element (with necessary props being evaluated and properly applied).
<Menu items={[
<MenuItem key='a' content='...' accessibility='...' />
<MenuItem key='b' content='...' accessibility='...' />
..
]} />
So, essentially, we could imagine some transformShorthandObject
function that produces React Element from the object provided - and this is, essentially, the only piece hidden from the client when shorthand is provided in a form of object
. This hidden piece, render
(transformShorthandObject
) function, is the corner stone of the proposed approach.
Example
Essentially, these two will provide identical rendered Menu
:
<Menu items={[
{ key: 'a', content: '...' },
{ key: 'b', content: '...' },
...
]} />,
<Menu items={[
render => render({ key: 'a', content: '...' }),
render => render({ key: 'b', content: '...' }),
...
]} />,
Benefits
- only one prop is (re)used
- doesn't introduce need to provide
renderXXX
prop for all slots of all components - solves the problems of
renderXXX
approach mentioned above - meet all the merit points of original approach, namely
- Define shorthand data (strings, numbers, props objects)
- Control the render tree of the shorthand components
- Accept and use state, styling, and accessibility computed by the Menu
- Asynchronously render child shorthand components
- doesn't introduce any semantic changes to the process of shorthand evaluation
Scenario Examples
Explain concept to consumer
Suppose that we have the following code as the originally written. Following example provide identical effect as this original one.
<Menu
items={[
{ key: 'a', content: 'custom chat message' },
{ key: 'b', content: 'custom chat message' },
{ key: 'c', content: 'custom chat message' },
]}
...
/>
Before
<Menu
items={[
{ key: 'a', content: 'custom chat message' },
{ key: 'b', content: 'custom chat message' },
{ key: 'c', content: 'custom chat message' },
]}
...
renderItem={(Comp, props) => <Comp {...props} />}
/>
Proposed
<Menu
items={[
// 'done' callback semantics
render => render({ key: 'a', content: 'custom chat message' }),
{ key: 'b', content: 'custom chat message' },
{ key: 'c', content: 'custom chat message' },
]}
...
/>
Thoughts
- not clear how objects from
items
are used inrenderItem
- client is required to compose rendered tree by herself, there is a possibility to accidentally introduce bugs that would be hard to discover. Simplest example - forget to spread
props
to element. - clear 'callback' semantics for alternative proposal - the same approach that is commonly used bto allow customizations in general processing logic. Just few examples of domains where applied
- async unit tests (Jasmine, Jest)
- middleware pipeline (Express)
- build pipeline (Gulp)
- ...
- only one prop (
item
) is utilized for the alternative proposal - this will prevent potential inconsistent use of twoitems
andrenderItem
props that are dependent on each other now.
Async Rendering
Suppose that client's intent is to render each item's shorthand asynchronously, once all the necessary shorthand data is fetched.
Also, for the sake of argument, suppose that client's code have some component that provides abstraction for data fetching logic - e.g. Async
, that provides the following basic props
getData
- defiines logic for async data fetchingrender
- defines logic to render tree once data is fetched
const urls = [
'http://url-a',
'http://url-b'
]
Before
const mapKeyToUrl = { // <----------------- should be defined and maintained
'a': urls[0],
'b': urls[1]
}
// .......
<Menu
items={[
{ key: 'a' },
{ key: 'b' },
{ key: 'c' },
]}
renderItem={(Comp, props) => (
<Async
getData={() => fetch(mapKeyToUrl[props.key])} // <---------- maps item to URL
render={asyncData => <Comp {...{...props, ...{content: asyncData} }} />}
/>
)}
/>
Proposed
<Menu
items={urls.map(url => renderItem => (
<Async
getData={() => fetch(url)}
render={asyncData => {
const withContent = { ...item, ...{ content: asyncData } }
return renderItem(withContent)
}}/>
))}
/>
Thoughts
- with proposed solution there are no changes made to the process of evaluating shorthand object to element (by
render
method), semantics remain to be the same: once shorthand descriptor object is fully ready, pass it to therender
method to evaluate the shorthand. WithrenderXXX
approach we see the shorthand object being precomputed first (implicitly, based on theitem
), decomposed toComp
andprops
- and when async operation finishes, its client responsibility to properly compose the object back. Thus, with the only need of async rendering being introduced, client is provided with additional irrelevant responsibility to properly compose the element. - problem related to aforementioned one - with current approach shorthand is evaluated for the object that doesn't contain full set of data necessary (as some data should be fetched). This requires us to maintain the following (quite strong!!) invariant for shorthand evaluation function to ensure that our logic is consistent and correct:
Evaluate(shortandProps + additionalProps }) = Evaluate(shorthand) + Evaluate(additionalProps)
- with the alternative approach we don't introduce any restrictions of linearity to shorthand evaluation functions. In fact, we are not introducing any additional restrictions with alternative approach, which opens much broader space for future maneuvers.
- *not strictly related to async case, but still - note that now, with current approach, client is not able to specify
url
directly as prop of the item initems
array, as what intuitive thought would be - because in that case this will be merged toprops
that are passed inrenderItem
callback. Thus there is a need to introduce thiskeyToUrl
mapper.
Custom Tree Rendering
Before
<Menu
items={[
{ key: 'a', content: 'custom chat message' },
{ key: 'b', content: 'custom chat message' },
{ key: 'c', content: 'custom chat message' },
]}
.....
renderItem={(Comp, props) => (
<Comp {...props} >
My cool subtreee!
</Comp>
)}
/>
Proposed
OPTION 1: Identical approach, use the same Comp
, props
tools so that the same set of cases could be addressed.
<Menu
items={[
{ key: 'a', content: 'custom chat message' },
{ key: 'b', content: 'custom chat message' },
{ key: 'c', content: 'custom chat message' },
]
.map(item => render =>
render(item, (Comp, props) =>
<Comp {...props} >
My cool subtreee!
</Comp>
)
)}
/>
OPTION 2: Safer approach
<Menu
items={[
{ key: 'a', content: 'custom chat message' },
{ key: 'b', content: 'custom chat message' },
{ key: 'c', content: 'custom chat message' },
]
.map(item => render =>
render({ ...item, children: <>My cool subtreee!</> })
)}
/>
Thoughts
- quite often client doesn't need to change the
<Component {...props} />
part - and this what is guaranteed to be properly handled by alternative approach - in cases where it is necessary, though, there is a possibility to utilize second argument of the
render
function - and, thus, it is possible to fully cover the same scenarios that are possible now.
Selectively apply special rendering
In this example lets suppose that our need is to fetch data for only one/several elements of Menu
- and lets suppose that we have to use the Async
component for that (for example, in case of Apollo-based app).
Before
<Menu
items={[
{ key: 'a' }, // <-------- item with unknown content, fetched async
{ key: 'b', content: 'custom chat message' },
{ key: 'c', content: 'custom chat message' },
]}
renderItem={(Comp, props) => {
if (props.key === 'a') {
<Async
getData={() => fetch(urlsMap[props.key])}
render={asyncData => (
<Comp {...{...props, ...{content: asyncData} }} />
)}
/>}
return <Comp props />
}}
/>
Proposed
<Menu
items={[
// item with unknown content, fetched async
render => <Async
getData={() => fetch('http://get-a')}
render={asyncData => render({ key: 'a', content: asyncData })} />,
{ key: 'b', content: 'custom chat message' },
{ key: 'c', content: 'custom chat message' },
]}
/>
Thoughts
- no 'opt-in' for
renderItem
prop case (the one we have now) - this will result inswitch
semantics being needed from client - either byif
-switch
expressions, or by using dedicated map object.