@ritojs/react is the React integration layer on top of the root reader from
@ritojs/core and the controller layer from @ritojs/kit.
Use it when you want hooks for reader lifecycle and state, plus a mount component for the controller-managed reading surface.
import { useRitoReader, Reader } from '@ritojs/react';Hooks:
useRitoReaderuseRitoScrollReaderuseRitoScrollViewuseSelectionuseScrollSelectionHandlesuseSearchuseAnnotationsuseReadingPositionuseContainerSizeuseControllerEvent
ScrollSelectionHandles({ controller, rootRef, touchInput }) draws a scrolled
view's selection handles as Reader draws the page controller's, placed over the
view's canvas inside rootRef and moved with the strip.
useScrollSelectionHandles(controller) gives those ends where the view shows
them (start, end, and focusEdge naming the focus), following the strip and
null while nothing is selected or a gesture still selects: what a toolbar over a
scrolled selection stands on, as useSelection's focusRect is for pages.
Types:
UseRitoReaderOptionsRitoReaderStateRitoReaderActionsUseRitoScrollReaderOptionsRitoScrollReaderStateRitoScrollReaderActionsUseRitoScrollViewOptionsRitoScrollViewStateRitoScrollViewActionsSelectionStateSearchStateAnnotationsStateReadingPositionStateContainerSize
Components:
ReaderReaderProps
import { useCallback, useEffect, useRef } from 'react';
import {
Reader,
useContainerSize,
useControllerEvent,
useReaderAutoResize,
useRitoReader,
} from '@ritojs/react';
import { springPresets } from '@ritojs/kit';
import { pinnedFontPolicy } from './pinned-fonts'; // app-owned font bytes + digests
export function App() {
const [sizeRef, containerSize] = useContainerSize();
const { controller, error, load } = useRitoReader({
reader: {
width: Math.max(containerSize.width, 1),
height: Math.max(containerSize.height, 1),
margin: 40,
spread: 'double',
// Required: the engine shapes with host font bytes only.
pinnedFontPolicy,
// The book opens already styled: one layout, not one per setting.
fontSize: 18,
fontFamily: 'serif',
},
controller: {
transition: { ...springPresets.default, effect: 'slide' },
},
});
// Re-paginates on every container size change after the book is loaded.
const resizeRef = useReaderAutoResize(controller, { margin: 40 });
const containerRef = useCallback(
(node: HTMLElement | null) => {
sizeRef(node);
resizeRef(node);
},
[resizeRef, sizeRef],
);
const didLoadRef = useRef(false);
useEffect(() => {
if (containerSize.width === 0 || containerSize.height === 0) return;
if (didLoadRef.current) return;
didLoadRef.current = true;
void load(fetch('/book.epub').then((resp) => resp.arrayBuffer()));
}, [containerSize.width, containerSize.height, load]);
// Non-fatal: the book stays open (a font no face answers to, a failed search…).
useControllerEvent(controller, 'error', ({ message, source }) => {
console.warn(source, message);
});
return (
<div ref={containerRef} style={{ width: '100vw', height: '100vh' }}>
{error ? <p role="alert">{error}</p> : <Reader controller={controller} />}
</div>
);
}This is the highest-level React hook. It manages:
- canvas creation
createReader()createController()- state synchronization for spread count and active spread
- cleanup on unmount
Use this hook when you want a full reader lifecycle in React.
Important behavior:
- rendering the hook itself is SSR-safe
spreadsis the navigation record,{ index, pageIndexes, leftPageIndex, rightPageIndex? }, andtotalSpreadsits length; both are book-wide: empty and 0 until the whole book's layout first commits, and held at their last book-wide values while a chapter is committed alone after a relayoutload()still needs a browser document and should run in an effect or event handler- replacement
load()calls wait for the previous Reader's engine session to be released before opening the next document load()is separate from later responsive resizes:useReaderAutoResize(controller)callscontroller.resize()whenever the observed element changes size (or call it yourself)- everything a book should open with — size, spread, theme, typography — belongs in
options.reader, so the book lays out once, already styled; later changes go through the controller (setTypography,setSpreadMode,setTheme,setRenderScale) errorsays why the book could not be opened. The controller's ownerrorevents are non-fatal and never land there: subscribe withuseControllerEvent(controller, 'error', …)
useRitoReader() does not choose or fetch fallback fonts. A
ReaderPinnedFontPolicy is required by the core: finish loading the
app-owned static font bytes before calling load(), then pass the completed
policy under options.reader.pinnedFontPolicy. The hook reads the current
Reader options once the data given to load() has resolved; a missing or empty
policy makes the load fail.
Replacing the policy object after load() does not reconfigure the open Reader.
To add a face while a book is open (a system font, a file the reader imported),
use controller.addFontFaces(); controller.fonts() lists every family a
font-family choice can name, which is what a font picker should be built from.
See Fonts for how families resolve.
useRitoScrollReader({ reader, scroll, initialLocator }) owns a scrolled reader
(the book laid out with spread: 'scrolled' whatever reader.spread says) and
its kit scroll view. Pass containerRef as the ref of the element the view is
shown in; load(data) opens a book, letting go of the one before, and shows
initialLocator's line at the reading line once the book is laid out; unload()
lets it go. The state
follows the view: progress (0 to 1 through the book), position (the locator
at the reading line as of the strip's last rest, to save and reopen at) and
selection (the selection event while something is selected, null
otherwise), with controller for everything else the view does
(scrollToLocator, setHighlights, on('tap')).
const { containerRef, load, progress, controller } = useRitoScrollReader({
reader: { width, height, pinnedFontPolicy },
initialLocator: saved,
});
useEffect(
() =>
controller?.on('tap', ({ target }) => {
if (target?.targetLocator) void controller.scrollToLocator(target.targetLocator);
}),
[controller],
);
return <div ref={containerRef} style={{ width, height }} />;useRitoScrollView(controller, { active, paged, scroll }) reads the book a
page controller shows scrolled, on the same reader: while active, the
controller hands the reader over (suspend()), the reader is laid out scrolled
and a scroll view shows it from where the pages were; turned off, the reader is
paged again as paged says ('single' or 'double') from where the view was
read, and the controller takes it back (resume()). Size, theme and typography
reach the reader through the controller as before, and the view follows each
layout. The state is the view's (controller, progress, position,
selection), with isLoading while the scrolled layout is made (only after a
short delay) and error; pass containerRef as the ref of the element the view
is shown in. When the controller is replaced (another book opened), the view is
let go with it.
const rito = useRitoReader({ reader: { width, height, pinnedFontPolicy } });
const scroll = useRitoScrollView(rito.controller, { active: scrolled, paged: 'double' });
return scrolled ? <div ref={scroll.containerRef} /> : <Reader controller={rito.controller} />;useReaderTransition(controller) returns { isTransitioning, direction } from the
controller's transitionStart / transitionEnd, for hiding chrome or deferring work while
a page moves. Every start is answered by exactly one end, so it never sticks, and it resets
when the controller is replaced.
useReaderTransitionProgress(controller) returns how far the turn on screen has moved, 0
at rest and 1 on arrival, from transitionProgress. It re-renders on every painted frame
of a turn, so read it in a small component of its own (a scrim, a progress edge).
The Reader component mounts the controller's managed DOM surface into a container.
It does not own pagination logic itself, and it does not call controller.resize() for you.
The controller remains the source of truth.
Use selection.hasSelection as the selection-presence signal.
selection.sourceLocator carries the durable source range when both endpoints share
a resource and selection.sourceSpan the resource-qualified endpoints, while text,
viewportRects, focusRect, and handles are ready for copy and selection UI. The
hook clears all selection state when its controller is replaced so data cannot leak
between books.
- Use
@ritojs/reactif you want fast app integration and React state bindings. - Use
@ritojs/kitdirectly if you want non-React UI or a custom state layer. - Use
@ritojs/coreif you only need the core reader without React state wiring.