Quickstart¶
Install¶
indah is pure-Python to install and run - no Node, npm, or bun, because the frontend ships pre-built inside the wheel.
Optional extras¶
The base install keeps four dependencies. indah never imports the libraries below itself: it accepts their objects by duck-typing, so you only need an extra when your app uses the library and you want a tested version range installed with it.
| Install | Adds | For |
|---|---|---|
pip install "indah[plot]" |
matplotlib |
Plot(figure) |
pip install "indah[data]" |
pandas, numpy |
DataFrame and Table from a pandas frame, numpy values in cells |
pip install "indah[all]" |
plot + data |
both of the above |
pip install "indah[demos]" |
numpy, pillow, onnxruntime, huggingface_hub, matplotlib |
running the gallery demos and the examples |
Quote the brackets in zsh. The extras do not make the install smaller; they only keep heavy
libraries out of the base install. The reasons are in ADR-0042.
Your first app¶
An app is a tree of components bound to reactive signals. Mutate a signal and only the components that read it update.
import indah
from indah import Signal, computed, Column, Slider, Text, Session
a, b = Signal(2), Signal(3)
total = computed(lambda: f"a + b = {a.value + b.value}")
page = Column(
children=[
Slider(a, min=0, max=10, label="a"),
Slider(b, min=0, max=10, label="b"),
Text(total),
]
)
indah.launch(indah.create_app(session=Session(page)))
Signalholds a value. Read it with.value, set it with.set(...).computedderives a value from other signals and recomputes only when one of them changes.Columnlays its children out vertically; components likeSliderandTextbind to a signal or computed.launch()binds a free port, starts the single-port ASGI app, and prints the URL. In a notebook it also embeds the app inline in the cell.
Try it here. This is the same app, running in your browser with no server. Nothing is downloaded until you press Run it, and then it loads about 9 MB (compressed) once. With JavaScript off you still see the example's source and summary, without the button.
def quickstart(ctx: indah.Context) -> indah.Component:
a, b = indah.Signal(2), indah.Signal(3)
total = indah.computed(lambda: f"a + b = {a.value + b.value}")
return indah.Column(
children=[
indah.Slider(a, min=0, max=10, label="a"),
indah.Slider(b, min=0, max=10, label="b"),
indah.Text(total),
]
) Loads about 9 MB, then runs in your browser. Open in the Studio (make demo, then c/start/quickstart)
Move a slider and the text changes. Only the Text that reads total is updated; the sliders
and the layout are not sent again.
Launching in each environment¶
These snippets reuse page from the app above. Run the cell. launch() detects the platform, binds the port, and prints the
proxy URL (and embeds the app inline). No tunnel, no WebSocket, no Node.
The same call works in a local JupyterLab. The app renders inline in the output cell.
Try it live. Nothing downloads until you press Run it.
create_app, launch. Each tab gets its own session; the code below is the complete file.import indah
def build(ctx): # called once per browser tab: state is per tab
n = indah.Signal(0)
return indah.Session(indah.Column(children=[
indah.Button("Add one", lambda: n.set(n.value + 1)),
indah.Text(lambda: f"n = {n.value}"),
]))
app = indah.create_app(session_factory=build) # an ASGI app: mount it anywhere
indah.launch(app) # or run it, in a script or a notebook cell Loads about 9 MB, then runs in your browser. Open in the Studio (make demo, then c/start/first-app)
Streaming and async work¶
Heavy work runs async and streams into the UI instead of freezing it. A Button
handler can be async def; a StreamText grows token by token over SSE while the
rest of the page stays live.
import indah
from indah import Signal, Button, TextInput, StreamText, Column, Session
prompt = Signal("")
out = StreamText(label="Response")
async def on_generate():
out.reset()
async for token in indah.mock_llm(prompt.value): # any async generator
out.feed(token)
page = Column(
children=[
TextInput(prompt, label="Prompt"),
Button("Generate", on_click=on_generate),
out,
]
)
indah.launch(indah.create_app(session=Session(page)))
indah.mock_llm is a stand-in async generator. Swap in a real model client with
the same shape - it never imports indah, so your generation logic stays plain
Python.
Batching writes¶
Writing two signals that one effect reads would normally run the effect twice. Wrap the
writes in indah.batch to run it once, after both:
import indah
from indah import Signal, effect
first, last = Signal(""), Signal("")
effect(lambda: print(f"name: {first.value} {last.value}"))
indah.batch(lambda: (first.set("Ada"), last.set("Lovelace"))) # one run, after both
first.set("Grace") # outside a batch: one run per write
The effect runs once when it is created, once for the batch, and once for the last write:
You rarely need it in a click or input handler: indah already runs every synchronous handler
inside a batch, so two writes there cause one update. batch matters after an await, where
that wrapper has ended, and in background tasks and threads you start yourself.
Try it live. Nothing downloads until you press Run it.
effect runs once, then again whenever a signal it read changes. Type in the box.def effect_page(ctx: indah.Context) -> indah.Component:
name = indah.Signal("Ada")
log = indah.Signal([])
def note() -> None: # reads `name`, so it reruns whenever `name` changes
log.set([*log.peek(), f"name is now {name.value!r}"])
indah.effect(note) # runs once now, then on every change
return indah.Column(
children=[
indah.TextInput(name, label="Name"),
indah.Text(lambda: "\n".join(f"- {line}" for line in log.value), markdown=True),
]
) Loads about 9 MB, then runs in your browser. Open in the Studio (make demo, then c/live/effect)
batch it reruns once. (A sync click handler is batched for you; this matters after an await or in a task.)def batch_page(ctx: indah.Context) -> indah.Component:
first, second = indah.Signal(0), indah.Signal(0)
runs = indah.Signal(0)
seen = {"n": 0}
def watcher() -> None: # an effect that reads both signals
first.value, second.value # noqa: B018
seen["n"] += 1
runs.set(seen["n"])
indah.effect(watcher)
# A sync click handler is already batched by indah. Code after an `await` (or in a background
# task) is not, so each write reruns the effect unless you wrap the writes in `batch`.
async def separately() -> None:
await asyncio.sleep(0)
first.set(first.value + 1)
second.set(second.value + 1) # two writes: the effect reruns twice
async def together() -> None:
await asyncio.sleep(0)
def both() -> None:
first.set(first.value + 1)
second.set(second.value + 1)
indah.batch(both) # the same two writes: the effect reruns once
return indah.Column(
children=[
indah.Row(
children=[
indah.Button("Write both", separately),
indah.Button("Write both in a batch", together, variant="tonal"),
]
),
indah.Text(lambda: f"first={first.value}, second={second.value}"),
indah.Text(lambda: f"The effect has run {runs.value} time(s)."),
]
) Loads about 9 MB, then runs in your browser. Open in the Studio (make demo, then c/live/batch)
Next¶
- Playground - edit this app and run it in your browser.
- Components - the full starter set.
- Custom components - add your own.