Rails 5.2 App with Webpacker and React - Part 3

This is Part 3 of our series on building a Rails 5.2 App with Webpacker and React. In the last post we organized our public and private systems to use different packs and assets, and prepared everything for developing the Dashboard.

So we weren't sure what to do with the dashboard at first, but decided that a basic Blog/Posts system would cover the bases and help us gain an understanding of how to piece everything together from the Frontend React SPA, and the Rails API/Backend system.

Overview

  • The dashboard will be an SPA.
  • Routing for the dashboard will be handled in the client, and not Rails.
  • The client will access Rails API endpoints to handle data.
  • Simple state management by React
  • We'll use Blueprint.js components to make it easier.

The Plan

  • Add root element to Dashboard rails view for React to connect to.
  • Configure app pack to render on the root element
  • Build App and PageWrapper components to handle and render presentation components.
  • Add react-router to route requests
  • Add Blueprint.js navbar
  • Configure basic client react-router routes
  • Add logout

Now that we have an idea of what we're working towards and a plan, let's get started.

Add root element to Dashboard

Our root element is the target or mounting point where our React SPA will be rendered.

Open app/views/layouts/app.html.erb remove the call to yield and add it there:

  <body>
    <div id="root"></div> 
  </body>

Because the root element may not be rendered by the time React runs, we need to add defer: true the javascript_pack_tag call in app/views/layouts/app.html.erb

<%= javascript_pack_tag 'app', defer: true %>

This will prevent an eventual Target container is not a DOM element. error on page load.

Configure app pack to render in root element

Now that our target is in place, we can open app/frontend/packs/app.js, add React, and render it to the root element.

...
// Import react and react-dom
import React from 'react'
import { render } from 'react-dom' 
import App from '../app/components/App' 

...

// Add the render function to the bottom of the file:
render(<App />, document.getElementById('root')) 

This will get us setup to continue developing the React app.

Build App component

Our App component (not to be confused with the app pack) will be the main workhorse of the SPA. It will configure the routes and determine which component to render based on the current path.

Create app/frontend/app/components/App/index.js and add this content:

NOTE: import will use the index.js of a directory

import React, { Component } from 'react';
import { Route } from 'react-router'
import { BrowserRouter } from 'react-router-dom'
import PageWrapper from '../PageWrapper' 
import Dashboard from '../Dashboard'

class App extends Component { 

  render() {
    return (
      <BrowserRouter>
        <div>
          <Route exact path='/' render={ () => (
            <PageWrapper>
              <Dashboard />
            </PageWrapper>
            )} />
        </div>
      </BrowserRouter>
    )
  }
}

export default App

We're going to go through this quickly, so if you have time to read about react-router and react-router-dom feel free to do that now.

For now let's cover what this component is doing:

  • Imports React and other necessary packages
  • Creates a root route that will render the Dashboard component as a child of the PageWrapper component. This pattern will enable us to have a "layout" type wrapper for duplicate content on different pages of the app.

Let's follow the errors in the screen when we view the dashboard:

Module not found: Error: Can't resolve '../Dashboard'

Let's create the Dashboard component:

Create app/frontend/app/components/Dashboard/index.js and add this content:

import React from 'react' 

class Dashboard extends React.Component {
  render() {
    return(
      <div>
        <h1>Dashboard</h1>
      </div>
    )
  }
}
export default Dashboard 

Just a simple react component that renders an H1.

Let's continue following the errors:

Module not found: Error: Can't resolve '../PageWrapper'

Create app/frontend/app/components/PageWrapper/index.js with this content:

import React from 'react'

const PageWrapper = (props) => (
  <div>
    { props.children } 
  </div>
)

export default PageWrapper

Earlier in our App component we passed Dashboard as a child element of the PageWrapper:

<PageWrapper>
  <Dashboard />
</PageWrapper>

This is passed as a prop to PageWrapper and the Dashboard component is rendered in call to { props.children }.

Let's continue following the errors:

Module not found: Error: Can't resolve 'react-router'

Since we already know we're importing from react-router and react-router-dom, we'll add them both using yarn:

$ yarn add react-router react-router-dom

Recap

Whew that was alot! Here is a quick overview of what our files should look like at this point:

frontend
|-- app
|   |-- assets
|   |   |-- images
|   |   |-- styles
|   |   |   |-- app-styles.scss
|   |-- components
|   |   |   |-- App
|   |   |   |   |-- index.js
|   |   |   |-- Dashboard
|   |   |   |   |-- index.js
|   |   |   |-- PageWrapper
|   |   |   |   |-- index.js
|-- application
|   |-- assets
|   |   |-- images
|   |   |-- styles
|   |   |   |-- application-styles.scss
|   |-- components
|-- packs
|   |-- app.js
|   |-- application.js

I hope you followed along without too many issues, and you can start to see the flow of how the React SPA app is starting to come together. There are obviously many ways to organize your apps, but we found this pattern closely mimics Rails view patterns which allows us to stay in somewhat familiar territory.

You should now be able to load the page an after logging in, see the "Dashboard" heading with "App Pack" logged to the console. So far, So good.

Continue with the Dashboard

Next we're going to start plugging in components from the Blueprint.js library.

Add Blueprint.js navbar

Because the Navbar will be shared between all our Dashboard's pages, let's add it to the PageWrapper component so we'll only have to add it once.

Open app/frontend/app/components/PageWrapper/index.js and add a child component so PageWrapper looks like this:

import React from 'react';
import MainNavbar from '../MainNavbar'

const PageWrapper = (props) => (
  <div>
    <MainNavbar /> 
    { props.children }
  </div>
)

export default PageWrapper

As you can see, we're importing MainNavbar and adding it as a child to PageWrapper. This way it'll show up on all our pages.

Create app/frontend/app/components/MainNavbar/index.js with the content:

import React from 'react' 
import {                       
    Alignment,                 
    Button,
    Navbar,
    NavbarDivider,             
    NavbarGroup,               
    NavbarHeading,             
    Position                   
} from "@blueprintjs/core" 

import { Link } from 'react-router-dom'

class MainNavbar extends React.Component {

  render() {                   
    return(                    
      <Navbar className="bp3-dark">
        <Navbar.Group align={Alignment.LEFT}>
          <Navbar.Heading>Posty</Navbar.Heading>
          <Navbar.Divider />   
          <Link icon="home" to="/">Dashboard</Link>
          <Navbar.Divider />
          <Link to="/posts">Posts</Link>
        </Navbar.Group>        
        <Navbar.Group align={Alignment.RIGHT}>
          <Button className="bp3-minimal" icon="log-out" text="Logout" /> 
        </Navbar.Group>        
      </Navbar>                
    )
  }
} 

export default MainNavbar                     

This will import components neeed from blueprint.js and give us a basic navbar with a few options. Notice the Link import from react-router-dom, and the 2 links Dashboard and Posts

Issue with Client Side Routing

We already have our root route set up so the Dashboard link should just work, but the /posts route isn't so let's start there.

Before we go too far, we should address a common issue with Client Side Routing (CSR). This occurs when you follow a link inside the context of the SPA, which loads just fine, but then you try to refresh the page while on that route. You'll get a rails route error:

No route matches [GET] "/posts"

You can either use react-router's HashRouter which will add # to the URL, or define a catch all in the server config. We'll use the latter approach to keep our url's looking good.

Open config/routes.rb and add the catch-all route to the authenticated block so it will only apply to authed users:

  authenticated :user do       
    root 'app/dashboard#index' 
    match "*path", to: 'app/dashboard#index', via: :get 
  end

Now if you follow the link to /posts and refresh the page, the rails server points to our dashboard controller, and react-router is able to pick up the request and load the appropriate component.

Nicely done! Let's continue.

Configure Client Side Routes

Open app/frontend/app/components/App/index.js import a new PostsIndex component and add a Route for /posts:

...
import PostsIndex from '../Posts'

...
<BrowserRouter>
  <div>
    <Route exact path='/' render={() => ( 
      <PageWrapper>
        <Dashboard />
      </PageWrapper>
      )} />
    <Route path='/posts' render={() => ( 
      <PageWrapper>
        <PostsIndex />
      </PageWrapper>
      )} />
  </div>
</BrowserRouter>
...

As always, let's follow the errors and create the missing PostsIndex component:

Create app/frontend/app/components/Posts/index.js with this content:

import React from 'react'      

class PostsIndex extends React.Component {
  render() {
    return(                    
      <div>
        <h1>Posts</h1>         
      </div>
    ) 
  }   
}     
export default PostsIndex

Now if you use the navbar links you'll see the Headings change, and you'll be able to refresh the page without any routing issues.

Adding Logout from React

Because we're in the React SPA context, we can't use helpers from devise for a logout link. We'll need to make a few adjustments in the Rails controllers to allow external json requests.

Open app/controllers/application_controller.rb and add this line:

protect_from_forgery with: :null_session, only: Proc.new { |c| c.request.format.json? }

This will disable raising errors on json non-GET requests and allow us to use devise's sign_out path with the DELETE type.

Next, open app/frontend/app/components/MainNavBar/index.js

Add this import to a new Client component that will handle remote calls for us:

import client from '../../../common/client'

Then, add this method inside the React component class:

    handleLogout() {
      client.logout((data)=> { 
        window.location = "/"  
      })
    }

Finally update the Logout link to call this method onClick:

<Button className="bp3-minimal" icon="log-out" text="Logout" onClick={this.handleLogout} />

This obviously isn't going to work yet, and to follow the errors, let's add our Client utility:

Create app/frontend/common/client/index.js with this content:

function logout(cb) { 
  return fetch('/users/sign_out', { 
    method: 'delete',
    headers: { 
      "Content-Type": "application/json; charset=utf-8",
    } 
  }).then(checkStatus)
    .then(cb);
}

function checkStatus(response) { 
  if (response.status >= 200 && response.status < 300) { 
    return response;
  } 
  const error = new Error(`HTTP Error ${response.statusText}`);
  error.status = response.statusText;
  error.response = response;
  console.log(error);
  throw error;
}

function parseJSON(response) { 
  return response.json();
}

const client = { logout };
export default client;

Now if everything worked out ok, you should be able to send DELETE requests to devise's signout endpoint to clear the session and log the user out.

Wrapping up

We've made some good progress in our application and solved some common blockers along the way. Here's what we've accomplished:

  • Added root element to Dashboard rails view for React to connect to.
  • Configured app pack to render on the root element
  • Built App and PageWrapper components to handle and render presentation components.
  • Added react-router to route requests
  • Added Blueprint.js navbar
  • Configured basic client react-router routes
  • Added logout feature

Here's the repo that holds all the source code, each part has it's own tag, so you can use git checkout tag/part-3 to get it running:
https://github.com/devato/rails-webpacker-react/tree/part-3

In the next part we're going to start building our Rails API, and adding CRUD functionality to our system. Stay tuned!

Series: