posit-dev / posit-dev/shinyreact

Typed contract: Python-defined data models as the single source of truth

Open
#50 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

theme: prototype
Dominant language
TypeScript
Stars
14
Forks
3
Avg merge
9h 12m
Merged PRs (30d)
74

Description

Typed contract: Python-defined data models as the single source of truth

The problem

When a shinyreact app breaks, the developer has no idea whether the bug is in their Python, their React, or the invisible string-matching that connects them.

Today, the connection between Python server functions and React components is a bare string — "scatter_data", "user_text". Nothing checks that the strings match. Nothing checks that the data shapes agree. A typo on either side produces silence, not an error.

Concrete DX gaps

1. No contract between Python and JS. A rename from scatter_data to scatter_plot_data in Python silently breaks useShinyOutput("scatter_data") in React — no error, just a component stuck at its default value forever.

2. No types across the bridge. useShinyOutput("scatter_data", null) returns any. The developer must read the Python source to know the shape is { age: number[], score: number[] }. The Python side has no way to declare what shape reactive_output produces — Jsonifiable is the entire type.

3. No "wiring diagram." In traditional Shiny, you read the UI function top-to-bottom and see the layout with output placeholders inline. In shinyreact's SPA model, the Python server is a flat list of functions with no spatial relationship to the React components that consume them. The only way to understand the wiring is to grep both codebases for matching strings.

4. Default values are load-bearing but unvalidated. Every useShinyOutput call requires a defaultValue that the component renders before the server responds. There's no way to distinguish "hasn't loaded yet" from "server returned the default," and no validation that the server's response matches what the component expects.

The core insight

All of these problems stem from the same root cause: the data contract between Python and React is implicit. It exists only as string literals scattered across two codebases in two languages. The framework has no model of what should flow between them, so it can't validate, type-check, generate code, or provide diagnostics.

Proposal: Python models as the shared contract

The developer declares their inputs and outputs as typed Python models. The framework enforces the contract, generates TypeScript types, and makes the wiring visible.

The source of truth lives in Python because that's where app developers spend most of their time. TypeScript is derived, not authored.


User API

Inputs: a typed Python class
from shinyreact import InputModel, Input

class Inputs(InputModel):
    user_text = Input("")
    button_trigger = Input(0, priority="event")
    tags = Input[list[str]]([])

inputs = Inputs()

Each field declares a name (from the attribute), a type (inferred from the default or specified explicitly), and a default value. Reading an input registers a reactive dependency:

inputs.user_text()       # -> str, registers reactive dependency
inputs.user_text.get()   # equivalent

For complex types where inference isn't sufficient:

Input[list[str]]([])           # explicit generic
Input([], type=list[str])      # equivalent with type= kwarg

Input options like priority are co-located with the declaration:

Input(0, priority="event")     # not scattered across hook calls
Implementation: descriptors and reactive values

Input is a Python descriptor. At the class level it stores metadata (default, type, priority). At the instance level, __get__ returns a _BoundInput that wraps a reactive.Value.

The descriptor approach gives correct types with no special tooling. Input("") constructs Input[str], pyright infers T=str, and __get__ returns Input[T] — so inputs.user_text is Input[str], inputs.user_text() returns str, and inputs.user_text.update(...) accepts str.

InputModel.__init_subclass__ collects all Input descriptors and builds a pydantic model via create_model(). This model serves two purposes:

  1. Validationmodel_validate(raw_dict) validates incoming client values with full pydantic error messages.
  2. JSON Schema / codegenmodel_json_schema() produces the complete schema for TypeScript generation.

At instance creation, each descriptor gets a _BoundInput + reactive.Value pair. reactive.Value(default, read_only=True) prevents direct .set() calls — the framework uses ._set() internally when client values arrive.

Updating inputs from the server

The server can request client-side input changes, mirroring Shiny's update_* pattern:

inputs.user_text.update("new value")

This sends a message to the React client, which updates its component state. The server is requesting a change, not directly mutating — the client remains the source of truth.

Implementation: update mechanism

When inputs.foo.update("new") is called:

  1. Python sends via send_message(session, "__update_input:foo", "new").
  2. This calls session.send_custom_message("shinyReactMessage", {"type": "__update_input:foo", "data": "new"}).
  3. On the JS side, a framework-level handler receives __update_input:* messages and routes them to the input registry, calling setValue on the corresponding hook's state.

For generated TypeScript hooks, the update handler is wired in automatically:

export function useUserText(): [string, (value: string) => void] {
  const [value, setValue] = useShinyInput<string>("user_text", "");
  useShinyMessageHandler("__update_input:user_text", (data: string) => {
    setValue(data);
  });
  return [value, setValue];
}
Outputs: typed return values
from pydantic import BaseModel

class ScatterData(BaseModel):
    age: list[float]
    score: list[float]

@reactive_output
def scatter_data() -> ScatterData:
    return ScatterData(age=[25, 30, 35], score=[85.5, 92.1, 88.3])

The function name is the output ID. The return type is the data contract. If the function returns data that doesn't match the model, it fails loudly in Python — not silently in the browser.

For simple scalar outputs, no model class is needed:

@reactive_output
def processed_text() -> str:
    return inputs.user_text().upper()
Implementation: output validation

reactive_output introspects the decorated function's return type annotation at decoration time. When the return type is a pydantic BaseModel subclass, the renderer validates the return value before sending it to the client:

class reactive_output(Renderer[Jsonifiable]):
    def __init__(self, fn):
        super().__init__(fn)
        hints = get_type_hints(fn)
        ret = hints.get("return")
        self._return_model = ret if (ret and issubclass(ret, BaseModel)) else None

    async def transform(self, value: Jsonifiable) -> Jsonifiable:
        if self._return_model is not None:
            value = self._return_model.model_validate(value)
            return value.model_dump()
        return value

For non-BaseModel return types (e.g., -> str, -> int), pydantic's TypeAdapter provides the same validation. This is a small, self-contained change to reactive_output that can be implemented independently of the input model work.

Namespacing

Inputs and outputs share a namespace scope via a context manager:

from shinyreact import namespace

def my_module(ns: str):
    with namespace(ns):
        inputs = MyInputs()       # IDs become "mod1-user_text", etc.

        @reactive_output
        def scatter_data(): ...   # output ID becomes "mod1-scatter_data"

Nesting composes naturally:

with namespace("outer"):
    with namespace("inner"):
        inputs = MyInputs()  # IDs: "outer-inner-user_text", etc.
Implementation: ContextVar-based namespacing

A ContextVar stores the active namespace. Both InputModel.__init__ and reactive_output read it at definition time (while the with block is active):

_current_namespace: ContextVar[str | None] = ContextVar("_current_namespace", default=None)

@contextmanager
def namespace(ns: str):
    parent = _current_namespace.get()
    full_ns = f"{parent}-{ns}" if parent else ns
    token = _current_namespace.set(full_ns)
    try:
        yield
    finally:
        _current_namespace.reset(token)

The - separator matches Shiny's existing module namespace convention. Integration with Shiny's module.resolve_id() and ModuleSession infrastructure needs to be resolved during implementation.

The full picture

A complete app reads top-to-bottom as: here are my inputs, here are my outputs.

from pydantic import BaseModel
from shinyreact import InputModel, Input, reactive_output
from shiny import reactive

# What the client sends
class Inputs(InputModel):
    user_text = Input("")
    button_trigger = Input(0, priority="event")

inputs = Inputs()

# What the server sends back
class ScatterData(BaseModel):
    age: list[float]
    score: list[float]

@reactive_output
def scatter_data() -> ScatterData:
    return ScatterData(age=[25, 30, 35], score=[85.5, 92.1, 88.3])

@reactive_output
def processed_text() -> str:
    return inputs.user_text().upper()

@reactive_output
@reactive.event(inputs.button_trigger, ignore_init=True)
def button_response() -> str:
    return f"Clicked {inputs.button_trigger()} times"

No app = ..., no server function, no wiring boilerplate.

TypeScript side: generated, not hand-written

From the Python models, a CLI command (shinyreact codegen app.py) generates typed React hooks:

// auto-generated from Python models
export function useScatterData() {
  return useShinyOutput<{ age: number[]; score: number[] }>("scatter_data", null);
}

export function useUserText(): [string, (value: string) => void] {
  return useShinyInput<string>("user_text", "");
}

export function useButtonTrigger(): [number, (value: number) => void] {
  return useShinyInput<number>("button_trigger", 0, { priority: "event" });
}

The React developer imports useScatterData() instead of writing useShinyOutput("scatter_data", null). No string IDs to get wrong. Full autocomplete. Types enforced on both sides.

Implementation: codegen pipeline

Every contract has a pydantic model behind it:

  • Inputs: InputModel._pydantic_model, built automatically by __init_subclass__.
  • Outputs: the user's BaseModel subclass from the reactive_output return type annotation.

Both expose .model_json_schema(), so the codegen pipeline is uniform: discover models → call model_json_schema() → convert JSON Schema to TypeScript.

The JSON Schema → TypeScript mapping:

JSON Schema TypeScript
{"type": "string"} string
{"type": "integer"} / {"type": "number"} number
{"type": "boolean"} boolean
{"type": "array", "items": ...} T[]
{"type": "object", "properties": ...} Interface with named fields
{"anyOf": [..., {"type": "null"}]} T | null

Existing tools like json-schema-to-typescript could handle this, or we write a minimal converter tuned to pydantic's output subset.


What this unlocks

Today Proposed
Inputs are untyped — input.user_text() returns Any Inputs are typed — inputs.user_text() returns str
Outputs are untyped — reactive_output accepts any dict Outputs are typed — reactive_output validates against the model
No way to update inputs from the server inputs.user_text.update("new") sends update to React client
Connection is invisible string matching Connection is an explicit, inspectable contract
Typos produce silence Typos produce errors (Python) or red squiggles (TypeScript)
Data shape documented nowhere Data shape documented in the model, enforced at runtime

Beyond correctness, the contract enables dev-mode diagnostics: the framework knows what should exist on both sides. It can warn at startup: "Output scater_data has no subscriber — did you mean scatter_data?" It can print a table of all inputs/outputs, their types, and their status — the "wiring diagram" that's currently invisible.


Scope and future directions

This proposal targets the use case where UI lives primarily in TypeScript (index.tsx) and Python defines the data/logic layer. The typed contract makes this story significantly more usable by eliminating the string-matching guesswork and providing end-to-end type safety.

However, the same foundational ideas — typed input models, validated output schemas, namespace scoping, codegen from Python declarations — could also serve a more "batteries included" approach where a Python UI toolkit generates the React components. In that world, the Python models would drive both the server logic and the UI generation, making the contract even more central. The InputModel / reactive_output typing infrastructure proposed here would be the foundation that a higher-level Python UI layer builds on top of.

For example, a component library could subclass Input to add UI semantics, and Shiny UI functions could return instances of these classes:

from shinyreact import Input

class TextInput(Input[str]):
    def __init__(self, default: str = "", *, label: str = "", placeholder: str = ""):
        super().__init__(default)
        self.label = label
        self.placeholder = placeholder

class SliderInput(Input[float]):
    def __init__(self, default: float, *, label: str = "", min: float = 0, max: float = 100, step: float = 1):
        super().__init__(default)
        self.label = label
        self.min = min
        self.max = max
        self.step = step

class Inputs(InputModel):
    user_text = TextInput(label="Enter text", placeholder="Type here...")
    temperature = SliderInput(0.7, label="Temperature", min=0, max=1, step=0.1)

inputs = Inputs()
inputs.user_text()      # -> str, same typed reactive API as before
inputs.temperature()    # -> float

The subclass carries enough metadata for the framework to generate the corresponding React component automatically — the developer never writes JSX for standard inputs. Custom/novel UI still uses the base Input() + hand-written TypeScript path from this proposal.

R implementation

The same architecture applies to R. R doesn't have pydantic or generics, but the ellmer package provides a composable type_*() API (type_string(), type_integer(), type_object(), etc.) for type specifications that already produce JSON Schema. An R InputModel could draw inspiration from this vocabulary:

inputs <- input_model(
  user_text = input(type_string(), default = ""),
  count = input(type_integer(), default = 0L),
  button = input(type_integer(), default = 0L, priority = "event")
)

inputs$user_text()           # read (reactive)
inputs$user_text$update("new")  # update

This wouldn't provide static type checking (R doesn't have that infrastructure), but it would provide runtime validation of client values and feed the same TypeScript codegen pipeline — the generated hooks are identical regardless of whether the server is Python or R.


Implementation order

  1. Output validation — add pydantic return type introspection to reactive_output. Smallest change, highest immediate value, no new API surface.
  2. Input + InputModel — core descriptor API. Verify types with pyright. Unit test reactive reads.
  3. Session binding — wire InputModel into SpaApp's session lifecycle. Resolve multi-session semantics.
  4. update() mechanism — Python → JS round-trip via send_message + useShinyMessageHandler.
  5. namespace() context manager — ContextVar-based ID prefixing with nesting support.
  6. TypeScript codegen — CLI tool generating typed hooks from model metadata.
  7. Input validation — pydantic model_validate() on incoming client data.
Multi-session considerations

When multiple browser sessions connect to the same app, each needs its own reactive.Value instances. Two approaches:

  1. Clone on session start_bind_inputs creates fresh values per session; module-level InputModel acts as a template.
  2. Session-keyed storage_BoundInput.__call__ looks up the current session's value dynamically (closer to how Shiny's Inputs class works internally).

To be resolved during implementation of step 3.

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start by mapping the proposed InputModel, reactive_output, namespace, client update handling, and codegen CLI entry points. Break the work into independently scoped areas and define acceptance checks for validated Python contracts, generated TypeScript hooks, namespaced IDs, and typed input/output behavior before implementation.

Written by the indexing model from the issue text.

Assessment

Tech stack
python, react, typescript
Domain
backend-api-design, developer-experience, full-stack, tooling
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
25/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.