Restoring your place…
Remember the reading position
Turn a few pages and reload this page: the reader opens where it was left. Nothing about that lives in the viewer, because where a position belongs — this browser, this session, or an account that follows a reader between devices — is a decision only the application can make. This demo keeps it in localStorage under a key that carries the document, so one origin can remember a position per document; swapping those two storage calls for requests to a backend is the whole difference between the two.
The position is held in the application and handed back to the viewer as currentIndex, with onIndexChange writing every page the reader turns to. A controlled index is what lets the “Start over” button return the reader to the first page as it clears the stored one; initialIndex is read once, when the viewer mounts, and would leave the reader where it was. An application writing to a network instead should debounce these calls, which arrive one per page turn.
Storage is an external store, and the hook reads it through useSyncExternalStore rather than while rendering: it does not exist on the server, and reading it during a render would make the markup React hydrates differ from the markup the server sent. The reader is held back for the one frame that takes, so that it opens on the stored page instead of fetching the first one and turning away from it. A browser that refuses storage outright, such as Safari in a private window, throws on the very first access; the position lives for as long as the tab does there, and an index stored for a document that has since grown shorter is clamped to the pages it now holds.
Source code
1import * as ComicViewer from "@publira/comic-viewer";2import { useCallback, useSyncExternalStore } from "react";34const getStorageKey = (documentId) =>5 `comic-viewer:reading-progress:${documentId}`;67// The position of every document opened this session, seeded from storage the8// first time a document is asked for and written through to it on every turn.9// The session's own copy is what keeps a reader whose browser refuses storage10// turning pages; only the position outliving the session is lost.11const sessionProgress = new Map();12const progressListeners = new Set();1314const subscribeToProgress = (onProgressChange) => {15 progressListeners.add(onProgressChange);1617 return () => {18 progressListeners.delete(onProgressChange);19 };20};2122const getProgress = (documentId) => {23 const sessionIndex = sessionProgress.get(documentId);2425 if (sessionIndex !== undefined) {26 return sessionIndex;27 }2829 let storedIndex = null;3031 try {32 storedIndex = window.localStorage.getItem(getStorageKey(documentId));33 } catch {34 // A browser set to block site data throws on the very first access.35 }3637 // Storage holds whatever an earlier version of an application wrote, so a38 // value that is not a page index is dropped. An empty entry would read as39 // zero, which is a page rather than nothing.40 const index = Number(storedIndex);41 const progress =42 storedIndex !== null &&43 storedIndex.trim() !== "" &&44 Number.isInteger(index) &&45 index >= 046 ? index47 : null;4849 sessionProgress.set(documentId, progress);5051 return progress;52};5354const setProgress = (documentId, index) => {55 sessionProgress.set(documentId, index);5657 try {58 if (index === null) {59 window.localStorage.removeItem(getStorageKey(documentId));60 } else {61 window.localStorage.setItem(getStorageKey(documentId), String(index));62 }63 } catch {64 // The position still stands for as long as this session does.65 }6667 for (const listener of progressListeners) {68 listener();69 }70};7172// Rendering happens on the server too, where there is no storage to read.73// Its snapshot is a value of its own, so that a position still on its way is74// never taken for a document that has none.75const unrestored = Symbol("unrestored");76const getServerSnapshot = () => unrestored;7778const useReadingProgress = (documentId, pageCount) => {79 const storedIndex = useSyncExternalStore(80 subscribeToProgress,81 useCallback(() => getProgress(documentId), [documentId]),82 getServerSnapshot83 );8485 const saveIndex = useCallback(86 (index) => setProgress(documentId, index),87 [documentId]88 );8990 const resetProgress = useCallback(91 () => setProgress(documentId, null),92 [documentId]93 );9495 const restoredIndex = typeof storedIndex === "number" ? storedIndex : null;9697 return {98 // A document can be shorter than it was when the position was stored, so99 // the index is held inside the document rather than trusted as it comes.100 currentIndex: Math.min(restoredIndex ?? 0, Math.max(0, pageCount - 1)),101 isRestored: storedIndex !== unrestored,102 resetProgress,103 saveIndex,104 storedIndex: restoredIndex,105 };106};107108export const Reader = ({ documentId, pages }) => {109 const { currentIndex, isRestored, resetProgress, saveIndex, storedIndex } =110 useReadingProgress(documentId, pages.length);111112 // The reader waits for the frame it takes to read the position back, so that113 // it opens on the stored page instead of fetching the first one and turning114 // away from it.115 if (!isRestored) {116 return <p>Restoring your place…</p>;117 }118119 return (120 <>121 <ComicViewer.Root122 currentIndex={currentIndex}123 onIndexChange={saveIndex}124 pages={pages}125 >126 <ComicViewer.Viewport />127 <ComicViewer.Toolbar />128 <ComicViewer.PageNavigation />129 </ComicViewer.Root>130 <button131 disabled={storedIndex === null}132 onClick={resetProgress}133 type="button"134 >135 Start over136 </button>137 </>138 );139};