june-logo
June's logo
Customers
Pricing
Changelog
Ferruccio BalestreriCTO and Co-Founder at June

12 Aug 23

Let's make Rails and React play nicely together

The DHH party-line on front-end interactivity is that most web apps don't need flashy front-end frameworks like React or Vue.

The Rails way? Use Rails' unobtrusive JavaScript controllers to spice things up. Need more complex tools? Build in Javascript (think rich text editing with Trix) and leave the HTML rendering to Rails.

That's the heavier machinery DHH endorsed.

Today's SaaS applications beg to differ. Users want action, interactivity. Everything's alive. Drag, drop, undo, redo, collaborate.

Build something like Linear or Figma in 2023? You need a front-end framework. Today's web is made of apps, not documents.

Try signing up for Basecamp and compare the user experience with a modern app like Campsite, and you'll see what I mean. Both are built with Rails, but Campsite uses React on the frontend and Basecamp doesn't.

We must make Rails and React friends.

The problem with the React community

Messy. Too many libraries, ways, names. Major libraries die every few years. Chaos.

Frameworks rise on frameworks, only to die. A business can't rebuild every two years.

Chakra-UI's a perfect mess. It was built on Emotion, Styled Components, and React. Now, migrating to three more frameworks. All for a UI component library. Too complicated.

React Router's been rewritten thrice in five years. Create-react-app? Deprecated. Chaos.

What's Great About Rails

Rails is old and wise. Fifteen years, stable, clear. No needless changes. Build your business, not your framework. Pragmatic.

A Rails approach to React

As someone who has been building Rails apps with Javascript front-ends for the last 5 years, I've been thinking a lot about how to make Rails and React play nicely together.

Today the opinionated and magic development experience ends where the React code consumes your Rails API.

Screenshot-2023-08-12-at-15.37.15

I think the place to start with to make the Rails+React development experience better is the data-syncing layer.

Making Rails controllers and React app work together is painful. I've tried a lot of approaches, but any time I'm adding a new endpoint to my Rails app I have to write a hundred lines of boilerplate code just to be able to load the data in my React app.

Additionally, my tests have a lot of duplication. I have to maintain factories for my Rails app, factories for my React app, and Typescript types for my React app. This is a lot of duplication and boilerplate code.

The testing experience of data coming from the API in React is brittle, there's a lot of boilerplate code that can break easily.

Screenshot-2023-08-12-at-15.37.24

So how would this even look like? Let's try and explore a possible solution.

Rails controllers

In Rails we have a controller method that looks like this:


def show
	@user = User.find(params[:id])
end

This method than has a corresponding view file that looks like this:


render partial: 'user', locals: { user: @user }

The user partial looks like this:

json.user do
	json.id user.id
	json.first_name user.first_name
	json.email user.email
	json.is_admin user.is_admin
end

Then there's routes file that looks like this:

resources :users, only: [:show]

And a factory that looks like this:

FactoryBot.define do
	factory :user do
		first_name { Faker::Name.name }
		email { Faker::Internet.email }
		is_admin { false }

		trait :admin do
			is_admin { true }
		end
	end
end

And a test that looks like this:


describe 'GET /users/:id' do
	let(:user) { create(:user) }

	it 'returns the user' do
		get :show, params: { id: user.id }
		expect(response.body).to eq(user.to_json)
	end

	context 'when the user is an admin' do
		let(:user) { create(:user, :admin) }

		it 'returns the admin user' do
			get :show, params: { id: user.id }
			expect(response.body).to eq(user.to_json)
		end
	end

	context 'when the user does not exist' do
		it 'returns a 404' do
			get :show, params: { id: 123 }
			expect(response.status).to eq(404)
		end
	end
end

Now that I have an endpoint in my Rails app, I need to add a corresponding endpoint in my React app. This is where things get messy.

React data-fetching

First I need to define the Typescript types for the user object:

export interface User {
	id: number
	firstName: string
	email: string
	isAdmin: boolean
}

Then I need to define a factory for the user object:


export const userFactory = (overrides?: Partial<User>): User => ({
	id: 1,
	firstName: 'John Doe',
	email: 'john@june.so',
	isAdmin: false,
	...overrides
})

Then because I'm using Redux-Toolkit I need to define a createApi for the user object, and add it to the store:

export const userApi = createApi({
	reducerPath: 'userApi',
	baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
	tagTypes: ['User'],
	endpoints: (builder) => ({
		getUser: builder.query<User, number>({
			query: (id) => `/users/${id}`,
			providesTags: ['Auth'],
			// Because the Rails API returns snake_case keys, we need to
			// transform the response to camelCase
			transformResponse: (response: IUser): IUser =>
        humps.camelizeKeys(response) as IUser,
		}),
	}),
})

Then I need to add the userApi to the store:


export const store = configureStore({
	reducer: {
		[userApi.reducerPath]: userApi.reducer,
	},
	middleware: (getDefaultMiddleware) =>
		getDefaultMiddleware().concat(authApi.middleware, userApi.middleware),
})

Now I can use the userApi in my React components:

const { data: user } = useGetUserQuery(1)

Now I need to add a test for the userApi:

describe('userApi', () => {
	describe('getUser', () => {
		it('returns the user', async () => {
			const user = userFactory()
			server.use(
				rest.get('/api/users/1', (req, res, ctx) => {
					return res(ctx.json(user))
				})
			)

			const result = await store.dispatch(getUser(1))

			expect(result.data).toEqual(user)
		})
	})
})

Finally I need to add tests for my React components:


describe('User', () => {
	it('renders the user', () => {
		const user = userFactory()
		render(<User user={user} />)

		expect(screen.getByText(user.firstName)).toBeInTheDocument()
	})

	it('renders the admin badge', () => {
		const user = userFactory({ isAdmin: true })
		render(<User user={user} />)

		expect(screen.getByText('Admin')).toBeInTheDocument()
	})
})

What if we added some Rails magic to React?

What if from our Rails controllers and views we could automatically generate Typescript types, factories, and hooks for our React app?

This automatically generated code, would be automatically kept in sync with our Rails app, so any time we change our endpoints in our Rails app, our React app would automatically be updated. This would automatically keep our tests in sync and allow us to find bugs in our React app before they make it to production.

The changes required to our Rails code would need to be:

  • Add type annotations for endpoint parameters
  • Add type annotation to all responses

Type annotations for endpoint parameters

# Types for params and instance variables
class UsersController
	sig { void }
	def show
		@user = Player.find(user_params[:id])
	end

	private

	sig { returns({ id: Integer }) }
	def user_params
		params.permit(:id).transform_values(&:to_i)
		# Ideally id would get typecasted to an integer
		# As it's super ugly to do transform_values(&:to_i)
	end
end

Add type annotation to all responses

The view files would need to be annotated, so that we can generate Typescript types for them. I think a good standard to adopt would be OpenAPI.

I haven't figured out exactly how the syntax would work, but it would look something like this:


# app/views/users/show.json.jbuilder
# The view stays the same
json.id @user.id
json.name @user.name
json.email @user.email
json.is_admin @user.is_admin

But our tests would define the types for each of the possible responses:

# spec/integration/users_spec.rb

require 'swagger_helper'

RSpec.describe 'User API', type: :request do
  path '/user/{user_id}' do
    get 'Retrieve a user' do
      tags 'Users'
      produces 'application/json'
      parameter name: :user_id, in: :path, type: :integer, required: true

      response '200', 'A single user object' do
        schema type: :object, properties: {
          id: { type: :integer },
          name: { type: :string },
          email: { type: :string },
					is_admin: { type: :boolean }
        }
      end
    end
  end
end

The factories could probably stay unchanged.

Moving to the React app

Now on the React side, what can we do to reduce the amount of boilerplate code we need to write?

Typescript types for responses

The typescript types for responses can be generated from the OpenAPI spec.

Much like when developing with Prisma, we can generate the typescript types from the OpenAPI spec.

Automatically generating hooks

From knowing the resources in our Rails app, we could automatically generate hooks or classes for each of the resources.


// The @rails/resource package would automatically generate hooks for each resource
import { useShowUserQuery } from '@rails/resource'

// Automatically generated hook
const {user, isLoading, errors} = useShowUserQuery(id: number)

// Or an Ember JS style automatically generated class for each resource
const user = User.find(id: number)

It should be possible to make all the parameters for the hooks type safe, because we know the types of the parameters from the OpenAPI spec.

Ideally the Rails resource package would also abstract away the need to directly use the Redux store, so that we can just use the hooks directly, and not have to worry about the Redux store.

Automatically generating factories

For each response type of the OpenAPI spec, we could automatically generate a factory.

// Automatically generated factory for each response type
server.use(showUser('200'))

// Maybe there's a way to support traits like in factory_bot
server.use(showUser('200', { isAdmin: true }))

Closing thoughts

This is just a rough idea of what is possible. I'm sure there are many things I haven't thought of, and many details I haven't worked out.

That being said before jumping into coding, I wanted to get some feedback on the idea, and see if anyone else has tried something similar.

I know many companies are using Rails and React together, and I think this could be a great way to make the development experience even better. If we could spend less time writing boilerplate code to keep our React app in sync with our Rails app. Compressing the complexity of the data-fetching layer of our apps. Making it as easy to display data from our Rails app in a React app, as it is to display data from our controllers in our .erb views.

I'd love it if a year from now, we could have a package like the one I described above, that is used by many companies, and that holds the same promise of Rails itself. That is, it's a framework that allows us to move fast, and build great products.

Call for contributions

I'd love to hear your thoughts, and maybe if enough people are interested we could work on this together. At June we're strongly considering spearheading this project.

What we think though is that this will only be successful if there's broader adoption, so I'd love to work together with other companies in shaping a solution that we can maintain until the end of the internet.

If you'd like to work on this together, or help fund this project with a contribution, please reach out to me at ferruccio@june.so.

Continue reading

Or back to June.so
Get on board in 30 mins
We're here to help with your journey to PMF. Set up June
in under 30 minutes and get our Pro plan free for a month.
Get started
Arrow-secondary
Barcode
WTF
Plane
PMF
FROMWhat the f***
TOProduct Market Fit
DEPARTURE