typescript 132 lines · 3 tabs

Deduplicating Paginated API Results With a Normalized Entity Store and Reducer

Shared by codesnips Aug 2026
3 tabs
export interface Post {
  id: string;
  title: string;
  updatedAt: string;
}

export interface State {
  byId: Record<string, Post>;
  pageOrder: Record<number, string[]>;
  loadedPages: Set<number>;
  totalPages: number;
}

export type Action =
  | { type: 'PAGE_LOADED'; page: number; posts: Post[]; totalPages: number }
  | { type: 'RESET' };

export const initialState: State = {
  byId: {},
  pageOrder: {},
  loadedPages: new Set<number>(),
  totalPages: 1,
};

export function entityReducer(state: State, action: Action): State {
  switch (action.type) {
    case 'PAGE_LOADED': {
      const byId = { ...state.byId };
      for (const post of action.posts) {
        byId[post.id] = post; // latest server data wins for repeated ids
      }
      const loadedPages = new Set(state.loadedPages);
      loadedPages.add(action.page);
      return {
        byId,
        pageOrder: { ...state.pageOrder, [action.page]: action.posts.map((p) => p.id) },
        loadedPages,
        totalPages: action.totalPages,
      };
    }
    case 'RESET':
      return initialState;
    default:
      return state;
  }
}
3 files · typescript Explain with highlit

Infinite-scroll and "load more" lists have a recurring problem: the same entity can appear on multiple pages (because rows shift between requests), and naively concatenating page arrays produces duplicate keys and flickering UI. This snippet shows the normalized-store approach, where entities are stored once in a byId map keyed by id, and pages are stored as arrays of ids in pageOrder. Deduplication becomes trivial because a map cannot hold two entries for the same id, and ordering is preserved separately from identity.

The entityReducer tab defines the shape and transitions. State holds byId, pageOrder, loadedPages, and totalPages. The PAGE_LOADED action merges a freshly fetched page: it spreads new entities over the existing byId (so the latest server data wins for any repeated id), then rebuilds pageOrder for that page slot. Because ids are deduplicated across the whole list at render time via a Set, an entity that migrated from page 2 to page 3 simply resolves to whichever id list currently references it, and the stale reference is dropped.

The reducer is written as a pure function returning new objects, never mutating state, which keeps React's referential-equality checks meaningful and makes the transitions easy to test in isolation. loadedPages is itself a Set so re-requesting an already-loaded page can be short-circuited by callers.

The useNormalizedPages hook wraps the reducer with useReducer and exposes a loadPage callback guarded by a loading ref to prevent overlapping fetches for the same page. It derives the flattened, deduplicated items list with useMemo by walking pageOrder in order, collecting ids into a seen Set, and resolving each unique id through byId. This is where duplicates physically disappear from the rendered output.

The PostFeed component consumes the hook and renders an ordered list plus a load-more button, disabling it when loading is true or all pages are present. The trade-off of normalization is a small amount of indirection — two lookups instead of one — in exchange for stable keys, cheap merges, and a single source of truth for each entity. It is the pattern to reach for whenever the same record can surface in more than one paginated response.


Related snips

ruby
class Money
  include Comparable

  attr_reader :amount, :currency

  def initialize(amount, currency = 'USD')

Value objects for domain modeling

ruby value-objects domain-driven-design
by Sarah Mitchell 2 tabs
ruby
payload = {
  sub: user.id,
  iss: 'https://auth.example.com',
  aud: 'codesnips-api',
  exp: 15.minutes.from_now.to_i,
  iat: Time.now.to_i,

JWT issuance and verification without common footguns

jwt authentication api
by Kai Nakamura 2 tabs
typescript
export interface RetryOptions {
  retries: number;
  baseMs: number;
  maxMs: number;
  signal?: AbortSignal;
  onRetry?: (attempt: number, delay: number, err: unknown) => void;

Exponential backoff with jitter for retries

typescript reliability retry
by codesnips 2 tabs
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
python
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler, MinMaxScaler, RobustScaler
from sklearn.linear_model import LogisticRegression

standard_pipeline = Pipeline([
    ('scaler', StandardScaler()),

Scaling and normalization choices for different model families

feature-scaling normalization machine-learning
by Dr. Elena Vasquez 1 tab

Share this code

Here's the card — post it anywhere.

Deduplicating Paginated API Results With a Normalized Entity Store and Reducer — share card
Link copied