
Rails 8, Inertia e React: Construindo um Admin em Tempo Real Sem Sair do Monolito
Construindo um painel admin em Rails 8 com Inertia e React em vez de Hotwire: os trade-offs, a arquitetura e um tutorial prático.
Há algumas semanas construí uma aplicação pequena, mas completa, como parte de uma avaliação técnica: um sistema de gestão de usuários com dashboard de admin, contadores ao vivo, gerenciamento de papéis e um importador de planilhas em background com barra de progresso em tempo real. O enunciado deixava uma grande decisão em aberto. O frontend poderia ser Hotwire, que é o padrão do Rails, ou React integrado via Inertia.js.
Escolhi Inertia e React. Este post explica o porquê, quanto isso me custou, como as peças se encaixam, e termina com um tutorial que leva você de uma app Rails 8 vazia a uma página atualizada ao vivo em cerca de uma hora.
O enunciado
Os requisitos foram desenhados de propósito para parecer uma sprint real:
- Rails 8 com um Ruby recente, PostgreSQL e o trio Solid para cache, fila e cable. Sem Redis.
- O gerador de autenticação nativo do Rails 8, estendido com os papéis admin e member.
- Um dashboard de admin mostrando o total de usuários e a distribuição por papel, atualizado em tempo real.
- CRUD completo de usuários para admins, edição do próprio perfil para members e cadastro público para visitantes.
- Importação de planilhas (CSV e XLSX) processada de forma assíncrona, com progresso ao vivo na tela.
- Um Dockerfile multi-stage, um arquivo de deploy do Kamal, testes em paralelo com pelo menos 90 por cento de cobertura e uma configuração de linter rigorosa.
Cada item é rotineiro isoladamente. Juntos, eles forçam você a tomar decisões de arquitetura cedo, porque as partes de tempo real e o job em background tocam em todo o resto.
Por que Inertia e React em vez de Hotwire
Eu gosto de Hotwire. Para a maioria das apps Rails ele é o padrão certo, e quero deixar claro que teria funcionado aqui. Mas três partes desse enunciado me empurraram para o outro lado.
Formulários com validação interativa. O enunciado pedia feedback no frontend enquanto o usuário digita, além da validação no servidor. Com Stimulus você acaba escrevendo um controller de validação por formulário, ou um genérico que lê data attributes, e de qualquer forma está reimplementando uma pequena máquina de estados em JavaScript puro. Com React o formulário já é estado. Um hook que roda regras no blur e no change, e mescla seus erros com os do servidor, tem cerca de 50 linhas e funciona para todos os formulários da app.
Uma barra de progresso guiada por eventos de WebSocket. Turbo Streams consegue atualizar uma barra de progresso substituindo uma partial a cada broadcast. Funciona, mas cada broadcast carrega HTML e o servidor precisa renderizá-lo. Eu queria que o broadcast fosse um sinal mínimo e que o cliente buscasse de novo apenas o que mudou. Os partial reloads do Inertia fazem exatamente isso: um broadcast diz "esta importação mudou", a página chama router.reload({ only: ['import'] }) e uma única prop é reavaliada no servidor.
TypeScript atravessando a fronteira. Com Inertia o controller entrega props para o componente da página. Tipar essas props significa que o compilador pega um campo renomeado antes de um teste de sistema. Hotwire não tem uma fronteira equivalente para tipar.
Também havia um motivo pessoal. Eu ainda não tinha colocado Inertia em produção, e uma avaliação com escopo claro é um bom lugar para aprender uma ferramenta direito, em vez de aprender pela metade sob pressão de produção.
O que o Inertia realmente é
Se você nunca usou, o modelo mental é curto. Inertia não é uma camada de API. Seus controllers continuam sendo controllers Rails, suas rotas continuam sendo rotas Rails e seus redirects continuam sendo redirects. A única mudança é que, em vez de renderizar um template ERB, o controller renderiza um componente de página nomeado e passa para ele um hash de props:
def index
render inertia: "Admin/Users/Index", props: {
users: UserSerializer.collection(search.records),
filters: search.to_props
}
end
A primeira requisição retorna uma página HTML completa com as props embutidas. Toda navegação depois disso é um XHR que retorna apenas as props da próxima página em JSON, e o Inertia troca o componente. Você tem a sensação de single-page app sem router no cliente, sem versionamento de API e sem lógica de autenticação duplicada.
Os contras, com honestidade
- Dois sistemas de build. O Vite fica ao lado do Bundler. O Dockerfile precisa de Node no estágio de build, e seu CI roda
npm cietscalém do RSpec. Hotwire com import maps não precisa de nada disso. - Server-side rendering é um processo separado. Sem SSR, o primeiro paint é uma div vazia até o React inicializar. Com SSR você roda um pequeno servidor Node ao lado do Puma. É administrável, e explico abaixo, mas é mais uma coisa para supervisionar.
- A dança dos redirects. O Inertia espera que um POST com falha redirecione de volta com os erros na sessão, e não que renderize uma página 422. Depois que você sabe disso é uma linha de código, mas me surpreendeu por uma tarde.
- Você vai escrever uma camada de serializers. Props são JSON, então todo model que chega a uma página precisa de um formato explícito. Considero isso uma vantagem, porque evita vazamento acidental de colunas, mas é código a mais.
- Hotwire é o caminho de menor resistência no Rails. Geradores, documentação e a maioria dos posts de blog assumem Turbo. Escolher Inertia significa ler a documentação dele e, de vez em quando, traduzir.
Se sua app é majoritariamente de páginas renderizadas no servidor com pouca interatividade, Hotwire vence em simplicidade. Se você tem estado real no cliente, formulários que precisam parecer instantâneos ou um time que já pensa em React, o Inertia entrega isso sem abrir mão do monolito.
Características da implementação
Veja como a app está estruturada. Estou descrevendo padrões em vez de colar o código-fonte completo, porque são os padrões que se transferem para o seu projeto.
Controllers enxutos, policies explícitas
A autorização é um pequeno objeto de policy por model, inspirado no Pundit, mas escrito à mão em cerca de 30 linhas. Um concern no controller base expõe authorize!(record) e policy_for(record). A policy também é dona de duas coisas que costumam ficar espalhadas:
- Os atributos permitidos. Os strong params chamam
params.expect(user: policy.permitted_attributes), então é a policy que decide serolepode ser enviado. Um member não consegue se promover editando a requisição. - O escopo. Admins recebem
User.all. Members recebemUser.where(id: current_user.id). Todo controller busca registros por esse escopo, então um member que pede a página de edição de outra pessoa recebe um 404, e não um erro de permissão que confirma que o registro existe.
Um serializer por model
Cada model que chega ao frontend tem um serializer em Ruby puro com um método as_json e um método de classe collection. O serializer de usuário também é onde a URL da variante do avatar é montada, então o componente React recebe uma URL de imagem pronta para uso e não sabe nada sobre Active Storage.
Tempo real sem tempestade de broadcasts
Os contadores do dashboard são a parte interessante. A abordagem ingênua faz um broadcast depois de cada commit de usuário. Durante uma importação de dez mil linhas, isso significa dez mil broadcasts e dez mil refetches no cliente.
A implementação tem três camadas de proteção:
- Um broadcaster com debounce. Ele reivindica uma chave de cache com
unless_exist: true, o que é atômico entre os workers do Puma e o processo de jobs, porque o cache é Solid Cache no Postgres. O primeiro chamador numa janela de um segundo faz o broadcast na hora. Os chamadores seguintes agendam um único job de fechamento, para que o estado final nunca se perca. - Uma chave de supressão para operações em lote. O job de importação envolve seu loop num bloco que liga uma flag em
ActiveSupport::IsolatedExecutionState. Os callbacks do model checam essa flag e ficam em silêncio. O job faz um único broadcast ao terminar. - Coalescência no cliente. O hook React que assina o canal espera 250 milissegundos antes de buscar de novo, então uma rajada de sinais vira uma única requisição.
O payload do broadcast em si não carrega dados, só um tipo. O cliente busca de novo pelo controller normal, o que significa que as estatísticas sempre passam pela autorização e pelo serializer. Um cliente nunca recebe números que não tem permissão para ver.
Um job de importação retomável
A importação roda como um job do Solid Queue usando ActiveJob::Continuable, que é a forma do Rails 8.1 de escrever jobs com checkpoints. O job tem três etapas: contar as linhas, importá-las e finalizar. A etapa de importação mantém um cursor e o avança a cada cem linhas. Se o worker for morto, o job reinicia a partir do cursor e pula as linhas que já foram commitadas, então nada é criado duas vezes.
Alguns detalhes que importam na prática:
- O parsing de cada linha é um pequeno objeto
ActiveModel. Ele normaliza os nomes dos cabeçalhos, remove caracteres que transformariam uma célula numa fórmula de planilha ao reexportar e valida antes de qualquer coisa tocar o banco. - CSV e XLSX são duas classes atrás de uma única factory, ambas
Enumerable. O job não sabe qual formato está lendo. - Os erros por linha vão para uma coluna
jsonb, limitada a algumas centenas de entradas, para que um arquivo muito quebrado não infle o registro. - E-mails já existentes são pulados em vez de contarem como falha, e uma corrida no índice único com um cadastro concorrente também é tratada como pulo.
SSR que vai para produção sem node_modules
O Vite gera o bundle de SSR com noExternal: true, então React e Inertia ficam embutidos num único arquivo JavaScript. A imagem de produção copia apenas o binário do Node, não o node_modules, e um plugin do Puma sobe o servidor de SSR ao lado do processo web. Tudo é controlado por uma única variável de ambiente e cai para renderização no cliente se o bundle não existir.
Testes que garantem o nível
RSpec com parallel_tests, um banco por worker, e SimpleCov falhando a execução abaixo de 90 por cento de cobertura de linhas. Os specs de sistema usam Capybara com o driver do Playwright e cobrem de ponta a ponta as partes que importam: o dashboard ao vivo, o fluxo de importação, a validação no cliente, o layout responsivo e a hidratação do SSR.
Tutorial: do zero a uma página atualizada ao vivo
Isto leva você a uma app Rails 8 funcionando com Inertia, React, TypeScript, Tailwind e uma página cujo contador se atualiza via Solid Cable. Assumo Ruby 3.4 ou mais recente, Node 22 ou mais recente e PostgreSQL rodando localmente.
1. Crie a app
rails new live_admin --database=postgresql --skip-hotwire --skip-jbuilder
cd live_admin
bin/rails db:create
Pular o Hotwire tira Turbo e Stimulus do caminho. As gems Solid já vêm no Gemfile do Rails 8.
2. Adicione Vite, Inertia e React
bundle add vite_rails inertia_rails
bundle exec vite install
npm install react react-dom @inertiajs/react @inertiajs/vite @vitejs/plugin-react tailwindcss @tailwindcss/vite
npm install -D typescript @types/react @types/react-dom
Substitua o vite.config.ts gerado:
import { defineConfig } from 'vite'
import RubyPlugin from 'vite-plugin-ruby'
import react from '@vitejs/plugin-react'
import inertia from '@inertiajs/vite'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [tailwindcss(), RubyPlugin(), inertia(), react()],
})
Crie o entrypoint em app/javascript/entrypoints/inertia.tsx:
import { createInertiaApp } from '@inertiajs/react'
import { createRoot } from 'react-dom/client'
createInertiaApp({
resolve: (name) => {
const pages = import.meta.glob('../pages/**/*.tsx', { eager: true })
return pages[`../pages/${name}.tsx`]
},
setup({ el, App, props }) {
createRoot(el).render(<App {...props} />)
},
})
E uma folha de estilos em app/javascript/entrypoints/application.css:
@import "tailwindcss";
Atualize o layout para que a página do Inertia seja renderizada num elemento raiz:
<!DOCTYPE html>
<html>
<head>
<%= csrf_meta_tags %>
<%= vite_client_tag %>
<%= vite_stylesheet_tag "application" %>
<%= vite_typescript_tag "inertia" %>
<%= inertia_headers %>
</head>
<body>
<%= yield %>
</body>
</html>
3. Renderize sua primeira página
# config/routes.rb
root "dashboard#show"
# app/controllers/dashboard_controller.rb
class DashboardController < ApplicationController
def show
render inertia: "Dashboard", props: { stats: -> { DashboardStats.new.to_h } }
end
end
A lambda transforma stats numa prop lazy. O Inertia a avalia na primeira visita e em qualquer partial reload que a mencione, e a ignora nos demais casos.
# app/queries/dashboard_stats.rb
class DashboardStats
def to_h
counts = User.group(:role).count
{ total: counts.values.sum, by_role: counts }
end
end
// app/javascript/pages/Dashboard.tsx
type Props = { stats: { total: number; by_role: Record<string, number> } }
export default function Dashboard({ stats }: Props) {
return (
<main className="mx-auto max-w-3xl p-6">
<h1 className="text-2xl font-semibold">Dashboard</h1>
<p className="mt-4 text-5xl">{stats.total}</p>
<ul className="mt-2 text-sm text-gray-600">
{Object.entries(stats.by_role).map(([role, n]) => (
<li key={role}>{role}: {n}</li>
))}
</ul>
</main>
)
}
Suba tudo com bin/dev e abra a URL raiz. Você tem uma página React renderizada por um controller Rails sem nenhuma API no meio.
4. Configure o Solid Cable
O Solid Cable é o adapter padrão do Action Cable no Rails 8, então não há nada para instalar. Crie um canal que transmite apenas para usuários autenticados:
# app/channels/dashboard_channel.rb
class DashboardChannel < ApplicationCable::Channel
def subscribed
stream_from "dashboard:stats"
end
end
Adicione um broadcaster e chame-o a partir do model:
# app/broadcasters/dashboard_broadcaster.rb
class DashboardBroadcaster
STREAM = "dashboard:stats"
WINDOW = 1.second
def self.call
return unless Rails.cache.write("dashboard:lead", true, expires_in: WINDOW, unless_exist: true)
ActionCable.server.broadcast(STREAM, { type: "stats.changed" })
end
end
# app/models/user.rb
class User < ApplicationRecord
after_commit -> { DashboardBroadcaster.call }, on: %i[create destroy]
end
Esta é a metade leading-edge do debounce. Basta para um tutorial. Em produção você também quer um broadcast de fechamento (trailing), para que a última mudança de uma rajada nunca se perca, o que é um job de uma linha agendado com set(wait: WINDOW).
5. Assine o canal no React
Instale o cliente do Action Cable:
npm install @rails/actioncable
Crie um único consumer compartilhado para que cada componente não abra seu próprio socket:
// app/javascript/lib/cable.ts
import { createConsumer, type Consumer } from '@rails/actioncable'
let consumer: Consumer | null = null
export const getConsumer = () => (consumer ??= createConsumer())
Depois, um hook que escuta o canal e pede ao Inertia para buscar de novo apenas a prop stats:
// app/javascript/hooks/useDashboardStream.ts
import { useEffect } from 'react'
import { router } from '@inertiajs/react'
import { getConsumer } from '@/lib/cable'
export function useDashboardStream() {
useEffect(() => {
const refresh = () => router.reload({ only: ['stats'] })
const sub = getConsumer().subscriptions.create(
{ channel: 'DashboardChannel' },
{ connected: refresh, received: refresh },
)
return () => sub.unsubscribe()
}, [])
}
Chame-o no topo do componente Dashboard. Abra a página numa aba, crie um usuário num console Rails em outro terminal e veja o número mudar.
6. Um formulário com validação ao vivo
O Inertia traz um hook useForm. Combine-o com um pequeno objeto de regras e você tem validação que roda no blur e no submit, com os erros do servidor caindo no mesmo lugar:
import { useForm } from '@inertiajs/react'
const rules = {
full_name: (v: string) => (v.trim().length < 2 ? 'Name is too short' : null),
email_address: (v: string) => (/^\S+@\S+\.\S+$/.test(v) ? null : 'Enter a valid email'),
}
export default function Register() {
const form = useForm({ full_name: '', email_address: '', password: '' })
const check = (field: keyof typeof rules) => {
const message = rules[field](form.data[field])
message ? form.setError(field, message) : form.clearErrors(field)
}
return (
<form onSubmit={(e) => { e.preventDefault(); form.post('/registration') }}>
<input
value={form.data.full_name}
onChange={(e) => form.setData('full_name', e.target.value)}
onBlur={() => check('full_name')}
/>
{form.errors.full_name && <p role="alert">{form.errors.full_name}</p>}
{/* repeat for the other fields */}
<button disabled={form.processing}>Create account</button>
</form>
)
}
No lado do Rails, um create com falha redireciona de volta com o hash de erros, e o Inertia o mescla em form.errors:
def create
user = User.new(params.expect(user: %i[full_name email_address password]))
if user.save
redirect_to root_path, notice: "Welcome"
else
redirect_to new_registration_path, inertia: { errors: user.errors }
end
end
7. Um job em background com progresso
Por fim, o esqueleto de um job retomável. Substitua o corpo pelo seu próprio processamento de linhas:
class ProcessImportJob < ApplicationJob
include ActiveJob::Continuable
def perform(import)
step :import_rows, start: 0 do |step|
import.rows.each_with_index do |row, index|
next if index < step.cursor
process(row)
if index % 100 == 0
import.update_columns(processed_rows: index)
ImportBroadcaster.call(import)
step.set! index
end
end
end
step :finalize do
import.update!(status: :completed)
ImportBroadcaster.call(import)
end
end
end
A página que mostra a importação assina um stream por importação e chama router.reload({ only: ['import'] }) a cada sinal, exatamente como o dashboard. O Solid Queue roda o job com bin/jobs, e o bin/dev sobe esse processo para você se você adicioná-lo ao Procfile.
Considerações finais
O Inertia não substitui o Hotwire. É uma resposta diferente para a mesma pergunta: como manter o monolito e ao mesmo tempo dar ao frontend estado de verdade. Para esta app, com seus formulários, contadores ao vivo e barras de progresso, React por trás do Inertia gerou menos código, e código mais claro, do que imagino que o Stimulus geraria, e manteve cada controller, rota e redirect onde um desenvolvedor Rails espera encontrá-los.
Os custos são reais. Você mantém um toolchain Node, pensa em SSR e escreve serializers. Se isso soa como coisas que você já faz, o Inertia vai parecer natural. Se soa como overhead, Hotwire continua sendo a escolha certa, e o Rails 8 faz dele uma escolha excelente.
Comentários
Faça login com Google ou GitHub para comentar.