Skip to content

Repository files navigation

react-cornerstone3d

한국어 · Live demo →

React bindings that expose Cornerstone3D's live engine state to React components — tearing-free, via useSyncExternalStore.

npm install react-cornerstone3d
import { useViewportState } from 'react-cornerstone3d';

function SliceIndicator() {
  const index = useViewportState('ct-axial', (s) => s.sliceIndex); // Stack or Volume alike
  if (index === undefined) return null; // viewport not enabled yet, or no slices yet — a normal state, your call what to show
  return <span>slice {index + 1}</span>;
}

Run it → — a real CT stack, the hook and the hand-rolled version side by side, and the mount-order bug the hand-rolled one still has.

That one hook call replaces the ~25 lines of useEffect + addEventListener + setState plumbing every Cornerstone3D + React app writes per widget today.

See what you'd write without the hook
function SliceIndicator() {
  const [state, setState] = useState<{ imageIdIndex: number }>();

  useEffect(() => {
    const enabled = getEnabledElementByViewportId('ct-axial');
    if (!enabled) return; // viewport not enabled yet? enabled later? — unhandled
    const { element } = enabled.viewport;

    const update = () => {
      const viewport = enabled.viewport as Types.IStackViewport;
      setState({ imageIdIndex: viewport.getCurrentImageIdIndex() });
    };
    update(); // patch the gap between first render and subscription — easy to forget

    element.addEventListener(Enums.Events.CAMERA_MODIFIED, update);
    element.addEventListener(Enums.Events.VOI_MODIFIED, update);
    element.addEventListener(Enums.Events.STACK_NEW_IMAGE, update);
    return () => {
      element.removeEventListener(Enums.Events.CAMERA_MODIFIED, update);
      element.removeEventListener(Enums.Events.VOI_MODIFIED, update);
      element.removeEventListener(Enums.Events.STACK_NEW_IMAGE, update);
    };
  }, []);

  if (!state) return null;
  return <span>slice {state.imageIdIndex + 1}</span>;
}

And after all 27 lines, you still have: the mount-order race (a viewport enabled later stays undefined forever), tearing under concurrent rendering, a re-render per event during drags (no batching) — repeated in every widget. The library solves these centrally.

Why this exists

Cornerstone3D is not badly built — it is an engine, not a store. It manages its own mutable state and fires events when things change; React needs immutable snapshots compared by Object.is. useViewportState is the adapter that bridges the two: subscribe to engine events, cache an immutable snapshot, rebuild it only when the data actually changed.

Without that adapter, every React viewer hand-rolls the same event plumbing, and with it the same bug layer:

  • Missed updates in the gap between first render and useEffect subscription
  • Tearing under React 18+ concurrent rendering — two components showing two different slice numbers on one screen
  • Subscription leaks under StrictMode double-mounting
  • Mount-order races when UI mounts before a viewport is enabled
  • Infinite loops or deep-compare hacks, because Cornerstone3D getters return a fresh object on every call — a naive getSnapshot never stabilizes (OHIF papers over this with per-hook JSON.stringify diffing)

This library solves that bug layer once, centrally. UI components become pure functions of engine state.

Core design

Three decisions shape everything (full rationale in docs/adr/):

  1. The Engine is the single source of truth. Reads flow Engine → event → immutable Snapshot → useSyncExternalStore. Writes stay plain Cornerstone3D API calls — their effects reach React by coming back as engine events. No parallel write API, no echo suppression, one read path regardless of who changed the state (your code or a mouse drag).

  2. The library owns no engine. Hooks take only a viewportId and resolve it through Cornerstone3D's own global registry. No Provider, no singleton, no engine prop — your existing engine management stays untouched.

  3. Absence is a normal state. A viewport that isn't enabled yet returns undefined; the value fills in automatically when it appears and empties when it's destroyed. What to render meanwhile is entirely your app's decision.

On top of that, the Snapshot layer guarantees referential stability (unchanged state ⇒ identical reference, no wasted renders, no loops) and immutability (deep-frozen — nothing you receive can drift under you). And a field earns its place only when the Engine has both a getter for it and an event that says it moved (ADR 0007) — which is why there is a loaded and no loading.

API

useViewportState(viewportId, selector?)

function useViewportState(viewportId: string, selector?: undefined): ViewportState | undefined;
function useViewportState<T>(viewportId: string, selector: (state: ViewportState) => T): T | undefined;
  • viewportId — resolved through Cornerstone3D's global registry. Returns undefined while no viewport with that id is enabled.
  • selector — the component re-renders only when the selected value changes by Object.is. Never called while the viewport is absent. Select a primitive or an existing field (s => s.voiRange is referentially stable across unrelated changes); a selector that builds a value — s => ({ index: s.sliceIndex }) — can never be Object.is-equal to its last result, so it re-renders on every Engine event (ADR 0004).

Engine events are coalesced to at most one update per animation frame, so a drag produces one render per frame instead of one per event. There is no opt-out: the Snapshot is state, not an event stream, and a component that needs every event belongs on a Cornerstone3D listener (ADR 0006).

ViewportState is a discriminated union. sliceIndex / numberOfSlices (the Slice Position) are common to every kind, so one slider serves Stack and MPR screens; narrow on kind for the rest:

interface ViewportStateCommon { camera: Types.ICamera; voiRange: Types.VOIRange | undefined; sliceIndex: number | undefined; numberOfSlices: number | undefined; currentImageId: string | undefined }
interface StackViewportState  extends ViewportStateCommon { kind: 'stack';  sliceIndex: number; numberOfSlices: number; imageIds: readonly string[] }
interface VolumeViewportState extends ViewportStateCommon { kind: 'volume' }
type ViewportState = StackViewportState | VolumeViewportState;

Every state object is a deep-frozen Snapshot, and the reference stays identical until the state actually changes. A rebuild shares structure with the Snapshot it replaces, so a field that did not move keeps its reference — a zoom never hands s => s.voiRange a new object, and a scroll never hands s => s.imageIds a new array. On a Stack, sliceIndex is the requested slice — it updates the moment a scroll happens, not when the image finishes loading (ADR 0003). On a Volume it derives from the camera, so it never runs ahead of the pixels. A viewport without slices (3D, or a Volume before setVolumes) reports undefined for both fields.

currentImageId is the image the viewport points at: on a Stack the requested slice's id, on a Volume the image closest to the camera, undefined when it points at nothing (a Stack before setStack, a Volume before data, 3D). imageIds is the Stack's list, replaced only when setStack changes its content. Together they are the key into the image hooks below.

useImageLoadState(imageId) · useImageLoadStates(imageIds)

function useImageLoadState(imageId: string | undefined): boolean | undefined;
function useImageLoadStates(imageIds: readonly string[] | undefined): readonly boolean[] | undefined;

Whether an image is in the cache, as the cache module reports it (cache.isLoaded). It is the cache's word, not the viewport's: an image can be loaded and not yet on any canvas. Two absences are distinct — undefined means there was no image to ask about (imageId was undefined), false means the cache was asked and does not have it, whether the image was never requested or failed and was dropped.

The array hook is useImageLoadState for a whole list: same answer per entry, one frozen array, replaced only when some entry changed. undefined in, undefined out; an empty list is one stable empty array. It owns no state of its own — a tick component reading useImageLoadState(imageIds[i]) and a track reading useImageLoadStates(imageIds) share one Binding per image.

The library never joins viewport and image state; the join is yours, and it is two lines:

function LoadTrack({ viewportId }: { viewportId: string }) {
  const imageIds = useViewportState(viewportId, (s) => (s.kind === 'stack' ? s.imageIds : undefined));
  const current  = useViewportState(viewportId, (s) => s.currentImageId);
  const loaded   = useImageLoadStates(imageIds); // readonly boolean[] | undefined
  if (!imageIds || !loaded) return null;
  return <div className="track">{imageIds.map((id, i) => <span key={id} data-loaded={loaded[i]} data-current={id === current} />)}</div>;
}

What you will not find: loading and failed. The cache has no getter for either — an entry appears silently when a load is requested and is deleted silently when it fails — so a Snapshot cannot carry them without the library keeping records the Engine does not (ADR 0007). For those, listen to IMAGE_LOAD_FAILED / IMAGE_LOAD_ERROR on Cornerstone3D's eventTarget yourself. Drawing N ticks is also yours: memo the row or paint a canvas if N is large.

Under the hood, Cornerstone3D's eventTarget keeps listeners in a plain array, so the library attaches one listener pair for all observed images and routes by id — a thousand observed slices cost two listeners, not a thousand.

<CornerstoneViewport />

Optional. Renders a <div>, enables it as a viewport on mount and disables it on unmount. The Engine stays app-created — the component only resolves it through the registry.

import { Enums } from '@cornerstonejs/core';
import { CornerstoneViewport } from 'react-cornerstone3d';

<CornerstoneViewport viewportId="ct-axial" type={Enums.ViewportType.STACK} style={{ width: 512, height: 512 }} />
Prop Description
viewportId Id to enable — the same id useViewportState observes.
type Enums.ViewportType, passed to enableElement.
defaultOptions? Types.ViewportInputOptions, applied once at enable time. Later changes do not re-enable.
renderingEngineId? Engine to enable on. Defaults to the app's single registered Engine; throws if there are zero or several and no id is given.
...divProps Everything else goes to the <div>.

A missing Engine at mount is a mount-ordering bug, so the component throws instead of degrading — unlike hooks, where viewport absence is a normal state.

Status

v0.4 — sync only. The library's sole responsibility is state synchronization.

Capability Status
Stack viewport state (camera, VOI, slice index) ✅
Volume viewport state + per-kind types ✅
Slice Position (sliceIndex, numberOfSlices) common to both kinds ✅
What a viewport points at (currentImageId; Stack imageIds) ✅
Image Load State (useImageLoadState, useImageLoadStates) ✅
Absent-viewport contract (undefined) ✅
Shared per-viewport Binding, StrictMode-safe ✅
Auto fill-in / empty-out on viewport enable/destroy ✅
Selectors (re-render only when your value changes) ✅
rAF batching for interaction-rate events ✅
Optional <CornerstoneViewport /> component ✅
Cache totals (useCacheState), Volume volumeIds roadmap
Annotation / tool / segmentation state roadmap

Requires: React 18+, @cornerstonejs/core 5.x. Ships ESM only.

Out of scope

App state management (use Zustand or whatever you like), write helpers, engine/viewport lifecycle beyond the optional component, rendering performance (that's the Engine's job).

Development

npx playwright install chromium   # once — the browser tests run against real Cornerstone3D
npm test                          # unit (jsdom + fake CS3D registry) and browser (headless Chromium) projects
npm run build                     # tsc → dist/

Unit tests observe only the public hook API — return values, referential stability, re-render counts. Browser tests drive a real Engine and a real cache to verify the assumptions the fakes make. Domain vocabulary (Engine, Viewport State, Snapshot, Command, Image Load State, Binding) lives in CONTEXT.md.

About

No description, website, or topics provided.

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages