Accessing State Outside React Components with Zustand in Pascal Editor

Pascal Editor exports its Zustand store as a singleton from packages/core/src/store/use-scene.ts, enabling any JavaScript module to read or mutate scene state via useScene.getState() without using React hooks.

Pascal Editor manages its entire scene graph—nodes, collections, and dirty-state tracking—through a central Zustand store enhanced with the zundo middleware for undo/redo capabilities. Because the store is instantiated at the module level and exported as a plain object rather than wrapped in a React Context provider, it remains accessible to utility functions, keyboard shortcuts, background workers, and auto-save logic that operate outside the component tree.

How the Scene Store Enables Universal Access

Zustand Configuration with Temporal Middleware

In packages/core/src/store/use-scene.ts, the store is created using create<SceneState>() wrapped with temporal() from the zundo library. This setup captures every state mutation while limiting the history stack to 50 entries via the limit: 50 configuration (lines 92-98). The store also implements partialize to control which state slices are tracked for undo purposes.

Lines 15-73 implement a subscription via useScene.temporal.subscribe() that performs diff-detection specifically when the user triggers undo or redo operations, ensuring the UI reflects historical states correctly even when accessed through imperative calls.

Module-Level Singleton Export

Lines 300-303 export the store instance as export default useScene. This pattern creates a singleton that any file in the monorepo can import. Unlike React Context, which requires a <Provider> tree, this Zustand store lives at the module level and initializes immediately upon import, eliminating side-effects that depend on the React component lifecycle.

Reading and Mutating State Outside Components

Retrieving Snapshots with getState()

Any non-React module can import the store and call useScene.getState() to retrieve the current SceneState snapshot. This method returns the full state object containing nodes, rootNodeIds, and the dirty-set, enabling read-only operations without triggering re-renders.

Performing Mutations via Store Actions

Rather than exposing raw setState access, Pascal Editor provides typed action methods directly on the store object, including createNode, updateNode, and deleteNode. These actions are defined in the store configuration and ensure all mutations pass through the zundo temporal tracking system, maintaining undo/redo consistency regardless of whether the caller is a React component or a utility function.

Practical Implementation Examples

Querying Nodes from Utility Files

The following example from packages/editor/src/lib/scene.ts (lines 1-4) demonstrates filtering wall nodes without React hooks:

// packages/editor/src/lib/scene.ts
import useScene from '@pascal-app/core/src/store/use-scene'

export function getAllWallNodes() {
  const { nodes } = useScene.getState()
  return Object.values(nodes).filter(node => node.type === 'wall')
}

Handling Keyboard Shortcuts

In packages/editor/src/hooks/use-keyboard.ts (lines 98-110), keyboard events access both the scene store and selection state to rotate objects:

// packages/editor/src/hooks/use-keyboard.ts
import useScene from '@pascal-app/core/src/store/use-scene'

export function rotateSelectedNode(deltaY: number) {
  const { selectedNodeIds } = useEditor.getState()
  const nodeId = selectedNodeIds[0]
  if (!nodeId) return

  const node = useScene.getState().nodes[nodeId]
  const newRotation = (node.rotation ?? 0) + deltaY
  useScene.getState().updateNode(nodeId, { rotation: newRotation })
}

Managing Draft Nodes in Non-React Services

This pattern from packages/editor/src/components/tools/item/use-draft-node.ts (lines 74-78) creates nodes from pure logic functions:

// packages/editor/src/components/tools/item/use-draft-node.ts
import useScene from '@pascal-app/core/src/store/use-scene'
import { ItemNode } from '@pascal-app/core/src/schema/nodes/item'

export function draftNewItem(currentLevelId: string) {
  const draft = ItemNode.parse({ /* default props */ })
  useScene.getState().createNode(draft, currentLevelId)
  return draft.id
}

Implementing Auto-Save Logic

The auto-save hook in packages/editor/src/hooks/use-auto-save.ts (lines 65-75) serializes state without React subscription overhead:

// packages/editor/src/hooks/use-auto-save.ts
import useScene from '@pascal-app/core/src/store/use-scene'

export function maybeSaveScene() {
  const { nodes, rootNodeIds } = useScene.getState()
  const payload = JSON.stringify({ nodes, rootNodeIds })
  // …send to backend or local storage
}

Architectural Safety Guarantees

Using useScene outside React components provides three critical guarantees:

  • Single source of truth – All application segments read from and write to the same Zustand instance (packages/core/src/store/use-scene.ts), ensuring consistency across the editor.
  • Undo/redo integration – The temporal wrapper tracks every mutation performed via store actions, so imperative callers participate fully in the undo stack alongside React components.
  • No context leakage – The store does not rely on a <Provider> pattern, preventing coupling between state access and the React tree structure. This enables safe imports in web workers or Node.js scripts if needed.

Summary

  • Pascal Editor’s scene state lives in a Zustand store at packages/core/src/store/use-scene.ts, wrapped with zundo for undo/redo support.
  • The store is exported as a singleton (export default useScene), making it importable anywhere in the codebase.
  • Call useScene.getState() to read the current snapshot and access actions like createNode or updateNode for mutations.
  • State changes made outside React still propagate through the temporal middleware, preserving history and consistency.
  • This pattern eliminates the need for React Context or hooks in utilities, keyboard handlers, and background services.

Frequently Asked Questions

Can I use useScene.getState() inside a React component?

Yes, though components typically use the useScene() hook (the default export called as a hook) to subscribe to state changes and trigger re-renders. However, calling useScene.getState() inside event handlers or effects is valid when you need a one-time snapshot without establishing a reactive subscription.

Does mutating state outside React break the undo/redo history?

No. Because Pascal Editor wraps the store with zundo’s temporal() middleware at initialization (packages/core/src/store/use-scene.ts lines 92-98), all mutations—whether from React event handlers or external utility functions—pass through the same tracking mechanism and append to the unified undo stack.

How do I access the store if I’m not in a React file?

Simply import the default export from the core package: import useScene from '@pascal-app/core/src/store/use-scene'. Since the store is a module singleton with no React dependencies, it initializes correctly in plain TypeScript files, test suites, or service workers without a JSX runtime.

What is the difference between getState() and the useScene hook?

useScene.getState() returns the current state object imperatively and does not subscribe to future changes, making it ideal for event handlers and utility functions. The useScene() hook (calling the same export as a React hook) establishes a subscription that causes components to re-render when tracked state slices change.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →