remotestate
v0.3.3
Published
Python state, React UI.
Maintainers
Readme
RemoteState - TypeScript/React Library
remotestate is the browser-side bridge for RemoteState apps. It gives React a typed client, a provider, hooks, path helpers, and task tracking to talk to the Python backend.
If you want the high-level product overview, start with the repository root README: RemoteState
If you want the Python runtime details, see: remotestate-py/README.md
Install
npm install remotestateDevelopment
- Node.js
>= 20 - from the package directory:
cd remotestate-ts - install dependencies with
npm install - build with
npm run build - run tests with
npm run tests - run type-checks and linting with
npm run checks - format the workspace with
npm run format
Quick Start
import {
RemoteStateProvider,
useRemoteState,
useRemoteStateClient,
} from "remotestate";
type CounterService = {
increment(): Promise<void>;
compute(x: number): Promise<number>;
};
function Counter() {
const client = useRemoteStateClient<CounterService>();
const [count, setCount] = useRemoteState<number>("count", 0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => void setCount((n) => (n ?? 0) + 1)}>
Local +1
</button>
<button onClick={() => void client.action("increment")}>
Backend +1
</button>
</div>
);
}
export function App() {
return (
<RemoteStateProvider url="ws://localhost:9753/ws">
<Counter />
</RemoteStateProvider>
);
}API Overview
The public TypeScript API is exported from remotestate:
createRemoteStateClientcreateLocalStateClientRemoteStateProvideruseRemoteStateClientuseRemoteStoreuseRemoteStateValueuseRemoteState
The remotestate module also exports an optional task monitoring API:
useRemoteTaskStoreuseRemoteTaskuseRemoteTaskscreateRemoteTaskStoreTaskStoreImpl
The remotestate/path module is dedicated to low-level path helpers and types.
Remote Client
createRemoteStateClient<S>(url, options?) creates a typed client bound to one WebSocket endpoint.
urlcan be aws:///wss://endpoint or anhttp:///https://URLhttp://andhttps://URLs are normalized to the corresponding WebSocket endpoint- the returned client exposes
store,tasks,action(),query(), anddispose() options.taskStorelets you replace the default in-memory task store
import { createRemoteStateClient } from "remotestate";
type CounterService = {
increment(): Promise<void>;
compute(x: number): Promise<number>;
};
const client = createRemoteStateClient<CounterService>("http://localhost:9753");client.action(method, args?, kwargs?, options?) calls a Python @action.
taskIdenables progress tracking for the callawaitInvalidatewaits for the resulting store update before resolving
client.query(method, args?, kwargs?, options?) calls a Python @query and returns the typed result.
Provider and Hooks
RemoteStateProvider exposes one client to child components.
urlcreates a remote client when it is present and non-blankfallbackcreates a local client whenurlis missing or blankclientlets you inject an already-created clienttaskStoreis passed through tocreateRemoteStateClientwhen the provider creates the client itself- owned clients are disposed automatically when the provider unmounts
import { RemoteStateProvider } from "remotestate";
export function App() {
return (
<RemoteStateProvider url="ws://localhost:9753/ws">
{/* ... */}
</RemoteStateProvider>
);
}Hook overview:
useRemoteStateClient<S>()returns the nearest typed clientuseRemoteStore()returns the reactive value store behind the hooksuseRemoteTaskStore()returns the task store behind the progress hooksuseRemoteStateValue(path?)subscribes to one path and returns the cached value orundefined; omitpathto subscribe to the root state valueuseRemoteState(path, initialValue?)behaves like ReactuseStatefor one remote pathuseRemoteTask(taskId)returns one tracked task snapshotuseRemoteTasks()returns all tracked task snapshots
import { useRemoteState, useRemoteTask, useRemoteTasks } from "remotestate";
const [count, setCount] = useRemoteState<number>("count", 0);
const saveTask = useRemoteTask("save");
const allTasks = useRemoteTasks();Fallback Clients
createLocalStateClient<S>(options) wraps local application state in a RemoteState-compatible client.
storeis requiredactionsandqueriesprovide local implementations of the service contracttaskscan replace the default in-memory task storedisposeruns when the local client is released
This is the main building block for RemoteStateProvider fallback mode, i.e., if the WebSocket
URL was not provided to the RemoteStateProvider.
Find an example in the section User Guide below.
Path Module
Low-level path helpers live in remotestate/path:
PathPathInputPathSegmentPathSegmentInputnormalizePath(path)validates aPathInputvalue and returns aPathparsePath(path)turns a strict string path into aPathand throwsSyntaxErroron malformed inputformatPath(path)turns parsed segments back into canonical path syntaxgetPathAt(value, path)reads a nested valuesetPathAt(value, path, nextValue)writes a nested value without mutating when nothing changesisPrefixPath(prefixPath, path)checks whether one path is a prefix of anotherpathsOverlap(leftPath, rightPath)checks whether either path is a prefix of the othergetRelativePath(prefixPath, path)removes a prefix from a path or returnsnull
RemoteState paths use a simplified JSONPath
subset without the $. prefix:
- an empty path addresses the root state value
- the first segment may be an identifier, bracketed integer index, or bracketed JSON string key
- later segments may be dotted identifiers, bracketed integer indices, or bracketed JSON string keys
- bracketed string keys may use either single or double quotes; canonical output uses double quotes
- the whole string must match the grammar; prefix parsing is not allowed
| Example | Valid? | Notes |
| ------------------------ | ------ | ----------------------------------------------------- |
| user | yes | root identifier only |
| items[0].label | yes | dotted identifier plus integer index |
| ["display name"].value | yes | bracketed string key at the root |
| user["display name"] | yes | bracketed string key |
| $.user | no | $. prefix is not part of the syntax |
| items[01] | no | indices are canonical integers without leading zeroes |
import {
getPathAt,
normalizePath,
parsePath,
setPathAt,
} from "remotestate/path";
const path = parsePath("items[0].label");
const labelPath = parsePath('user["display name"]');
const rootPath = parsePath("");
const arrayRootPath = normalizePath([0, "label"]);
const safePath = normalizePath(["items", 0, "label"]);
const current = getPathAt(state, path);
const next = setPathAt(state, path, "updated");Task Stores
RemoteState tracks long-running actions and queries as task snapshots.
createRemoteTaskStore()returns the default in-memory task storeTaskStoreImplis the concrete store implementationuseRemoteTaskStore()anduseRemoteTasks()let React components observe progressuseRemoteTask(taskId)is useful when you know the task identifier up front
User Guide
Direct Client
Use createRemoteStateClient(url) when you want a standalone bridge object.
The URL must be provided explicitly.
import { createRemoteStateClient } from "remotestate";
type CounterService = {
increment(): Promise<void>;
compute(x: number): Promise<number>;
};
const client = createRemoteStateClient<CounterService>(
"ws://localhost:9753/ws",
);client exposes action() and query() methods together with the reactive
store and task store used by the React hooks.
The low-level store can be observed by path:
const path = parsePath("items[1].label");
const unsubscribe = client.store.subscribe(path, () => {
console.log(client.store.get(path));
});
console.log(client.store.get()); // root state valueSubscriptions also react to related parent or child updates. For example, a
listener on "items" fires when "items[1].label" changes.
Using a Remote State
Use RemoteStateProvider when your app always expects a RemoteState backend.
The hooks below bind React components to the Python-owned "count" state and
the typed increment action.
import {
RemoteStateProvider,
useRemoteStateClient,
useRemoteState,
} from "remotestate";
type CounterService = {
increment(): Promise<void>;
compute(x: number): Promise<number>;
};
function Counter() {
const client = useRemoteStateClient<CounterService>();
const [count, setCount] = useRemoteState<number>("count", 0);
return (
<div>
<p>Count: {count ?? "..."}</p>
<button onClick={() => void setCount((prev) => (prev ?? 0) + 1)}>
Set from React
</button>
<button onClick={() => void client.action("increment")}>
Run backend action
</button>
</div>
);
}
export function App() {
return (
<RemoteStateProvider url="ws://localhost:9753/ws">
<Counter />
</RemoteStateProvider>
);
}Optional Remote State With Local State Fallback
Some applications can run with or without a RemoteState backend. For example,
an addon might use Python-owned state when a RemoteState URL is configured, but
fall back to the app's existing local state store when no backend is available.
The provider always exposes a client: it creates a remote client when url is a
non-empty string, otherwise it calls fallback. If neither url nor
fallback is provided, the provider throws.
Fallback clients use the same RemoteStateClient shape as remote clients, so
the standard hooks keep working and remain reactive. The example below adapts a
Zustand store for local fallback mode.
When you implement a fallback store, the store methods receive parsed path
segments. An empty path addresses the root state value. getPathAt() and setPathAt() are the shared path
helpers to use for nested reads and writes. setPathAt() preserves the
original identity when the target value is unchanged, which makes it easy to
skip unnecessary Zustand updates and avoid extra renders.
import type { ReactNode } from "react";
import {
RemoteStateProvider,
createLocalStateClient,
type LocalActionHandlers,
type LocalQueryHandlers,
type RemoteStateClient,
type Store,
} from "remotestate";
import { getPathAt, setPathAt, type Path } from "remotestate/path";
import { useCounterStore } from "./counterStore";
type CounterService = {
increment(): Promise<void>;
};
type CounterActions = LocalActionHandlers<CounterService>;
type CounterQueries = LocalQueryHandlers<CounterService>;
function createLocalCounterClient(): RemoteStateClient<CounterService> {
const isCountProperty = (path: Path) =>
path.length === 1 && path[0] === "count";
const store: Store = {
// Read the current local value for one RemoteState path.
get: (path: Path = []): unknown => {
if (isCountProperty(path)) {
return getPathAt(useCounterStore.getState(), path);
}
},
// Write one RemoteState path; useRemoteState() setters call this method.
set: (path: Path, value: unknown): void => {
if (isCountProperty(path)) {
const currentState = useCounterStore.getState();
const nextState = setPathAt(currentState, path, value);
if (nextState !== currentState) {
useCounterStore.setState(nextState);
}
}
},
// Re-render hook consumers when the subscribed path changes.
subscribe: (path: Path, listener: () => void): (() => void) => {
if (isCountProperty(path)) {
return useCounterStore.subscribe(listener);
}
return () => {};
},
// Ensure a path is available; local tasks are already available here.
provide: (_path: Path): void => {},
// Release local resources owned by this store, if any.
dispose: (): void => {},
};
const actions: CounterActions = {
// Implement local equivalents for client.action("increment").
increment: (): void => {
useCounterStore.getState().increment();
},
};
const queries: CounterQueries = {
// Add local equivalents for client.query(...) methods. Not used here.
};
return createLocalStateClient<CounterService>({
// Reactive store used by useRemoteStateValue() and useRemoteState().
store,
// Local service actions used by useRemoteStateClient().action(...).
actions,
// Local service queries used by useRemoteStateClient().query(...).
queries,
});
}
export function CounterStateProvider({
remoteUrl,
children,
}: {
remoteUrl?: string | null;
children: ReactNode;
}) {
return (
<RemoteStateProvider url={remoteUrl} fallback={createLocalCounterClient}>
{children}
</RemoteStateProvider>
);
}Components can now use useRemoteState(), useRemoteStateValue(), and
useRemoteStateClient() in both modes. When url changes between absent and
present, the provider switches clients and disposes the client it created. It
does not sync state between the fallback client and the remote client.
