/ react

Intl & Async Routes in React Router v4

Edit: After writing this and refining the approach, I've released a github package for everyone! react-router-async-routing

After a two week hiatus from Christmas vacation, I'm back with some code. I came up with this idea today and couldn't wait to post it for others. I've read over yahoo's react-intl but I just don't like the approach very much. I've heard other people say the same. When you don't like something, what do you do? Go and do it yourself! That's what I did and what I came up with is something I'm very happy with. We'll see how you feel about it.

Let me preface this by saying I have almost no experience doing intl stuff. I haven't done an insane amount of research. The things I have seen feel over engineered. The solution I came up with seems to be the most simple way to do it. If I'm wrong, leave a comment and I'll update this post with a better solution!

Folder Structure

modules
├── entry
│   └── index.js
├── intl
│   ├── README.md
│   ├── about
│   │   └── en-us.js
│   ├── contact
│   │   └── en-us.js
│   
└── views
    ├── about.js
    ├── contact.js
    ├── index.js

Let's get through this fast. Entry is the webpack entry point in our app. Looks like this:

ReactDOM.render(
    <AppContainer>
      <App />
    </AppContainer>,
    document.getElementById('root')
  )

AppContainer would be from react-hot-loader. App can be whatever you want. In our case, it looks like this:

import Routes from 'views/index'
import { BrowserRouter } from 'react-router'

export default () => (
  <BrowserRouter>
    <div>
      <Routes />
    </div>
  </BrowserRouter>
)

We are setting up react-router v4 and loading routes from our views folder.

Oh and the intl folder? It corresponds to the route along with the views folder. views/about.js will have its language files inside intl/about.

Language Files

All I'm doing is creating plain objects that represent all the text on a page. Here's what intl/about/en-us.js could look like.

export default {
  title: 'About',
  heading: 'Meet Our Team',
}

Now, let's create views/about.js

export default ({ text }) => (
  <div className="about">
    <h1>{text.title}</h1>
    <div>{text.heading}</div>
  </div>
)

Now how exactly do we inject that language file as a text prop? There's a couple ways we can do this. Here's how I'm doing it with React Router. My views/index looks like this:

import Route from 'utils/route'

export default () => (
  <div>
    <Route
      pattern='/about'
    />
  </div>

This component looks for a view inside of views${pattern} (in this case: views/about) and a corresponding language file in intl/about/language.js. It async loads them, and passes the language into the view! Here's how it looks:

import { Match } from 'react-router'
import { loadComponent, loadLanguage } from './route-helper'

class Async extends React.Component {
  constructor () {
    super()
    if (window.components[pattern]) this.state = { ...window.components[pattern] }
    else this.state = {}
  }

  componentDidMount () {
    if (!this.state.component) this.load(this.props)
  }

  componentWillReceiveProps (props) {
    if (this.props !== props) this.load(props)
  }

  async load ({ pattern }) {
    if (!pattern) return
    let { router } = this.context

    /* 
       load the language and component 
       at the same time and wait on them 
       to both resolve
    */

    let [component, text] = await Promise.all([
      loadComponent(router, pattern),
      loadLanguage(pattern)
    ])

    /* 
       we let the page show even 
       if a language file doesnt exist
    */
    if (component) {
      this.setState({
        component: component.default,
        text: text ? text.default : {}
      })
    }
  }

  render () {
    let { component, text } = this.state
    if (!component || !text) return null

    return React.createElement(component, { ...this.props, text })
  }
}

Async.contextTypes = {
  router: React.PropTypes.object
}
/*
  Load it after matching, otherwise it will load the view 
  before requesting the route.
*/

const Route = props => (
  <Match
    {...props}
    component={Async}
  />
)

export default Route

The loadComponent and loadLanguage functions are split out because they are needed in one more place. I'll explain why in a minute, but here they are.

let language = navigator.language.toLowerCase()
if (navigator.languages) language = navigator.languages[0].toLowerCase()

async function loadAsync (paths) {
  for (let path of paths) {
    try {
      return await path()
    } catch (e) {
      // console.log(e)
      // doesnt exist
    }
  }
  return false
}

export async function loadComponent (router, path) {
  let possiblePaths = [
    () => System.import(`../../views${path}`),
    () => System.import(`../../views${path}/index`)
  ]
  let component = await loadAsync(possiblePaths)
  if (component) return component
  router.transitionTo('/404')
  return false
}

export async function loadLanguage (path) {
  // cant split the first part into a base variable, due to bug in webpack 2 https://github.com/webpack/webpack/issues/3873
  let possiblePaths = [
    () => System.import(`../intl${path}/${language}`),
    () => System.import(`../intl${path}/index/${language}`),
  ]
  if(language !== 'en-us') {
    possiblePaths.push(
      () => System.import(`../intl${path}/en-us`)
    )
    possiblePaths.push(
      () => System.import(`../intl${path}/index/en-us`)
    )
  }
  let text = await loadAsync(possiblePaths)
  if (text) return text
  console.warn('No language file found for this route.')
  return false
}

We get the language locale from the browser. Then, we try to load a couple variations of the route pattern. If you put /jobs as your Route pattern, we try to load views/jobs.js if that doesn't exist, we try views/jobs/index.js. If that doesn't work, we redirect to a 404 page. For the language, say we are en-us, we look for intl/jobs/en-us.js, then intl/jobs/index/en-us.js. If we are say, es-mx. We will first check for intl/jobs/es-mx.js then if it doesn't exist fall back to english, and so on for the index.

Are we done?

Almost. There's one problem here. All the 3rd party React Router Async Match components have it. If I click a link to go to the contact page, it will render nothing. A few ms later, it will render in the content because the browser loaded it. In React Router v2, it supported async routes out of the box. The page wouldn't change until the next page was actually loaded. I don't like a flash of nothing for a split second, it makes the browser feel jumpy. So I also made a wrapper around the Link component.

import { Link } from 'react-router'
import { loadComponent, loadLanguage } from './route-helper'

export default class AsyncLink extends React.Component {
  constructor () {
    super()
    this.state = {}
    this.loadBeforeNavigate = this.loadBeforeNavigate.bind(this)
  }
  async loadBeforeNavigate (e) {
    e.preventDefault()
    if (window.components[to]) return router.transitionTo(to)

    let { to } = this.props
    let { router } = this.context

    let [ component ] = await Promise.all([
      loadComponent(router, to),
      loadLanguage(to)
    ])
    if (component) {
      window.components[to] = {
        component: component.default,
        text: text ? text.default : {}
      }
      router.transitionTo(to)
    }
  }

  render () {
    let { ...props } = this.props
    return (
      <Link
        onClick={this.loadBeforeNavigate}
        {...props}
      />
    )
  }
}

AsyncLink.contextTypes = {
  router: React.PropTypes.object
}

It will wait to transition until loading the bundles. Then the route will get through it's System.import almost instantly. Although, that part could be cleaned up. Currently, you click a link, it loads the component and language, once the page matches, it attempts to do the same thing. I need to make a way to just ditch the loading and render immediately, but maybe someone can help with that after reading this post! Edit: I fixed this by adding loaded components onto the window. If there's a built in way to check for a loaded module in webpack, that would work much better.

Oh, and I'm also using offline-plugin which means anyone on a modern browser will have all these splits downloaded in the background when they first load the site. These people will never have to wait even just a few ms to switch pages!

...

This is really rough. I made all of this today. It works, I like it, but it can be cleaned up and improved. Let me know if you have a better solution or if you think this should be put on github so we can work on an async loading api for v4 together!