reflex-perspective
A Reflex custom component for Perspective — the WebAssembly analytics engine and interactive viewer (datagrid + WebGL charts) built for large and streaming datasets.
- All three Perspective data architectures
- Client-only: data from Reflex state, a URL (CSV / JSON / NDJSON / Arrow) or a schema lives in a browser WebWorker.
- Server-only (virtual): tables live in
perspective-pythoninside the Reflex backend; only the visible cells cross the wire. - Client/server replicated: the browser keeps a live copy of a server table and computes views locally.
- Declarative props for the whole
ViewerConfig(plugin,group_by,split_by,columns,filter,sort,expressions,aggregates,columns_config,theme,settings, ...) mapped torestore(). - Multi-panel workspaces (
restoreWorkspace) with master panels and global cross-filters. - Streaming through props (
update_rows,remove_keys) or straight from event handlers (rp.update). - Perspective Custom Events as Reflex events:
on_click,on_select,on_config_update,on_global_filter_update,on_layout_update,on_load,on_error, ... - Imperative API from Python:
rp.save,rp.restore,rp.download,rp.copy,rp.export,rp.reset,rp.toggle_config,rp.update,rp.remove,rp.clear,rp.add_panel, ... - All 16 bundled themes, every datagrid and chart plugin (X/Y Bar, Line, Area, Scatter, Density, Treemap, Sunburst, Heatmap, Candlestick, OHLC, Map Scatter/Line/Density).
- SSR-safe lazy WASM bootstrap, automatic WebSocket reconnection, works in
reflex rundev and--env prod.
Built against Reflex 0.10.0 and Perspective 5.5.1 (@perspective-dev/* npm packages and perspective-python).
Installation
pip install reflex-perspective # client-only mode
pip install "reflex-perspective[server]" # + perspective-python for server modes
The npm packages are installed by Reflex automatically on the first reflex run.
Requires Python 3.11+ (Reflex 0.10 dropped 3.10; perspective-python 5.x only publishes
cp311-abi3 wheels).
Quick start (client-only)
import reflex as rx
import reflex_perspective as rp
class State(rx.State):
rows: list[dict] = [
{"region": "East", "product": "A", "sales": 120.5},
{"region": "West", "product": "B", "sales": 98.1},
]
plugin: str = "Y Bar"
@rx.event
def clicked(self, detail: dict):
print(detail["row"], detail["config"]["filter"])
def index():
return rp.perspective_viewer(
id="sales",
data=State.rows, # list of rows, dict of columns, or CSV text
plugin=State.plugin,
group_by=["region"],
split_by=["product"],
columns=["sales"],
theme="Pro Dark",
on_click=State.clicked,
height="500px",
)
app = rx.App()
app.add_page(index)
Changing any prop re-applies only the difference with restore(); changing data calls
table.replace() and keeps the user's view.
Server-hosted tables (perspective-python)
import reflex as rx
import reflex_perspective as rp
from reflex_perspective import server as ps
hub = ps.get_hub() # a perspective.Server + local client
hub.table(big_dataframe, name="orders", index="order_id")
app = rx.App(api_transformer=ps.perspective_api()) # WebSocket at /perspective
# or: ps.mount(app) (keeps any existing api_transformer)
def page():
return rx.hstack(
# virtual: pivots/sorts run in Python, only the viewport is sent
rp.perspective_viewer(server_url="/perspective", server_table="orders"),
# replicated: the browser keeps a synced copy and computes locally
rp.perspective_viewer(
server_url="/perspective", server_table="orders", server_mode="replicated"
),
)
class State(rx.State):
@rx.event
def add_order(self):
hub.update("orders", [{"order_id": 42, "qty": 3}]) # every viewer updates
server_url may be a path (resolved against the Reflex backend URL, which also handles
the dev setup where frontend and backend run on different ports) or an absolute ws:// URL
of any Perspective server. Feed tables from background tasks, lifespan tasks
(ps.run_periodically(fn, interval)) or plain threads — the hub is thread-safe and the
WebSocket handler marshals messages back onto the event loop. Engine requests run in the
event loop's default thread pool (pass executor= to perspective_api() / mount() to use
your own), so a heavy pivot on a large table never blocks other Reflex events.
Access control
By default (as in 0.1.0) the endpoint only checks the browser Origin header against
Reflex's cors_allowed_origins (or allowed_origins=[...]), closing foreign connections
with code 1008, and any client that reaches it can read and modify every hosted table.
For any deployment with more than one user, set explicit origins and use both options
below (keyword-only on perspective_api(), mount(), asgi_app() and serve()):
async def authorize_panel(websocket): # sync or async
user = await user_from_cookie(websocket.cookies.get("session"))
if user is None:
return 4401 # refuse: close code 4000-4999 (or 1008)
if not user.can_view_analytics:
return 4403
return None # accept
ps.mount(
app,
path="/analytics/ws",
allowed_origins=["https://app.example.com"],
authorize=authorize_panel,
read_only=True,
)
authorize=runs after theOrigincheck and before any Perspective session exists. A refusal accepts the socket and closes it at once with your code, so the browser sees it (closing before the handshake would turn it into an HTTP 403 and the browser would only see 1006). A hook that raises, or returns anything other thanNone, 4000–4999 or 1008, closes with 1011 and is logged.read_only=Trueforwards only frames carrying exactly one known read request of the pinned Perspective version (ps.READ_VARIANTS); writes (update,remove,replace/clear, tabledelete, new tables, joins), unknown variants and malformed or tampered frames close the socket with 4409 (write_close_code=to change it) and are logged by variant number and name, never by content. Writes from Python (hub.update(), lifespan tasks) keep reaching every viewer. On aperspective-pythonversion without a verified read table, building the app raisesRuntimeErrorunless you passread_variants=.- With
read_only=True, a server viewer withedit_mode="EDIT",update_rows,remove_keysorrp.update/remove/replace/cleargets its socket closed with 4409; the viewers stop reconnecting and the browser console says why. Write through the hub instead. Read-only does not stop users from exporting what they can see (view_to_*reads stay allowed). max_sessions=Ncaps the sessions open on the hub (across all its routes). Once reached, new sockets are accepted and closed with 4429 afterauthorize; the viewers keep retrying with backoff and connect when a slot frees up.hub.session_countgives the current number.on_reject=fnis called asfn(code, websocket)(sync or async) before every refusal (1008,authorize's code, 1011, 4409 and 4429), for metrics. Its errors are logged and never change the close.ps.request_variant()andps.is_read_request()are public, for apps that compose their own handler aroundhub.serve().
PerspectiveHub helpers: table(), get_table(), has_table(), table_names(),
update(), remove(), clear(), size(), query(name, **view_config) and
delete_table().
Streaming
rp.perspective_viewer(
id="ticks",
schema={"time": "datetime", "symbol": "string", "price": "float"},
limit=5000, # rolling window
update_rows=State.batch, # every new list is applied with table.update()
plugin="Y Line",
group_by=["time"],
split_by=["symbol"],
columns=["price"],
)
# or push rows without storing them in state:
@rx.event
def burst(self):
return rp.update("ticks", rows)
Workspaces
rp.perspective_viewer(
data=State.rows,
workspace={
"masters": ["regions"], # selections here become global filters
"layout": {
"type": "split-layout",
"orientation": "horizontal",
"sizes": [0.4, 0.6],
"children": [
{"type": "tab-layout", "tabs": ["regions"]},
{"type": "tab-layout", "tabs": ["chart"]},
],
},
"panels": {
"regions": {
"plugin": "Datagrid",
"group_by": ["Region"],
"columns": ["Sales"],
},
"chart": {
"plugin": "Treemap",
"group_by": ["Category"],
"columns": ["Sales"],
},
},
},
on_global_filter_update=State.set_filters,
)
Panels without a table in the workspace prop are bound to the viewer's own table
automatically. rp.restore_workspace() and rp.add_panel() pass their config through
unchanged, so include table there.
Props
| Prop | Description |
|---|---|
data |
Rows (list[dict]), columns (dict[str, list]) or CSV text. |
schema |
{"col": "string" | "integer" | "float" | "boolean" | "date" | "datetime"}; enforces types. |
index / limit |
Primary key (upserts) or rolling row limit. |
table_name |
Table name (defaults to the component id); saved configs reference it. |
url, url_format |
Load CSV / JSON / NDJSON / Arrow from a URL. |
server_url, server_table, server_mode |
Connect to a Perspective WebSocket server ("server" or "replicated"). |
update_rows, remove_keys |
Streaming: applied every time the prop changes. |
config |
Full ViewerConfigUpdate; the shortcut props below are merged on top. |
plugin, columns, group_by, split_by, filter, filter_op, sort, expressions, aggregates, group_by_depth, group_rollup_mode, split_rollup_mode, plugin_config, columns_config, theme, title, settings |
View configuration. |
edit_mode |
Datagrid mode: READ_ONLY, EDIT, SELECT_ROW, SELECT_COLUMN, SELECT_REGION, SELECT_ROW_TREE. |
workspace |
Multi-panel WorkspaceConfigUpdate. |
auto_size, auto_pause, throttle, plugin_limits |
Render policy. |
Styling props (height, width, border_radius, ...) apply to the <perspective-viewer> element (default height 600px).
Events
| Event | Payload |
|---|---|
on_load |
{"table", "schema", "num_rows", "source"} |
on_config_update |
Full saved viewer config after any change |
on_click |
{"row", "column_names", "config", "panel"} (config.filter selects the clicked datum) |
on_select |
{"selected", "row", "column_names", "insert_filters", "remove_filters", "panel"} |
on_global_filter_update |
List of filters (workspaces) |
on_layout_update / on_active_panel_update |
Panel ids / active panel id |
on_toggle_settings |
bool |
on_disconnect |
(url, code): the server URL and the WebSocket close code (None if unknown); one-argument handlers get the URL |
on_error |
Message |
A controlled loop (config=State.cfg, on_config_update=State.set_cfg) is safe: the
component does not re-apply a config the viewer itself just emitted.
Server viewers reconnect with exponential backoff (0.5 s doubling, up to 10 s) after a lost
connection, except when the server refused it for good: a close code in 4400–4499 other than
4429 (e.g. 4401/4403 from authorize, 4409 from read_only). Then every viewer of that URL
stops retrying until the page is reloaded and the browser console explains the code; 4429
("too many sessions") keeps retrying.
Actions
All return an EventSpec, usable in triggers (on_click=rp.download("v")) or returned from
handlers. Arguments may be plain values or state vars. Result-returning actions take a
callback event handler.
save(id, callback), restore(id, config), save_workspace(id, callback),
restore_workspace(id, workspace), reset(id, all=False), toggle_config(id, force=None),
download(id, method="csv"), copy(id, method="csv"), export(id, callback, method="csv"),
update(id, rows), remove(id, keys), replace(id, data), clear(id),
table_size(id, callback), table_schema(id, callback), add_panel(id, config), resize(id).
export delivers text for csv, json, ndjson, html and json-config, and a base64
data: URL for the binary methods (arrow*, and plugin, which is a PNG image).
Export methods: rp.EXPORT_METHODS. Plugins: rp.PLUGINS. Themes: rp.THEMES.
Aggregates: rp.AGGREGATES.
Demo app
uv venv -p 3.12 && uv pip install -e ".[server]"
cd perspective_demo
uv run reflex run
| Page | What it shows |
|---|---|
/ Explorer |
Declarative props driven by state controls, click/select/config events |
/streaming |
Background task streaming through update_rows, rp.update bursts, callbacks |
/server |
perspective-python tables over a WebSocket: live market feed, 250k-row virtual table, replicated mode, Python-side queries, authorize switch and read-only mode |
/workspace |
Multi-panel dashboards, master panels, global filters, saveWorkspace() |
/api |
Imperative actions, named layouts, editable datagrid, mutations, event log |
/gallery |
Every chart plugin with a different theme |
Run it with PERSPECTIVE_DEMO_READ_ONLY=1 uv run reflex run to serve the WebSocket in
read-only mode: the "Write from the browser" button on /server is then refused with 4409.
PERSPECTIVE_DEMO_MAX_SESSIONS=1 caps the hub: a second tab gets 4429 until the first closes.
The access card counts refusals by code through on_reject.
Notes
- Keep
perspective-pythonand the npm packages on the same version (rp.PERSPECTIVE_VERSION); the WebSocket protocol is versioned together. - Server tables live in the backend process. With several backend workers each worker has its own hub; use a single worker or an external Perspective server (
server_url="ws://...") for shared tables. - Unless you pass
authorize=andread_only=True(see Access control), any client that passes theOrigincheck can read and modify every hosted table, just like with Perspective's own handlers. update_rows/rp.updatewrite to the viewer's own table. Inservermode that's the shared hosted table; inreplicatedmode it's only the browser replica (write through the hub to reach every viewer).- On the very first
reflex runafter installing, Vite may pre-bundle the new npm dependencies while the page loads; reload once if the viewer does not appear.
Development
uv sync --extra dev
uv run pytest # unit tests
uvx ruff check . && uvx ruff format --check .
uv build # sdist + wheel in dist/
CI runs on every push and PR to main / develop: Quality (ruff, a JSX syntax check,
pytest on Python 3.11–3.13, a build + twine check with a wheel-content check, and a
reflex compile of the demo) and Security (CodeQL, Bandit, pip-audit, gitleaks and
dependency review).
Releasing
- Bump
versioninpyproject.tomland__version__incustom_components/reflex_perspective/__init__.py(CI checks they match). - Merge to
main, then tag and push:git tag v0.1.1 && git push origin v0.1.1. - The Release workflow checks the tag against the version, runs the checks again, builds, publishes to PyPI (Trusted Publishing) and creates the GitHub release with the artifacts attached.
License
Apache-2.0. Perspective is © the Perspective Authors, Apache-2.0.
Metadata
Release files for reflex-perspective 0.4.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| reflex_perspective-0.4.0.tar.gz | 55.6 kB | With provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| reflex_perspective-0.4.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 96.0 kB
Release files / reflex_perspective-0.4.0.tar.gz
| Download URL | reflex_perspective-0.4.0.tar.gz |
|---|---|
| Size | 55.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
51be496251f90a6d5cd190ef4e95039c4d839c4b3eaf44382add38b927b8c7a1
|
|
BLAKE2b-256 checksum How to use checksums |
0f7fcbf309ff3e0f6e708cf8ef4a3d980021308558db8c7a6652e181e96a0e97
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 25, 2026.
Transparency logRelease files / reflex_perspective-0.4.0-py3-none-any.whl
| Download URL | reflex_perspective-0.4.0-py3-none-any.whl |
|---|---|
| Size | 40.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
673158162f0a135bc53d1a6249b6582e9bfab6490bf35a788d54f57d4d4698c4
|
|
BLAKE2b-256 checksum How to use checksums |
884ef4428a9ff99b0c8c0ff6a008fb6453ff00d8925cd0f2cca37bd5d5bc24bf
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 25, 2026.
Transparency logSecurity for reflex-perspective 0.4.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for reflex_perspective-0.4.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 25, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.