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;
}
}
import { useCallback, useMemo, useReducer, useRef, useState } from 'react';
import { entityReducer, initialState, Post, State } from './entityReducer';
interface PageResponse {
posts: Post[];
totalPages: number;
}
type Fetcher = (page: number) => Promise<PageResponse>;
export function useNormalizedPages(fetchPage: Fetcher) {
const [state, dispatch] = useReducer(entityReducer, initialState);
const [loading, setLoading] = useState(false);
const inFlight = useRef<Set<number>>(new Set());
const loadPage = useCallback(
async (page: number) => {
if (inFlight.current.has(page) || state.loadedPages.has(page)) return;
inFlight.current.add(page);
setLoading(true);
try {
const res = await fetchPage(page);
dispatch({ type: 'PAGE_LOADED', page, posts: res.posts, totalPages: res.totalPages });
} finally {
inFlight.current.delete(page);
setLoading(false);
}
},
[fetchPage, state.loadedPages]
);
const items = useMemo(() => resolveItems(state), [state]);
return { items, loading, loadPage, loadedPages: state.loadedPages, totalPages: state.totalPages };
}
function resolveItems(state: State): Post[] {
const seen = new Set<string>();
const out: Post[] = [];
const pages = Object.keys(state.pageOrder).map(Number).sort((a, b) => a - b);
for (const page of pages) {
for (const id of state.pageOrder[page]) {
if (seen.has(id)) continue; // dedupe across pages
seen.add(id);
const post = state.byId[id];
if (post) out.push(post);
}
}
return out;
}
import React, { useEffect, useState } from 'react';
import { useNormalizedPages } from './useNormalizedPages';
import { Post } from './entityReducer';
async function fetchPostsPage(page: number) {
const res = await fetch(`/api/posts?page=${page}&per=20`);
if (!res.ok) throw new Error(`Failed to load page ${page}`);
return (await res.json()) as { posts: Post[]; totalPages: number };
}
export function PostFeed() {
const { items, loading, loadPage, loadedPages, totalPages } = useNormalizedPages(fetchPostsPage);
const [page, setPage] = useState(1);
useEffect(() => {
loadPage(page);
}, [page, loadPage]);
const hasMore = loadedPages.size < totalPages;
return (
<div className="post-feed">
<ul>
{items.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
<button
disabled={loading || !hasMore}
onClick={() => setPage((p) => p + 1)}
>
{loading ? 'Loading…' : hasMore ? 'Load more' : 'No more posts'}
</button>
</div>
);
}
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
class Money
include Comparable
attr_reader :amount, :currency
def initialize(amount, currency = 'USD')
Value objects for domain modeling
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
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
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
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
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
Share this code
Here's the card — post it anywhere.