Skip to content

Instantly share code, notes, and snippets.

@kentcdodds
Created October 25, 2017 22:01
Show Gist options
  • Save kentcdodds/0b17a4ac77b679ef3a71250107ac3849 to your computer and use it in GitHub Desktop.
Save kentcdodds/0b17a4ac77b679ef3a71250107ac3849 to your computer and use it in GitHub Desktop.
Rendering a function with React

Rendering a function with React

No, this isn't about render props

I'm going to clean this up and publish it in my newsletter next week!

Context

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...

With React

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...

The problem

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.

A solution and a new problem

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?

Developing the solution

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 😉

@kentcdodds
Copy link
Author

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.

@naholyr
Copy link

naholyr commented Oct 25, 2017

I think that would have been a good candidate for Symbol.toPrimitive too ;) alas I don't think it's as compatible, and React seems to check it's a function, so hacking toString, valueOf or even Symbol.toPrimitive might not do it (it's worth testing anyway):

const getContent = path => contentGetter(path)
const contentGetter = (path, subpath = null) => {
  path = path + subpath ? `.${subpath}` : ''
  const f = subpath => contentGetter(path, subpath)
  // Where the magic happens: now you control the typecast
  f[Symbol.toPrimitive] = () => path // TODO render value if path actually exists
  return f
}

String(getContent('foo')('bar')('baz')) // 'foo.bar.baz'

You could do the same in a more browser-compatible way by simply overriding f.toString instead of using the symbol I guess ;) But then again, React's detection may make it useless in the end.

@kentcdodds
Copy link
Author

Yeah, toPrimitive wont work due to the way that react checks things. It doesn't care about the primitive value. And I don't want people to have to wrap things in String like: <p>{String(getContent('blah'))}</p>, especially since getContent could also return an array of react components and strings 🤷‍♂️

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment