javascript 94 lines · 3 tabs

Preserve Scroll Position When Loading Older Comments With useLayoutEffect

Shared by codesnips Sep 2026
3 tabs
import { useLayoutEffect, useRef, useCallback } from 'react';

export function useScrollAnchor(anchorItemCount) {
  const containerRef = useRef(null);
  const savedRef = useRef(null);

  const capture = useCallback(() => {
    const el = containerRef.current;
    if (!el) return;
    savedRef.current = {
      height: el.scrollHeight,
      top: el.scrollTop,
    };
  }, []);

  useLayoutEffect(() => {
    const el = containerRef.current;
    const saved = savedRef.current;
    if (!el || !saved) return;

    const delta = el.scrollHeight - saved.height;
    if (delta > 0) {
      el.scrollTop = saved.top + delta;
    }
    savedRef.current = null;
  }, [anchorItemCount]);

  return { containerRef, capture };
}
3 files · javascript Explain with highlit

When a comment thread prepends older messages above the current viewport, the browser keeps the same scrollTop, which visually yanks the content the reader was looking at downward. This snippet shows the standard fix: measure the scroll container's height before the DOM paints with the new rows, then restore the reader's relative position synchronously so no flicker is visible.

The core trick lives in useScrollAnchor hook. It exposes a containerRef for the scroll element and a capture() function that a caller invokes right before triggering a state update that prepends items. capture() stashes the current scrollHeight and scrollTop in a ref. A useLayoutEffect keyed on anchorItemCount then runs after React mutates the DOM but before the browser paints, computes how much taller the container became (scrollHeight - prev.height), and adds that delta back to scrollTop. Because it uses useLayoutEffect rather than useEffect, this adjustment happens in the same frame, so the reader never sees the jump.

Using a ref for the saved measurement matters: it must survive the render caused by the state change without itself triggering another render. The effect also clears the saved value after applying it so a later append (which grows the bottom, not the top) is left untouched.

In CommentThread component, useLoadMoreComments hook supplies the comments array plus a loadOlder async action and a hasMore flag. The onLoadMore handler calls anchor.capture() immediately before await loadOlder(), guaranteeing the height snapshot reflects the pre-prepend layout. The 'Load more' button sits at the top of the list because that is where new rows appear.

useLoadMoreComments hook handles the data side: cursor-based paging keyed on the oldest loaded id, an inFlight guard to prevent overlapping requests, and prepending with setComments(prev => [...page.items, ...prev]). Cursor pagination is preferred over offset here because the list mutates as people post, so offsets would skip or duplicate rows.

The main pitfall this avoids is a race between measurement and paint; doing the math in useEffect would let the browser paint the shifted layout first. Reach for this pattern for chat backfills, activity feeds, or any reverse-chronological list that grows upward.


Related snips

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
javascript
import { Controller } from "@hotwired/stimulus"
import Mousetrap from "mousetrap"

export default class extends Controller {
  connect() {
    // Global shortcuts

Keyboard shortcuts with Stimulus and Mousetrap

stimulus javascript ux
by Jordan Lee 2 tabs
typescript
import axios, { AxiosError } from 'axios'
import { v4 as uuidv4 } from 'uuid'

const api = axios.create({
  baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1',
  timeout: 15000,

Axios API client with interceptors

react axios api
by Maya Patel 1 tab
typescript
import { create } from 'zustand'
import { persist } from 'zustand/middleware'

interface FilterState {
  search: string
  category: string | null

Zustand for lightweight state management

react zustand state-management
by Maya Patel 2 tabs
html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Form Validation Example</title>
  <style>

HTML forms with validation and accessibility

html forms validation
by Alex Chang 1 tab
javascript
import { Application } from "@hotwired/stimulus"
import FormSubmitController from "./controllers/form_submit_controller"

const application = Application.start()
application.debug = false

Disable submit button while Turbo form is submitting

rails hotwire stimulus
by codesnips 3 tabs

Share this code

Here's the card — post it anywhere.

Preserve Scroll Position When Loading Older Comments With useLayoutEffect — share card
Link copied