javascript ruby 95 lines · 3 tabs

Stimulus: intersection observer for “mark as read”

Shared by codesnips Jan 2026
3 tabs
import { Controller } from "@hotwired/stimulus"
import { NotificationsApi } from "notifications_api"

export default class extends Controller {
  static targets = ["notification"]
  static values = {
    threshold: { type: Number, default: 0.6 },
    rootMargin: { type: String, default: "0px" },
    debounce: { type: Number, default: 400 }
  }

  connect() {
    this.pending = new Set()
    this.flushTimer = null
    this.observer = new IntersectionObserver(
      (entries) => this.handleIntersect(entries),
      { threshold: this.thresholdValue, rootMargin: this.rootMarginValue }
    )
    this.notificationTargets.forEach((el) => this.observer.observe(el))
  }

  disconnect() {
    this.observer.disconnect()
    clearTimeout(this.flushTimer)
  }

  handleIntersect(entries) {
    for (const entry of entries) {
      if (!entry.isIntersecting) continue
      const id = entry.target.dataset.readMarkerIdValue
      if (id) {
        this.pending.add(id)
        this.observer.unobserve(entry.target)
        entry.target.classList.add("notification--read")
      }
    }
    this.queueFlush()
  }

  queueFlush() {
    clearTimeout(this.flushTimer)
    this.flushTimer = setTimeout(() => this.flush(), this.debounceValue)
  }

  async flush() {
    if (this.pending.size === 0) return
    const ids = Array.from(this.pending)
    this.pending.clear()

    try {
      await NotificationsApi.markRead(ids)
    } catch (error) {
      ids.forEach((id) => this.pending.add(id))
      this.queueFlush()
    }
  }
}
3 files · javascript, ruby Explain with highlit

This snippet shows how a Stimulus controller uses the IntersectionObserver API to mark notifications as read once they have actually been seen on screen, rather than eagerly marking everything on page load. The interesting part is coordinating browser visibility detection with a batched server round-trip that stays cheap even when many rows enter the viewport at once.

In read_marker_controller.js, the controller declares a notification target so each notification row registers itself with a single shared observer created in connect(). The threshold and rootMargin values are read from data-attribute-backed values, so the markup decides how much of a row must be visible before it counts as read — a common tweak, since a row poking one pixel into view should usually not count. When handleIntersect fires, only entries with isIntersecting are collected; each element's dataset.readMarkerIdValue is pushed into a pending Set and the element is immediately unobserved so it is never reported twice. This unobserve step is the key idempotency guard on the client side.

Rather than sending one request per row, queueFlush uses a debounce so a burst of intersections during a fast scroll collapses into a single POST. flush() drains the pending Set into an array, clears it, and calls NotificationsApi.markRead, which is defined in notifications_api.js. That wrapper centralizes the fetch call, pulls the CSRF token from the standard Rails <meta name="csrf-token"> tag, and sends JSON. On failure it re-queues the ids so a dropped network request does not silently lose reads.

The server side lives in NotificationsController, whose mark_read action scopes the update to current_user.notifications so a malicious client cannot mark another user's rows. It uses where(id:, read_at: nil) plus update_all for a single set-based write, which is both fast and naturally idempotent — already-read ids simply match nothing.

The trade-off is eventual consistency: reads are reported slightly after they happen, and a user who closes the tab mid-debounce may lose the last batch. For a read-receipt feature that looseness is acceptable, and the pattern avoids the thundering-herd of per-row requests. A developer would reach for this whenever "seen" should mean genuinely visible, not merely rendered.


Related snips

ruby
class CommentsController < ApplicationController
  before_action :set_post

  def create
    @comment = @post.comments.build(comment_params)

System test: asserting Turbo Stream responses

rails hotwire turbo
by codesnips 4 tabs
ruby
class Post < ApplicationRecord
  belongs_to :author, class_name: 'User'
  has_many :comments, dependent: :destroy

  scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
  scope :draft, -> { where(published_at: nil) }

ActiveRecord scopes for reusable query logic

rails activerecord patterns
by Alex Kumar 1 tab
ruby
module Api
  module V1
    class UsersController < BaseController
      def show
        user = User.includes(:profile).find(params[:id])

ETags for conditional requests and caching

rails caching http-caching
by Alex Kumar 1 tab
ruby
class PostsController < ApplicationController
  def index
    @posts = Post.includes(:author)
                 .order(created_at: :desc)
                 .page(params[:page])
                 .per(10)

Turbo Frames: infinite scroll with lazy-loading frame

rails turbo hotwire
by codesnips 4 tabs
ruby
require "csv"

class PeopleCsvStream
  include Enumerable

  HEADERS = %w[id full_name email signed_up_at plan].freeze

Resilient CSV Export as a Streamed Response

rails performance streaming
by codesnips 3 tabs
erb
<form data-controller="query-sync" data-action="change->query-sync#apply">
  <select name="status" class="rounded border p-2">
    <option value="">Any</option>
    <option value="open">Open</option>
    <option value="closed">Closed</option>
  </select>

Filter UI that syncs query params via Stimulus (no front-end router)

rails hotwire stimulus
by Henry Kim 2 tabs

Share this code

Here's the card — post it anywhere.

Stimulus: intersection observer for “mark as read” — share card
Link copied