Uso de cookies em comunidade.academiadoruby.com.br

Utilizamos cookies para melhorar sua experiência. Você pode aceitar ou recusar o uso de cookies não essenciais. Sua escolha ficará salva por 6 meses. Saiba mais em Política de Privacidade · Política de Cookies

  1. Conteúdos
  2. Hotwire

Infinite scroll no Rails sem uma linha de JavaScript

Daniel Denis Moreira

· 9 min de leitura

Infinite scroll no Rails sem uma linha de JavaScript

Talvez, toda vez que você abre um tutorial de infinite scroll é aquela mesma novela: um Stimulus controller, IntersectionObserver, fetch manual, parsing de resposta no JS, manipulação de DOM. Tem até spinner a ser inserido dentro desse emaranho que se tornou seu JavaScript. É aquele código que você copia do Stack Overflow, ou do GPT, cola no projeto e torce pra funcionar em qualquer tipo de dispositivo.

Pode parecer uma propaganda da TV Shop, mas seus problemas acabaram! Você não precisa de nada disso.

O Turbo já resolve isso nativamente. O Pagy, a gem para paginação que considero uma das melhores escolhas atualmente, entrega os helpers que você vai precisar prontos. Sua implementação inteira apenas em três arquivos, sem uma linha de JavaScript.

Atualmente estou usando esse padrão na plataforma da Academia do Ruby em diversos lugares: lista de posts, categorias, trilhas, canais, busca global e em praticamente qualquer CRUD da área do admin. O mesmo código em todos sem precisar de qualquer tipo de hotfix.

A receita "JavaScript-first" que envelheceu mal

Mais ou menos assim:

  1. 1

    Sentinela observado por Stimulus

    Stimulus observa um elemento sentinela no fim da lista.

  2. 2

    Sentinela na viewport dispara fetch

    Quando o sentinela aparece na viewport, dispara fetch.

  3. 3

    Parse e injeção manual no DOM

    O retorno é parseado e injetado no DOM manualmente.

  4. 4

    Estados gerenciados na mão

    Você precisa gerenciar qualquer evento como "carregando", "última página", "erro".

  5. 5

    Cada filtro novo é mais um parâmetro

    Pra cada filtro novo, mais um parâmetro extra no fetch.

Funciona, mas é muito código pra um problema que, do ponto de vista do Rails, é bobo: renderizar a próxima página quando o usuário rola até o fim. O framework já resolveu isso!

As quatro peças

1. turbo_frame_tag com loading: :lazy

Um Turbo Frame com src + loading: :lazy só dispara o fetch quando entra na viewport. O navegador cuida do IntersectionObserver internamente.

<%= turbo_frame_tag "pagination",
      src: posts_path(page: 2, format: :turbo_stream),
      loading: :lazy %>

O frame fica vazio no DOM. O usuário rola até ele e o Turbo dispara. É a mesma técnica do <img loading="lazy"> que os navegadores fazem há anos, só que aplicada num pedaço de HTML.

2. format: :turbo_stream no src

O src aponta pra mesma action do controller, com format: :turbo_stream. Isso faz o Rails procurar a view .turbo_stream.erb em vez da .html.erb. Não tem rota nova, não tem endpoint JSON dedicado. Você reaproveita o que já existe.

3. Append + replace

Aqui mora o truque. A view .turbo_stream.erb faz duas coisas: adiciona os itens novos ao final lista (append) e substitui o frame de paginação por um novo, já apontando pra próxima página.

O frame substituto vem com loading: :lazy de novo. O usuário chega no fim, o ciclo repete. Quando acaba a paginação, o frame não é renderizado e o scroll para sozinho.

4. @pagy.next

@pagy.next retorna nil quando não há próxima página. Um if @pagy.next resolve a parada. Sem flag de "fim", sem estado intermediário.

Os três arquivos

Gemfile

gem "pagy", "~> 43.1"

Controller

class PostsController < ApplicationController
  include Pagy::Backend

  def index
    @pagy, @posts = pagy(Post.published.recent, limit: 12)

    respond_to do |format|
      format.html
      format.turbo_stream
    end
  end
end

O respond_to aqui é obrigatório. Sem ele o Rails não sabe que existe .turbo_stream.erb pra renderizar quando o frame fizer o fetch. (É a fonte mais comum de "não tá funcionando aqui" na hora de aplicar isso.)

app/views/posts/index.html.erb

<div id="posts-list">
  <%= render @posts %>
</div>

<%= turbo_frame_tag "pagination",
      src: posts_path(page: @pagy.next, format: :turbo_stream),
      loading: :lazy if @pagy.next %>

Um container com id fixo pra receber os appends. Um frame de paginação que só aparece se houver próxima página.

app/views/posts/index.turbo_stream.erb

<%= turbo_stream.append "posts-list" do %>
  <%= render @posts %>
<% end %>

<%= turbo_stream.replace "pagination" do %>
  <%= turbo_frame_tag "pagination",
        src: posts_path(page: @pagy.next, format: :turbo_stream),
        loading: :lazy if @pagy.next %>
<% end %>

Appenda os posts no container, substitui o frame de paginação pelo próximo. Quando @pagy.next for nil, o bloco renderiza um frame vazio e a coisa para.

É isso. Production-ready.

A vida real: filtros, busca, URL no histórico

A parte que os tutoriais costumam pular é o que acontece quando a página tem filtros. A ingenuidade é assumir que posts_path(page: 2) resolve. E os outros params? Sumiram.

A correção é mesclar os parâmetros atuais com o número da próxima página:

<%= turbo_frame_tag "pagination",
      src: posts_path(request.query_parameters.merge(page: @pagy.next, format: :turbo_stream)),
      loading: :lazy if @pagy.next %>

request.query_parameters pega tudo que veio na URL e repassa. Categoria, ordenação, termo de busca, o que for.

URL no histórico

Por padrão, o Turbo Frame faz fetch sem mexer na URL do navegador. Pra que o histórico funcione (back/forward, link compartilhável, scroll restoration), envolve a lista num frame externo com data-turbo-action: "advance":

<%= turbo_frame_tag "posts-frame", data: { turbo_action: "advance" } do %>
  <div id="posts-list">
    <%= render @posts %>
  </div>

  <%= turbo_frame_tag "pagination",
        src: posts_path(request.query_parameters.merge(page: @pagy.next, format: :turbo_stream)),
        loading: :lazy if @pagy.next %>
<% end %>

A cada fetch do frame interno, o Turbo avança a URL pra incluir ?page=2, ?page=3. Botão de voltar do navegador faz o que se espera. Link compartilhável também.

Próximos passos

O padrão acima resolve 90% dos casos, mas tem dois problemas que aparecem quando a lista cresce ou muda. Vale ter no radar pra quando bater.

Listas que mudam durante o scroll. Paginação por offset (page=2, page=3) parte do princípio de que a lista é estável entre as requisições. Se um post novo entra no topo enquanto o usuário rola, page=2 vai trazer um item que ele já viu na page=1 — ou pior, pular um. Em feed cronológico decrescente isso aparece rápido. A saída é cursor pagination: o cliente envia "me dá o que vem depois do item X" em vez de "me dá a página 2". O Pagy 43 tem Pagy::Keyset com adapter pra ActiveRecord, e a API encaixa no mesmo template — só muda o que vai no src do frame (cursor opaco em vez de número de página).

N+1 escondido na próxima página. Como cada página vem em um request separado, o Bullet (e o seu olho na review) costumam pegar o N+1 da primeira página e considerar resolvido. Mas o includes precisa estar no scope que entra no pagy(...), não no controller depois. Vale rodar a app com Bullet ligado e rolar até o fim de pelo menos uma lista grande antes de aceitar como pronto.

Cobrir keyset com profundidade fica pra um outro post — keyset é assunto que merece o seu próprio fluxo de cursor, encoding, ordenação composta. O ponto aqui é só: quando você sentir o problema, sabe pra onde olhar.

Gotchas

respond_to no controller é obrigatório. Já falei, mas é a fonte mais comum de bug aqui. Sem ele, o Rails cai no HTML padrão e quebra tudo.

O id do container precisa ser único na página. Se tem duas listas na mesma view (tracks + posts, por exemplo), usa ids distintos. Senão o append vai parar na lista errada.

loading: :lazy dispara se o frame já estiver visível no load. Se a primeira página tem poucos itens e o frame de paginação aparece sem precisar rolar, o Turbo busca imediatamente. Comportamento correto, mas pode surpreender se você espera que ele só dispare quando o usuário rolar.

Sem @pagy.next, não renderize o frame. O if @pagy.next no final é crítico. Se omitir, o frame fica no DOM com src apontando pra uma página que não existe e o Turbo gera requisição em vão.

Fechando

app/
  controllers/
    posts_controller.rb         → pagy() + respond_to turbo_stream
  views/posts/
    index.html.erb              → container#posts-list + frame#pagination (lazy)
    index.turbo_stream.erb      → append items + replace pagination frame

Essa é solução. Filtros, busca, histórico de URL — tudo funcionando, sem JavaScript escrito por você.

A primeira vez que vi alguém fazendo dessa forma eu pensei "não, deve ter uma pegadinha nisso aqui". Não tem. É só isso mesmo e funciona lindamente.

Até a próxima!

Referências


Tópicos Relacionados
Compartilhar

Escrito por Daniel Denis Moreira

Criador da Academia do Ruby.
Acredito que simplicidade é estratégia — e que Rails é uma vantagem competitiva.

Feedback

Esse conteúdo foi…

Comentários (0)

Ainda não há comentários. Seja o primeiro a comentar!

Faça login para deixar um comentário.

Conteúdos Relacionados