Zoom Image uses @namnode/store - a lightweight, framework-agnostic observable store for managing zoom state. This enables reactive updates across all framework adapters while keeping the core library independent.
The Store Pattern
Each zoom mode creates its own store instance to manage state:
import { createStore } from "@namnode/store"
// Inside createZoomImageWheel
const store = createStore<ZoomImageWheelState>({
currentZoom: 1,
enable: true,
currentPositionX: 0,
currentPositionY: 0,
currentRotation: 0,
})
The store provides three core methods:
getState() - Get the current state synchronously
setState(newState) - Update state and notify subscribers
subscribe(callback) - Listen to state changes
cleanup() - Remove all subscriptions and cleanup
State Shapes by Mode
Each zoom mode maintains different state based on its functionality.
Wheel/Pinch Mode State
The wheel/pinch mode has the most comprehensive state for controlling zoom, pan, and rotation:
type ZoomImageWheelState = {
currentRotation: number // Rotation angle in degrees (0, 90, 180, 270, etc.)
currentZoom: number // Current zoom level (1 = no zoom, 4 = 4x zoom)
enable: boolean // Whether zoom interactions are enabled
currentPositionX: number // X-axis translation in pixels
currentPositionY: number // Y-axis translation in pixels
}
Example values:
// Default state (no zoom)
{
currentRotation: 0,
currentZoom: 1,
enable: true,
currentPositionX: 0,
currentPositionY: 0
}
// Zoomed in 2x, panned right and down
{
currentRotation: 0,
currentZoom: 2,
enable: true,
currentPositionX: -150,
currentPositionY: -200
}
// Rotated 90 degrees, zoomed 3x
{
currentRotation: 90,
currentZoom: 3,
enable: true,
currentPositionX: -100,
currentPositionY: -100
}
Hover Mode State
Hover mode tracks the loading status and enabled state:
type ZoomImageHoverState = {
zoomedImgStatus: ZoomedImgStatus // Image loading status
enabled: boolean // Whether hover zoom is active
}
type ZoomedImgStatus = "idle" | "loading" | "loaded" | "error"
State transitions:
// Initial state
{ zoomedImgStatus: "idle", enabled: true }
// User hovers, starts loading high-res image
{ zoomedImgStatus: "loading", enabled: true }
// Image loaded successfully
{ zoomedImgStatus: "loaded", enabled: true }
// Image failed to load
{ zoomedImgStatus: "error", enabled: true }
// Hover zoom disabled programmatically
{ zoomedImgStatus: "loaded", enabled: false }
Move Mode State
Move mode only tracks image loading status:
type ZoomImageMoveState = {
zoomedImgStatus: ZoomedImgStatus
}
type ZoomedImgStatus = "idle" | "loading" | "loaded" | "error"
Click Mode State
Click mode has the same state shape as move mode:
type ZoomImageClickState = {
zoomedImgStatus: ZoomedImgStatus
}
type ZoomedImgStatus = "idle" | "loading" | "loaded" | "error"
Reading State
Use getState() to read the current state synchronously:
import { createZoomImageWheel } from "@zoom-image/core"
const container = document.getElementById("zoom-container")
const zoomImage = createZoomImageWheel(container)
// Get current state
const state = zoomImage.getState()
console.log("Current zoom level:", state.currentZoom)
console.log("Is enabled:", state.enable)
console.log("Position:", state.currentPositionX, state.currentPositionY)
Reading State in Framework Adapters
Framework adapters typically sync core state with framework reactivity:
import { useZoomImageWheel } from "@zoom-image/react"
function ZoomViewer() {
const { createZoomImage, zoomImageState } = useZoomImageWheel()
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (containerRef.current) {
createZoomImage(containerRef.current, { maxZoom: 5 })
}
}, [])
// zoomImageState is reactive and updates automatically
return (
<div>
<div ref={containerRef}>
<img src="/image.jpg" alt="Zoomable" />
</div>
<div>
<p>Zoom: {zoomImageState.currentZoom.toFixed(2)}x</p>
<p>Rotation: {zoomImageState.currentRotation}°</p>
<p>Enabled: {zoomImageState.enable ? "Yes" : "No"}</p>
</div>
</div>
)
}
Subscribing to State Changes
Use subscribe() to listen for state changes and react to them:
const zoomImage = createZoomImageWheel(container)
const unsubscribe = zoomImage.subscribe(({ state, prevState }) => {
console.log("State changed")
console.log("Previous:", prevState)
console.log("Current:", state)
// React to specific changes
if (state.currentZoom !== prevState.currentZoom) {
console.log(`Zoom changed from ${prevState.currentZoom} to ${state.currentZoom}`)
}
if (state.currentRotation !== prevState.currentRotation) {
console.log(`Rotated from ${prevState.currentRotation}° to ${state.currentRotation}°`)
}
})
// Later: unsubscribe when no longer needed
unsubscribe()
Subscription Callback Signature
The subscription callback receives an object with current and previous state:
type SubscriptionCallback<T> = (payload: {
state: T // Current state after update
prevState: T // State before update
}) => void
Multiple Subscribers
You can have multiple subscribers on the same zoom instance:
const zoomImage = createZoomImageWheel(container)
// Subscriber 1: Update UI
const unsubscribe1 = zoomImage.subscribe(({ state }) => {
document.getElementById("zoom-level").textContent = `${state.currentZoom}x`
})
// Subscriber 2: Log analytics
const unsubscribe2 = zoomImage.subscribe(({ state, prevState }) => {
if (state.currentZoom > prevState.currentZoom) {
analytics.track("zoom_in", { level: state.currentZoom })
}
})
// Subscriber 3: Enable/disable controls
const unsubscribe3 = zoomImage.subscribe(({ state }) => {
const resetButton = document.getElementById("reset-zoom")
resetButton.disabled = state.currentZoom === 1
})
// Cleanup all
unsubscribe1()
unsubscribe2()
unsubscribe3()
Subscription Lifecycle
Subscriptions remain active until explicitly unsubscribed or the instance is cleaned up:
const zoomImage = createZoomImageWheel(container)
const unsubscribe = zoomImage.subscribe(({ state }) => {
console.log("Zoom:", state.currentZoom)
})
// Option 1: Unsubscribe manually
unsubscribe()
// Option 2: Cleanup removes all subscriptions
zoomImage.cleanup() // Also unsubscribes all
Updating State
Some zoom modes support programmatic state updates via setState().
Wheel/Pinch Mode Updates
Wheel mode supports updating zoom, rotation, and enable state:
const zoomImage = createZoomImageWheel(container, { maxZoom: 5 })
// Zoom to 2x
zoomImage.setState({ currentZoom: 2 })
// Rotate 90 degrees
zoomImage.setState({ currentRotation: 90 })
// Disable zoom interactions
zoomImage.setState({ enable: false })
// Update multiple properties
zoomImage.setState({
currentZoom: 3,
currentRotation: 180,
enable: true
})
// Change zoom target element
const newTarget = document.getElementById("new-target")
zoomImage.setState({ zoomTarget: newTarget })
Available updates:
type ZoomImageWheelStateUpdate = Partial<{
enable: boolean // Toggle zoom on/off
currentZoom: number // Set zoom level (clamped to 1-maxZoom)
currentRotation: number // Set rotation angle
zoomTarget: HTMLElement | null // Change the element being transformed
}>
Hover Mode Updates
Hover mode only supports enabling/disabling:
const zoomImage = createZoomImageHover(container, options)
// Disable hover zoom
zoomImage.setState({ enabled: false })
// Re-enable hover zoom
zoomImage.setState({ enabled: true })
Move and Click Modes
Move and click modes don’t expose setState() - they are controlled entirely by user interaction.
State Update Constraints
The zoom implementations enforce constraints on state updates:
// Zoom is clamped between 1 and maxZoom
const zoomImage = createZoomImageWheel(container, { maxZoom: 4 })
zoomImage.setState({ currentZoom: 10 }) // Actually sets to 4 (maxZoom)
zoomImage.setState({ currentZoom: 0.5 }) // Actually sets to 1 (minimum)
console.log(zoomImage.getState().currentZoom) // 1
// Position is constrained to keep image within bounds
zoomImage.setState({ currentZoom: 2 })
// currentPositionX and currentPositionY are automatically adjusted
Image Loading States
Modes that support separate zoom image sources track loading status:
const zoomImage = createZoomImageHover(container, {
customZoom: { width: 400, height: 400 },
zoomTarget: document.getElementById("zoom-target"),
zoomImageSource: "/images/high-res.jpg" // Separate high-res image
})
zoomImage.subscribe(({ state }) => {
switch (state.zoomedImgStatus) {
case "idle":
console.log("No zoom image loaded yet")
break
case "loading":
console.log("Loading high-res image...")
// Show loading spinner
break
case "loaded":
console.log("High-res image ready")
// Hide loading spinner
break
case "error":
console.log("Failed to load high-res image")
// Show error message
break
}
})
Loading State Timing
The image loader waits 50ms before transitioning to “loading” state to avoid flash of loading state for cached images:
// From imageLoader.ts implementation
const THRESHOLD = 50 // milliseconds
function createZoomImage(img, src, store) {
if (img.src === src) return // Already loaded
img.src = src
let complete = false
img.onload = () => {
complete = true
store.setState({ zoomedImgStatus: "loaded" })
}
img.onerror = () => {
complete = true
store.setState({ zoomedImgStatus: "error" })
}
// Only show loading state if image takes >50ms
setTimeout(() => {
if (!complete) {
store.setState({ zoomedImgStatus: "loading" })
}
}, THRESHOLD)
}
Batch Updates
The store supports batching multiple state updates to trigger only one subscriber notification:
// Inside createZoomImageWheel setState implementation
store.batch(() => {
const currentState = store.getState()
if (typeof newState.enable === "boolean") {
store.setState({ enable: newState.enable })
}
if (typeof newState.currentRotation === "number") {
store.setState({ currentRotation: newState.currentRotation })
}
if (typeof newState.currentZoom === "number") {
const newZoom = clamp(newState.currentZoom, 1, maxZoom)
updateStateOnNewZoom(newZoom)
}
})
// Only ONE subscriber notification fired for all updates above
This improves performance by avoiding redundant renders/re-renders.
Practical Examples
Example 1: Zoom Level Indicator
const container = document.getElementById("image-container")
const indicator = document.getElementById("zoom-indicator")
const zoomImage = createZoomImageWheel(container)
zoomImage.subscribe(({ state }) => {
indicator.textContent = `${Math.round(state.currentZoom * 100)}%`
// Change color based on zoom level
if (state.currentZoom >= 3) {
indicator.style.color = "red"
} else if (state.currentZoom >= 2) {
indicator.style.color = "orange"
} else {
indicator.style.color = "green"
}
})
const resetButton = document.getElementById("reset-zoom")
const zoomImage = createZoomImageWheel(container, { maxZoom: 5 })
// Disable button when already at default state
zoomImage.subscribe(({ state }) => {
const isDefault =
state.currentZoom === 1 &&
state.currentRotation === 0 &&
state.currentPositionX === 0 &&
state.currentPositionY === 0
resetButton.disabled = isDefault
})
// Reset to default state on click
resetButton.addEventListener("click", () => {
zoomImage.setState({
currentZoom: 1,
currentRotation: 0
})
})
Example 3: Zoom Controls
const zoomInBtn = document.getElementById("zoom-in")
const zoomOutBtn = document.getElementById("zoom-out")
const rotateBtn = document.getElementById("rotate")
const zoomImage = createZoomImageWheel(container, { maxZoom: 4 })
zoomInBtn.addEventListener("click", () => {
const current = zoomImage.getState().currentZoom
zoomImage.setState({ currentZoom: current + 0.5 })
})
zoomOutBtn.addEventListener("click", () => {
const current = zoomImage.getState().currentZoom
zoomImage.setState({ currentZoom: current - 0.5 })
})
rotateBtn.addEventListener("click", () => {
const current = zoomImage.getState().currentRotation
zoomImage.setState({ currentRotation: current + 90 })
})
// Update button states
zoomImage.subscribe(({ state }) => {
zoomInBtn.disabled = state.currentZoom >= 4
zoomOutBtn.disabled = state.currentZoom <= 1
})
Example 4: Loading State UI
const loadingSpinner = document.getElementById("loading-spinner")
const errorMessage = document.getElementById("error-message")
const zoomImage = createZoomImageMove(container, {
zoomImageSource: "/images/very-high-res.jpg"
})
zoomImage.subscribe(({ state }) => {
loadingSpinner.style.display =
state.zoomedImgStatus === "loading" ? "block" : "none"
errorMessage.style.display =
state.zoomedImgStatus === "error" ? "block" : "none"
})
Example 5: Analytics Tracking
const zoomImage = createZoomImageWheel(container)
let sessionMaxZoom = 1
zoomImage.subscribe(({ state, prevState }) => {
// Track zoom level changes
if (state.currentZoom !== prevState.currentZoom) {
analytics.track("zoom_changed", {
from: prevState.currentZoom,
to: state.currentZoom,
direction: state.currentZoom > prevState.currentZoom ? "in" : "out"
})
// Track max zoom achieved
if (state.currentZoom > sessionMaxZoom) {
sessionMaxZoom = state.currentZoom
analytics.track("new_max_zoom", { level: sessionMaxZoom })
}
}
// Track rotation usage
if (state.currentRotation !== prevState.currentRotation) {
analytics.track("image_rotated", {
angle: state.currentRotation
})
}
})
The store pattern enables reactive UI updates without coupling the core library to any specific framework’s reactivity system.
Always unsubscribe from state changes when your component unmounts, or use the cleanup() method which handles this automatically.
Don’t mutate the state object directly. Always use setState() to ensure subscribers are notified and constraints are enforced.