No, this isn't about render props
I'm going to clean this up and publish it in my newsletter next week!
So react-i18n
(not the npm one... one we made at PayPal internally) has this
API that I call "sorta-curried". I wrote about it a bit in
my last newsletter.
So here's an example:
import getContent, {init} from 'react-i18n'
init({
content: {
pages: {
home: {
nav: {
about: 'About',
contactUs: 'Contact us',
}
}
}
}
})
// here's the sorta-curried part... These all result in exactly the same thing: "About"
getContent('pages.home.nav.about')
getContent('pages')('home')('nav')('about')
getContent('pages.home')('nav.about')
getContent('pages')('home.nav')('about')
// etc...
So thinking about this in the context of React:
const getHomeContent = getContent('pages.home')
const ui = <a href="/about">{getHomeContent('nav.about')}</a>
// that'll get you:
<a href="/about">About</a>
So far so good. But, what if you mess up and have a typo?
Before today, here's what happened:
const ui = <a href="/about">{getContent('pages.typo.nav.about')}</a>
// that'll get you:
<a href="/about">{pages.typo.nav.about}</a>
// note, that's a string of "{" and "}"... not jsx interpolation...
So that's fine. But here's where things get tricky. Because we return a string
of {full.path.to.content}
, if content is missing or there's a typo
you can't call a function on what you get back. If you try, you're calling a function
on a string and that'll give you an error that would crash the app. Error
boundaries could help with this, though sometimes we call getContent
outside
of a React context, so that wouldn't help in every case. Anyway, this will break
the app:
const getHomeContent = getContent('pages.typo')
const ui = <a href="/about">{getHomeContent('nav.about')}</a>
// 💥 error 💥
Again, this is happening because getContent('pages.typo')
will return the
string {pages.typo}
(to indicate that there's no content at that path and
the developer needs to fix that problem to get the content). And you can't
invoke a string.
So the change I made today makes it so that when there's no content at a given path, it still returns a "sorta-curried" function so you can keep calling it all day long if you want. No problem.
So now this wont throw an error, but we lose rendering:
const getHomeContent = getContent('pages.typo')
const ui = <a href="/about">{getHomeContent('nav.about')}</a>
// that'll get you:
<a href="/about"></a>
And we want to make sure that we show the missing content so it's more obvious
for developers (yes we log to the console as well) and if the world is on fire
and the content failed to load for some reason, it's better for a button to say
{pages.transfer.sendMoney}
than to say nothing at all.
So here's where the challenge comes in. Let's rewrite the above to make this more clear:
const getHomeContent = getContent('pages.typo')
const aboutContent = getHomeContent('nav.about')
const ui = <a href="/about">{aboutContent}</a>
aboutContent
in this example is a function because getHomeContent
had a
typo, so we'll never actually find content that matches the full path. So the
challenge is how do we make sure that we can render the full path in a situation
like this?
At first I thought I could monkey-patch toString
on the content getter
function. But that didn't work. I still got this warning from React:
Warning: Functions are not valid as a React child. This may happen if you return a Component instead of from render. Or maybe you meant to call this function rather than return it.
So I stuck a breakpoint at the line where that error was logged and stepped up the stack to find where the problem was.
> printWarning
> warning
> warnOnFunctionType
> reconcileChildFibers <-- ding ding! 🔔
The reconcileChildFibers
is where I discovered that react will check the children you're trying to render
to make sure they're render-able.
Looking through that code, it checks if it's an object first, then it checks if it's a string or number, then an array, then an iterator. If it's none of those things, then it'll throw (for a non react-element object) or warn (for a function).
So, in my case, the thing I want to render has to be a function due to the constraints mentioned earlier. So I can't make it work as an object, string, number, or array. But I realized that there's nothing stopping me from making my function iterable (if you're unfamiliar, here's the iterators part of my ES6 workshop recording).
So, I made my function iterable:
const ITERATOR_SYMBOL =
(typeof Symbol === 'function' && Symbol.iterator) || '@@iterator'
// ...
function iterator() {
let timesCalled = 0
const resultAtPath = getNestedPath(options.content, options.path)
log.error(
'An attempt was made to render a content getter function.',
// eslint-disable-next-line no-nested-ternary
!options.path
? 'This is because no path was provided'
: resultAtPath
? `This is because the content at path "${String(
options.path,
)}" is not a string`
: `This is because there is no content at the path "${String(
options.path,
)}"`,
{ resultAtPath, options },
)
return {
next() {
return { done: timesCalled++ > 0, value: pathAsString }
},
}
}
// ...
contentGetter[ITERATOR_SYMBOL] = iterator
// ...
The cool thing about this too is that I can log an error with a bunch of context
to help the developer figure out what's going on. Because if iterator
is
called I can assume that React is attempting to render the contentGetter
.
So yeah, there's my use case for making a function iterable 😉
Note: I don't get notifications on comments on this gist. Feel free to ping me on twitter if you comment on here to let me know about it.