Crash course: coming from React, Vue or Django

You already know how the web works: requests, components, state, an API, a database. This page maps what you know onto Rational Web, and calls out the three ideas that are genuinely different. Reading it takes ten minutes; the code you write is real and runs as printed.

Install and run

$ python -m pip install "rational[db]"
$ python app.py --dev     # hot reload, live browser reload, readable error pages

There is no build step, no package.json, no bundler, no CORS. Pages are served at /, your JSON API at /api (interactive docs at /api/docs), framework assets at /_rational — one origin, one process.

The mental model in three sentences

  1. Your components are server-side Python objects. Their render() produces HTML on the server, and State attributes are reactivity roots: writing one marks the components that read it dirty, and the server sends back just their HTML.

  2. The browser is a thin delivery mechanism. A ~4 KiB runtime wires events to the server, applies the HTML patches (preserving focus and caret), and runs the small closed set of js.* operations locally. There is no client-side component tree, no hydration, no virtual DOM, and no client router.

  3. The network boundary is explicit. Every event handler is a server round trip you can see in the network tab. Only methods marked @remote are addressable as HTTP; nothing else is callable from the browser, by accident or otherwise.

Translation table

What you know

What Rational Web does

<Component> + JSX/templating

class Thing(Component) returning Python objects

props

annotated class attributes (name: str = "Ada")

useState

count = State(0) (also page and session scopes)

useEffect / fetch-on-mount

async def mount(self) — runs on the server

onClick={fn}

Button("Go", on_click=self.go)

controlled input

Input(..., on_input=self.set_x) (debounced server-side)

re-render + diff/hydration

server re-render of dirty components + DOM morph patch

context / store (Redux, Pinia)

State(scope="session") — server-side, per visitor

CSS-in-JS / Tailwind classes

Style(...) values compiled to hashed CSS classes

react-router / vue-router

@app.page("/posts/{slug}") — routing is on the server

fetch("/api/x") from the client

self.use(MyService).x() — a typed Python call

Express/FastAPI/Laravel controllers

Service classes with @remote methods

ORM models

SQLAlchemy 2.0 async models (no second query language)

A component, for orientation

from rational import Button, Card, Component, State, Text


class Tagger(Component):
    tags = State(factory=list)

    def add(self, value: str):
        self.tags = [*self.tags, value]   # plain Python: assign a new value

    def render(self):
        return Card(
            Text(" ".join(f"#{t}" for t in self.tags) or "No tags yet"),
            Button("Add #python", on_click=lambda: self.add("python")),
        )

Clicking the button POSTs to the server, add runs on the server, and the response carries only the card’s new HTML. It feels like client-side reactivity; the mechanism is a round trip costing well under a millisecond of server time (see Benchmarks).

Two rules that prevent most confusion:

  • render() must not change state — do that in handlers or mount().

  • Components in a list need key= so instances keep their state across re-renders — same idea as React keys, same failure mode when omitted.

Data: services instead of fetch calls

In a JS stack your component fetches an endpoint. Here, a service owns the rules and the queries, and both pages and the JSON API call it:

from rational import FieldError
from rational.backend import Service, remote


class TodoService(Service):
    @remote(method="GET")
    async def list_todos(self) -> list[TodoOut]:
        """Every to-do for the current user."""
        ...

    @remote
    async def add(self, title: str) -> TodoOut:
        if not title.strip():
            raise FieldError("title", "Give it a name")   # shown under the field
        ...

The same method is two things at once: self.use(TodoService).add(title) from any component, and a validated, documented POST /api/todo/add with an OpenAPI schema — the endpoint you’d otherwise hand-write. FieldError places the message under the right form field, exactly where a client-side validation library would.

Forms are validated from handler annotations (Pydantic): pass a form model as the handler’s parameter and invalid input never reaches your code.

Styling without a preprocessor

from rational import Style

CARD = Style(padding="md", shadow="lg", viewport={"narrow": Style(shadow="none")})
hero = Style(background="primary", color="on_primary") | Style(padding_y="xl")

Style values are immutable and compose with |. The compiler emits one hashed class per element — no specificity wars, no unused CSS, no purge step. Themes (light/dark/system) are CSS custom properties; viewport= is your media query and container= your container query. Anything it can’t express goes through the raw= escape hatch as plain CSS.

Escape hatches (use them without guilt)

  • h("iframe", src=...) — any HTML element.

  • Style(raw={...}) — any CSS.

  • A FastAPI APIRouter mounted as-is — the blog example serves RSS this way.

  • Hand-written JavaScript if you truly need it; pages without handlers ship zero JS.