# Save, Restore & Export

> Persist and restore flow state, export to PNG, and copy/paste.

**Site index:** [https://flows.2plot.dev/llms.txt](https://flows.2plot.dev/llms.txt) — every page on this site, as Markdown.  
**Network index:** [https://2plot.dev/llms.txt](https://2plot.dev/llms.txt) — The 2plot network; start here to discover sibling sites.  
**Sibling sites:** 13 more in The 2plot network — listed in the site index above.  
**Sitemap:** https://flows.2plot.dev/sitemap.xml  


---



### Overview

Serialize the whole graph with `exportFlowState` / `flowState` and rebuild it with `restoreFlowState`. Export the canvas to an image via `downloadImage`, and duplicate elements with `copyAction` / `pasteAction`.

### Live demo

Drag the nodes, pan, and zoom — the canvas below is a real, running `DashFlows` component (no callbacks, just the rendered graph):



```python
# File: docs/persistence/demo.py

"""Live, callback-free demo for the docs page. Rendered via `.. exec::docs.persistence.demo`."""
import dash_flows

nodes = [{'id': 'load', 'type': 'input', 'data': {'label': 'Load'}, 'position': {'x': 120, 'y': 40}},
 {'id': 'edit', 'type': 'default', 'data': {'label': 'Edit'}, 'position': {'x': 120, 'y': 180}},
 {'id': 'save', 'type': 'output', 'data': {'label': 'Save'}, 'position': {'x': 120, 'y': 320}}]

edges = [{'id': 'le', 'source': 'load', 'target': 'edit', 'animated': True},
 {'id': 'es', 'source': 'edit', 'target': 'save'}]

component = dash_flows.DashFlows(
    id="persistence-demo",
    nodes=nodes,
    edges=edges,
    style={'border': '1px solid var(--mantine-color-default-border)',
 'borderRadius': '8px',
 'height': '440px'},
    showControls=True,
    showMiniMap=True,
    fitView=True,
    colorMode='system',
)
```

    :defaultExpanded: false
    :withExpandedButton: true

### Examples

Each example below is a complete, runnable Dash app from the [`examples/`](https://github.com/pip-install-python/dash-flows/tree/main/examples) folder. Run any of them with `python examples/<file>`.

#### Save & Restore

Export the current flow to JSON and restore it later, plus drive the viewport (fit, zoom, focus) from callbacks. Use this pattern for a "save my diagram" button or a session-restore flow.



```python
# File: examples/15_save_restore.py

"""
Example 15: Save and Restore Flow State
=======================================
This example demonstrates how to:
- Export the current flow state to JSON
- Save the state to localStorage or download as file
- Restore a previously saved flow state
- Use viewport actions for programmatic control
"""

import dash
from dash import html, dcc, callback, Input, Output, State
import dash_flows
import json

app = dash.Dash(__name__)

# Initial nodes
initial_nodes = [
    {
        "id": "node-1",
        "type": "input",
        "data": {"label": "Data Source"},
        "position": {"x": 100, "y": 100},
    },
    {
        "id": "node-2",
        "type": "default",
        "data": {"label": "Transform"},
        "position": {"x": 100, "y": 250},
    },
    {
        "id": "node-3",
        "type": "default",
        "data": {"label": "Process"},
        "position": {"x": 300, "y": 250},
    },
    {
        "id": "node-4",
        "type": "output",
        "data": {"label": "Output"},
        "position": {"x": 200, "y": 400},
    },
]

# Initial edges
initial_edges = [
    {"id": "e1-2", "source": "node-1", "target": "node-2"},
    {"id": "e1-3", "source": "node-1", "target": "node-3"},
    {"id": "e2-4", "source": "node-2", "target": "node-4"},
    {"id": "e3-4", "source": "node-3", "target": "node-4"},
]

app.layout = html.Div([
    html.H1("Save and Restore Flow State"),
    html.P("Drag nodes around, add connections, then save or restore the flow state."),

    # Control buttons
    html.Div([
        html.Button("Export State", id="export-btn", n_clicks=0,
                   style={"marginRight": "10px", "padding": "10px 20px"}),
        html.Button("Download JSON", id="download-btn", n_clicks=0,
                   style={"marginRight": "10px", "padding": "10px 20px"}),
        html.Button("Restore from Saved", id="restore-btn", n_clicks=0,
                   style={"marginRight": "10px", "padding": "10px 20px"}),
        html.Button("Reset to Initial", id="reset-btn", n_clicks=0,
                   style={"marginRight": "10px", "padding": "10px 20px"}),
    ], style={"marginBottom": "10px"}),

    # Viewport action buttons
    html.Div([
        html.Button("Fit View", id="fit-view-btn", n_clicks=0,
                   style={"marginRight": "10px", "padding": "8px 16px"}),
        html.Button("Zoom In", id="zoom-in-btn", n_clicks=0,
                   style={"marginRight": "10px", "padding": "8px 16px"}),
        html.Button("Zoom Out", id="zoom-out-btn", n_clicks=0,
                   style={"marginRight": "10px", "padding": "8px 16px"}),
        html.Button("Focus Node 1", id="focus-node-btn", n_clicks=0,
                   style={"marginRight": "10px", "padding": "8px 16px"}),
    ], style={"marginBottom": "20px"}),

    # Download component
    dcc.Download(id="download-json"),

    # Hidden store for saved state
    dcc.Store(id="saved-state-store"),

    # Status display
    html.Div(id="status-display", style={
        "padding": "10px",
        "marginBottom": "10px",
        "background": "#f0f0f0",
        "borderRadius": "5px",
    }),

    # Flow component
    dash_flows.DashFlows(
        id="flow",
        nodes=initial_nodes,
        edges=initial_edges,
        style={"height": "500px", "border": "1px solid #ddd"},
        fitView=True,
        showControls=True,
        showMiniMap=True,
        showBackground=True,
    ),

    # JSON preview
    html.H3("Exported State Preview:"),
    html.Pre(id="state-preview", style={
        "padding": "15px",
        "background": "#2d2d2d",
        "color": "#a6e22e",
        "borderRadius": "5px",
        "maxHeight": "300px",
        "overflow": "auto",
        "fontSize": "12px",
    }),
])


# Export state callback
@callback(
    Output("flow", "exportFlowState"),
    Input("export-btn", "n_clicks"),
    prevent_initial_call=True,
)
def trigger_export(n_clicks):
    return True


# Handle exported state
@callback(
    [Output("saved-state-store", "data"),
     Output("state-preview", "children"),
     Output("status-display", "children")],
    Input("flow", "flowState"),
    prevent_initial_call=True,
)
def handle_export(flow_state):
    if flow_state:
        json_str = json.dumps(flow_state, indent=2)
        return (
            flow_state,
            json_str,
            f"State exported! {len(flow_state.get('nodes', []))} nodes, {len(flow_state.get('edges', []))} edges"
        )
    return dash.no_update, dash.no_update, dash.no_update


# Download JSON callback
@callback(
    Output("download-json", "data"),
    Input("download-btn", "n_clicks"),
    State("saved-state-store", "data"),
    prevent_initial_call=True,
)
def download_json(n_clicks, saved_state):
    if saved_state:
        return dict(
            content=json.dumps(saved_state, indent=2),
            filename="flow_state.json",
        )
    return None


# Restore from saved state
@callback(
    Output("flow", "restoreFlowState"),
    Input("restore-btn", "n_clicks"),
    State("saved-state-store", "data"),
    prevent_initial_call=True,
)
def restore_state(n_clicks, saved_state):
    if saved_state:
        return saved_state
    return None


# Reset to initial state
@callback(
    Output("flow", "restoreFlowState", allow_duplicate=True),
    Input("reset-btn", "n_clicks"),
    prevent_initial_call=True,
)
def reset_state(n_clicks):
    return {
        "nodes": initial_nodes,
        "edges": initial_edges,
        "viewport": {"x": 0, "y": 0, "zoom": 1},
    }


# Viewport action callbacks
@callback(
    Output("flow", "viewportAction"),
    [Input("fit-view-btn", "n_clicks"),
     Input("zoom-in-btn", "n_clicks"),
     Input("zoom-out-btn", "n_clicks"),
     Input("focus-node-btn", "n_clicks")],
    prevent_initial_call=True,
)
def handle_viewport_action(fit_clicks, zoom_in_clicks, zoom_out_clicks, focus_clicks):
    ctx = dash.callback_context
    if not ctx.triggered:
        return None

    button_id = ctx.triggered[0]["prop_id"].split(".")[0]

    if button_id == "fit-view-btn":
        return {"action": "fitView", "options": {"padding": 0.2, "duration": 500}}
    elif button_id == "zoom-in-btn":
        return {"action": "zoomIn", "options": {"duration": 300}}
    elif button_id == "zoom-out-btn":
        return {"action": "zoomOut", "options": {"duration": 300}}
    elif button_id == "focus-node-btn":
        return {"action": "focusNode", "nodeId": "node-1", "zoom": 2, "duration": 800}

    return None


if __name__ == "__main__":
    app.run(debug=True, port=8085)
```

    :defaultExpanded: false
    :withExpandedButton: true

**How it works**

- `exportFlowState=True` — asks the flow to serialize its current nodes, edges, and viewport; the result comes back on `flowState`.
- `Input("ex15-flow", "flowState")` — fires once export completes, so you can stash it in a `dcc.Store` and preview it as JSON.
- `restoreFlowState={"nodes": ..., "edges": ..., "viewport": ...}` — rebuilds the flow from a previously saved (or hand-built) state object.
- `Output(..., allow_duplicate=True)` — required whenever a second callback also targets `restoreFlowState`, here used by the "Reset to Initial" button.
- `viewportAction={"action": "fitView" | "zoomIn" | "zoomOut" | "focusNode", "options": {...}}` — triggers programmatic viewport moves without touching nodes or edges.

#### Export Image

Download the flow as a PNG, SVG, or JPEG using html-to-image, with options for background color, quality, and resolution.



```python
# File: examples/18_export_image.py

"""
Example 18: Export Flow as Image
================================
This example demonstrates how to:
- Export the flow as PNG, SVG, or JPEG
- Configure export options (quality, background, resolution)
- Track when downloads complete
"""

import dash
from dash import html, callback, Input, Output, State
import dash_flows

app = dash.Dash(__name__)

# Sample nodes
nodes = [
    {
        "id": "node-1",
        "type": "input",
        "data": {"label": "Data Input"},
        "position": {"x": 100, "y": 50},
    },
    {
        "id": "node-2",
        "type": "default",
        "data": {"label": "Process"},
        "position": {"x": 100, "y": 200},
    },
    {
        "id": "node-3",
        "type": "default",
        "data": {"label": "Transform"},
        "position": {"x": 300, "y": 200},
    },
    {
        "id": "node-4",
        "type": "output",
        "data": {"label": "Output"},
        "position": {"x": 200, "y": 350},
    },
]

edges = [
    {"id": "e1-2", "source": "node-1", "target": "node-2", "animated": True},
    {"id": "e1-3", "source": "node-1", "target": "node-3"},
    {"id": "e2-4", "source": "node-2", "target": "node-4", "label": "Result"},
    {"id": "e3-4", "source": "node-3", "target": "node-4"},
]

button_style = {
    "padding": "10px 20px",
    "marginRight": "10px",
    "borderRadius": "6px",
    "border": "none",
    "cursor": "pointer",
    "fontWeight": "500",
}

app.layout = html.Div([
    html.H1("Export Flow as Image"),
    html.P("Click a button below to export the flow as an image file."),

    # Export buttons
    html.Div([
        html.Button(
            "Download PNG",
            id="btn-png",
            n_clicks=0,
            style={**button_style, "background": "#4CAF50", "color": "white"}
        ),
        html.Button(
            "Download SVG",
            id="btn-svg",
            n_clicks=0,
            style={**button_style, "background": "#2196F3", "color": "white"}
        ),
        html.Button(
            "Download JPEG",
            id="btn-jpeg",
            n_clicks=0,
            style={**button_style, "background": "#FF9800", "color": "white"}
        ),
        html.Button(
            "High-Res PNG (4x)",
            id="btn-hires",
            n_clicks=0,
            style={**button_style, "background": "#9C27B0", "color": "white"}
        ),
        html.Button(
            "Transparent PNG",
            id="btn-transparent",
            n_clicks=0,
            style={**button_style, "background": "#607D8B", "color": "white"}
        ),
    ], style={"marginBottom": "20px"}),

    # Status display
    html.Div(id="export-status", style={
        "padding": "10px",
        "marginBottom": "10px",
        "background": "#f0f0f0",
        "borderRadius": "5px",
    }, children="Click a button to export the flow"),

    # Flow component
    dash_flows.DashFlows(
        id="flow",
        nodes=nodes,
        edges=edges,
        style={"height": "500px", "border": "1px solid #ddd"},
        fitView=True,
        showControls=True,
        showMiniMap=True,
        showBackground=True,
        backgroundVariant="dots",
    ),
])


# Handle export button clicks
@callback(
    Output("flow", "downloadImage"),
    [Input("btn-png", "n_clicks"),
     Input("btn-svg", "n_clicks"),
     Input("btn-jpeg", "n_clicks"),
     Input("btn-hires", "n_clicks"),
     Input("btn-transparent", "n_clicks")],
    prevent_initial_call=True,
)
def handle_export(png_clicks, svg_clicks, jpeg_clicks, hires_clicks, transparent_clicks):
    ctx = dash.callback_context
    if not ctx.triggered:
        return None

    button_id = ctx.triggered[0]["prop_id"].split(".")[0]

    if button_id == "btn-png":
        return {
            "format": "png",
            "filename": "flow_diagram",
            "backgroundColor": "#ffffff",
        }
    elif button_id == "btn-svg":
        return {
            "format": "svg",
            "filename": "flow_vector",
            "backgroundColor": "#ffffff",
        }
    elif button_id == "btn-jpeg":
        return {
            "format": "jpeg",
            "filename": "flow_compressed",
            "quality": 0.8,
            "backgroundColor": "#ffffff",
        }
    elif button_id == "btn-hires":
        return {
            "format": "png",
            "filename": "flow_highres",
            "pixelRatio": 4,
            "backgroundColor": "#ffffff",
        }
    elif button_id == "btn-transparent":
        return {
            "format": "png",
            "filename": "flow_transparent",
            "backgroundColor": "transparent",
        }

    return None


# Show status when download completes
@callback(
    Output("export-status", "children"),
    [Input("flow", "imageDownloaded"),
     Input("flow", "lastError")],
    prevent_initial_call=True,
)
def update_status(downloaded, error):
    ctx = dash.callback_context
    if not ctx.triggered:
        return dash.no_update

    trigger = ctx.triggered[0]["prop_id"]

    if "imageDownloaded" in trigger and downloaded:
        return f"Downloaded: {downloaded.get('filename', 'unknown')} ({downloaded.get('format', '').upper()})"
    elif "lastError" in trigger and error:
        if error.get("type") == "image-export":
            return f"Export failed: {error.get('message', 'Unknown error')}"

    return dash.no_update


if __name__ == "__main__":
    app.run(debug=True, port=8088)
```

    :defaultExpanded: false
    :withExpandedButton: true

**How it works**

- `downloadImage={"format": "png" | "svg" | "jpeg", "filename": ..., "backgroundColor": ...}` — triggers a browser download of the rendered canvas.
- `pixelRatio` — renders at a higher resolution (e.g. `4` for a 4x PNG) without changing the on-screen layout.
- `backgroundColor="transparent"` — exports a PNG with no background fill, useful for overlaying on other content.
- `Input("ex18-flow", "imageDownloaded")` — fires with the filename and format once the export succeeds.
- `Input("ex18-flow", "lastError")` — reports export failures (checked here for `type == "image-export"`).

#### Copy & Paste

Copy selected nodes and edges to an internal clipboard, then paste them with a position offset — wired to both buttons and Ctrl/Cmd+C / Ctrl/Cmd+V keyboard shortcuts.



```python
# File: examples/19_copy_paste.py

"""
Example 19: Copy and Paste
==========================
This example demonstrates how to:
- Copy selected nodes and edges
- Paste with position offset
- Use keyboard shortcuts for copy/paste
"""

import dash
from dash import html, callback, Input, Output, State
import dash_flows

app = dash.Dash(__name__)

# Initial nodes
initial_nodes = [
    {
        "id": "node-1",
        "type": "input",
        "data": {"label": "Source A"},
        "position": {"x": 100, "y": 100},
    },
    {
        "id": "node-2",
        "type": "default",
        "data": {"label": "Process"},
        "position": {"x": 100, "y": 250},
    },
    {
        "id": "node-3",
        "type": "output",
        "data": {"label": "Output"},
        "position": {"x": 100, "y": 400},
    },
]

initial_edges = [
    {"id": "e1-2", "source": "node-1", "target": "node-2"},
    {"id": "e2-3", "source": "node-2", "target": "node-3"},
]

button_style = {
    "padding": "10px 20px",
    "marginRight": "10px",
    "borderRadius": "6px",
    "border": "none",
    "cursor": "pointer",
    "fontWeight": "500",
    "fontSize": "14px",
}

app.layout = html.Div([
    html.H1("Copy and Paste Example"),
    html.P([
        "Select nodes (click or shift+drag), then use the buttons or keyboard shortcuts to copy/paste.",
        html.Br(),
        "Tip: Hold Shift and drag to select multiple nodes."
    ]),

    # Control buttons
    html.Div([
        html.Button(
            "Copy Selected (Ctrl+C)",
            id="btn-copy",
            n_clicks=0,
            style={**button_style, "background": "#4CAF50", "color": "white"}
        ),
        html.Button(
            "Paste (Ctrl+V)",
            id="btn-paste",
            n_clicks=0,
            style={**button_style, "background": "#2196F3", "color": "white"}
        ),
        html.Button(
            "Copy All",
            id="btn-copy-all",
            n_clicks=0,
            style={**button_style, "background": "#FF9800", "color": "white"}
        ),
    ], style={"marginBottom": "15px"}),

    # Status display
    html.Div(id="status", style={
        "padding": "10px",
        "marginBottom": "10px",
        "background": "#f0f0f0",
        "borderRadius": "5px",
    }, children="Select nodes and use Copy/Paste"),

    # Flow component
    dash_flows.DashFlows(
        id="flow",
        nodes=initial_nodes,
        edges=initial_edges,
        style={"height": "500px", "border": "1px solid #ddd"},
        fitView=True,
        showControls=True,
        showMiniMap=True,
        showBackground=True,
        selectionOnDrag=True,  # Enable drag selection
    ),
])


# Copy button handler
@callback(
    Output("flow", "copyAction"),
    Input("btn-copy", "n_clicks"),
    prevent_initial_call=True,
)
def handle_copy(n_clicks):
    return True


# Copy all button handler
@callback(
    Output("flow", "copyAction", allow_duplicate=True),
    Input("btn-copy-all", "n_clicks"),
    State("flow", "nodes"),
    prevent_initial_call=True,
)
def handle_copy_all(n_clicks, nodes):
    # Deselect all first (to trigger copy all behavior)
    return True


# Paste button handler
@callback(
    Output("flow", "pasteAction"),
    Input("btn-paste", "n_clicks"),
    prevent_initial_call=True,
)
def handle_paste(n_clicks):
    return {"offset": {"x": 100, "y": 50}}


# Update status based on clipboard and paste events
@callback(
    Output("status", "children"),
    [Input("flow", "clipboard"),
     Input("flow", "pastedElements"),
     Input("flow", "selectedNodes")],
)
def update_status(clipboard, pasted, selected):
    status_parts = []

    if selected:
        status_parts.append(f"Selected: {len(selected)} node(s)")
    else:
        status_parts.append("No nodes selected")

    if clipboard and clipboard.get("nodes"):
        status_parts.append(f" | Clipboard: {len(clipboard['nodes'])} node(s)")

    if pasted and pasted.get("nodeIds"):
        status_parts.append(f" | Just pasted: {len(pasted['nodeIds'])} node(s)")

    return "".join(status_parts) if status_parts else "Select nodes and use Copy/Paste"


# Client-side callback for keyboard shortcuts
app.clientside_callback(
    """
    function(id) {
        document.addEventListener('keydown', function(e) {
            // Ctrl+C or Cmd+C
            if ((e.ctrlKey || e.metaKey) && e.key === 'c' && !e.shiftKey) {
                // Don't interfere with normal copy in text inputs
                if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;

                e.preventDefault();
                document.getElementById('btn-copy').click();
            }
            // Ctrl+V or Cmd+V
            if ((e.ctrlKey || e.metaKey) && e.key === 'v' && !e.shiftKey) {
                if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;

                e.preventDefault();
                document.getElementById('btn-paste').click();
            }
        });
        return window.dash_clientside.no_update;
    }
    """,
    Output("status", "data-keyboard"),
    Input("status", "id"),
)


if __name__ == "__main__":
    app.run(debug=True, port=8089)
```

    :defaultExpanded: false
    :withExpandedButton: true

**How it works**

- `copyAction=True` — copies the currently selected nodes/edges (or everything, if nothing is selected) into the flow's internal `clipboard`.
- `pasteAction={"offset": {"x": 100, "y": 50}}` — pastes the clipboard contents, shifting the new copies so they don't sit exactly on top of the originals.
- `selectionOnDrag=True` — lets a shift+drag rectangle select multiple nodes at once, so Copy Selected has something to act on.
- `Input("ex19-flow", "clipboard")` / `Input("ex19-flow", "pastedElements")` — report clipboard contents and the ids of the most recently pasted elements.
- `clientside_callback(...)` — listens for Ctrl/Cmd+C and Ctrl/Cmd+V at the document level and clicks the copy/paste buttons, so keyboard shortcuts work without a server round trip.


---

*Source: /persistence*
