Skip to content
D
Documentation

Qwik: state and resumption

how-to
5 min readUpdated

Use QRL callbacks and instance signals to connect a resumable diagram to sibling controls. You get two connected nodes, a Fit view button, selection and camera readouts, and a server-rendered version that becomes interactive in the browser.

The binding is a thin skin over the shared model: keep plain specs in resumable state and use the live instance for rendering operations. Start with an existing Qwik 1.x project using @builder.io/qwik ^1.5.0 and its optimizer; see the Qwik quick start for project setup.

bash
npm install @grafloria/qwik @grafloria/engine @grafloria/renderer @grafloria/element @builder.io/qwik

1. Keep the diagram input as data

Share the GrafloriaFlow input data—typed with NodeSpec and EdgeSpec—between the sibling-subscription and server-adoption examples below; see the React quick start for basic node-and-edge setup.

ts
import type { NodeSpec, EdgeSpec } from '@grafloria/qwik';

export const nodes: NodeSpec[] = [
  { id: 'plan', label: 'Plan', position: { x: 60, y: 80 },
    size: { width: 160, height: 64 } },
  { id: 'ship', label: 'Ship', position: { x: 320, y: 80 },
    size: { width: 160, height: 64 } },
];

export const edges: EdgeSpec[] = [
  { id: 'plan-ship', source: 'plan', target: 'ship',
    sourceHandle: 'right', targetHandle: 'left' },
];

2. Connect QRL callbacks and sibling subscriptions

Wrap the canvas and its toolbar in GrafloriaProvider. useGrafloria returns the shared instance signal; it is undefined until the flow mounts. useSelection returns the current selected nodes and edges, and useViewport returns the camera's zoom and world origin.

Use the DiagramInstance signal to show canvas readiness beside the sibling toolbar; see the Qwik quick start for onInit$, noSerialize() and QRL callback setup.

For a sibling side effect, useOnSelectionChange$ fires a QRL on each selection change and removes its subscription automatically. Here it counts selection notifications rather than storing live models in application state.

useOnSelectionChangeQrl accepts an explicitly $()-wrapped handler. The second subscription logs selected node ids to the browser console.

tsx
import { $, component$, noSerialize, useSignal, type NoSerialize } from '@builder.io/qwik';
import {
  GrafloriaFlow, GrafloriaProvider, useGrafloria,
  useSelection, useViewport, useOnSelectionChange$, useOnSelectionChangeQrl,
  type DiagramInstance,
} from '@grafloria/qwik';
import { nodes, edges } from './diagram-data';

const Toolbar = component$(() => {
  const instance = useGrafloria();
  const selection = useSelection();
  const viewport = useViewport();
  const notifications = useSignal(0);

  useOnSelectionChange$(() => {
    notifications.value += 1;
  });

  useOnSelectionChangeQrl($(({ nodes: selectedNodes }) => {
    console.log('Selected nodes:', selectedNodes.map((node) => node.id));
  }));

  return (
    <div>
      <button type="button" disabled={!instance.value}
        onClick$={() => instance.value?.fitView(40)}>
        Fit view
      </button>
      <p>
        Selected: {selection.value.nodes.length} node(s),{' '}
        {selection.value.edges.length} edge(s).{' '}
        Zoom: {viewport.value.zoom.toFixed(2)}.{' '}
        Origin: {viewport.value.x.toFixed(0)}, {viewport.value.y.toFixed(0)}.
      </p>
      <p>Selection notifications: {notifications.value}</p>
    </div>
  );
});

export default component$(() => {
  const instance = useSignal<NoSerialize<DiagramInstance>>();
  const clicked = useSignal('None');

  return (
    <GrafloriaProvider>
      <Toolbar />
      <p>Canvas: {instance.value ? 'Ready' : 'Starting'}. Last clicked node: {clicked.value}</p>
      <GrafloriaFlow
        defaultNodes={nodes}
        defaultEdges={edges}
        fitView
        style={{ height: '400px' }}
        onInit$={(diagram) => { instance.value = noSerialize(diagram); }}
        onNodeClick$={({ node }) => { clicked.value = node.id; }}
      />
    </GrafloriaProvider>
  );
});

The mounted canvas shows Plan connected to Ship. Click a node to update the clicked-node text and selection readout. Pan or zoom to update the camera readout; Fit view frames the content. defaultNodes and defaultEdges seed the instance once, so the instance owns subsequent edits.

Plan connects to Ship beneath the Fit view button, selection and camera readouts, and Ready status. No node is selected.

For controlled state instead, pass nodes and edges from typed signals and write the returned specs back through onNodesChange$ and onEdgesChange$. Those callbacks receive spec arrays, not the live models supplied by selection callbacks. See State and event flow for the ownership decision.

3. Adopt the server SVG with its CSS

Pass the StaticRenderResult from renderToStaticSVG to the Qwik flow's ssr prop and emit its CSS separately to turn the server preview into an interactive canvas; see React: state and subscriptions for the static result's fields.

In an SSR-rendered component, build the result in useTask$, following the resumable demo. Keep that result as plain data and pass it to ssr together with the same node and edge specs. This example emits the stylesheet beside the flow; when composing your document shell, place result.css in a <style> in the document head so the first server response already carries the diagram's styles.

tsx
import { component$, useSignal, useTask$ } from '@builder.io/qwik';
import { GrafloriaFlow, renderToStaticSVG, type StaticRenderResult } from '@grafloria/qwik';
import { nodes, edges } from './diagram-data';

export default component$(() => {
  const result = useSignal<StaticRenderResult>();
  const ready = useSignal(false);

  useTask$(() => {
    result.value = renderToStaticSVG({
      nodes, edges, width: 640, height: 400,
      instanceId: 'plan-ship-ssr', fitView: true,
    });
  });

  return (
    <>
      <style dangerouslySetInnerHTML={result.value?.css ?? ''} />
      <p>{ready.value ? 'Interactive' : 'Server preview'}</p>
      <GrafloriaFlow
        defaultNodes={nodes}
        defaultEdges={edges}
        ssr={result.value}
        style={{ height: '400px' }}
        onInit$={() => { ready.value = true; }}
      />
    </>
  );
});

View the server response source: Plan, Ship and their SVG are already in the HTML. At document ready, the flow builds a live instance using the snapshot and adopts the existing DOM instead of rebuilding it. The status changes to Interactive, and you can drag the nodes. Resumption avoids re-walking the component tree; it does not defer the diagram's initialization until the first click.

The Interactive status appears above Plan connected to Ship; the left edge of the Plan box is clipped by the canvas boundary.

The snapshot carries instance scope, canvas width and height, zoom, and viewport origin—not a saved document. The client rebuilds the model from your specs. Use Save and restore documents for persistence. Custom HTML nodes mount in the browser; they are not part of the server-rendered SVG.

Options that matter

For the flow options below, see GrafloriaFlowProps. Static rendering takes StaticRenderOptions.

OptionTypeDefaultWhat it does
defaultNodes, defaultEdgesNodeSpec[], EdgeSpec[]Empty arrays when no controlled inputs existSeed the instance on mount.
nodes, edgesNodeSpec[], EdgeSpec[]UnsetReconcile controlled specs into the instance. Wire their change callbacks back to your state.
ssr{ html: string; snapshot: HydrationSnapshot }UnsetEmit server markup and adopt it in the browser. Supply its CSS separately.
Static width, heightnumber800, 600Set the server canvas dimensions in CSS pixels.
Static instanceIdstring'grafloria-ssr'Scope the diagram. Give multiple server-rendered diagrams distinct ids.
Static fitViewbooleanfalseFrame content instead of using the supplied camera.
Static fitPaddingnumber40Set fit padding in CSS pixels.

HydrationSnapshot is returned by static rendering; pass it through rather than reconstructing it.

Keep browser objects out of resumable state

The provider's instance signal is already marked noSerialize(). Your own instance signal needs the explicit marker shown above. Its value does not survive serialization: the browser builds a fresh instance, and onInit$ supplies that instance again. Do not close over a live instance directly in another QRL; capture its signal and read .value when the handler runs.

Create live transports and shared stores in useVisibleTask$, retain them with noSerialize(), and tear them down when their owner unmounts. The flow's collaboration options are fixed for the instance's lifetime, so create the options before mounting a flow that consumes them. See Documents and kits for live-object handling and Qwik: custom content and kits for browser-built spec$ factories.

Keep the provider above both the canvas and its subscribing siblings. Outside a provider, the hooks have no instance to reach; useGrafloria() stays undefined and logs a development warning. Use a separate provider for each independently controlled canvas.

Load the event loader in client-only apps

An SSR page carries Qwik's event loader automatically. If your app only calls Qwik's render() in the browser, add the loader once before rendering; without it, DOM handlers such as Fit view do not fire.

Use this entry only for a client-only app. It mounts the editor from step 2 into a newly created element in the browser.

tsx
import { render } from '@builder.io/qwik';
import { QWIK_LOADER } from '@builder.io/qwik/loader';
import Editor from './editor';

const loader = document.createElement('script');
loader.textContent = QWIK_LOADER;
document.head.appendChild(loader);

const container = document.createElement('div');
document.body.appendChild(container);
void render(container, <Editor />);

Was this page helpful?

Qwik: state and resumption — Grafloria