Skip to main content

React-like state management for Textual TUI framework

Project description

Textual Reactive

React-like state management for the Textual TUI framework, with Pydantic support.

Features

  • use_state - Simple reactive state (like React's useState)
  • use_reducer - Complex state with actions and reducers (like React's useReducer)
  • use_derived - Computed values that update automatically (like Svelte's derived stores)
  • @effect - Decorator to watch specific state changes (like React's useEffect with dependencies)
  • Context system - Share state across widget trees without prop drilling
  • Pydantic support - Full integration with Pydantic models for validated state

Installation

pip install textual-reactive

Or with uv:

uv add textual-reactive

Quick Start

from dataclasses import dataclass
from pydantic import BaseModel
from textual.app import App, ComposeResult
from textual.widgets import Button, Static

from textual_reactive import (
    create_reducer_context,
    use_reducer,
    use_reducer_context,
    ReducerProvider,
    effect,
)


# 1. Define your state with Pydantic
class CounterState(BaseModel):
    count: int = 0


# 2. Define actions as dataclasses
@dataclass
class Increment:
    pass

@dataclass
class Decrement:
    pass


# 3. Create a reducer function
def counter_reducer(state: CounterState, action) -> CounterState:
    match action:
        case Increment():
            return state.model_copy(update={"count": state.count + 1})
        case Decrement():
            return state.model_copy(update={"count": state.count - 1})
    return state


# 4. Create a context to share the reducer
CounterContext = create_reducer_context("counter")


# 5. Build your widgets
class CounterDisplay(Static):
    def on_mount(self) -> None:
        self.counter = use_reducer_context(self, CounterContext)

    @effect("counter")
    def on_counter_change(self, old: CounterState, new: CounterState) -> None:
        self.update(f"Count: {new.count}")


class CounterApp(App):
    def compose(self) -> ComposeResult:
        # Create reducer at the top level
        self.counter = use_reducer(self, counter_reducer, CounterState(), name="counter")

        # Provide to children
        yield ReducerProvider(CounterContext, self.counter,
            CounterDisplay(),
            Button("+ Increment", id="inc"),
            Button("- Decrement", id="dec"),
        )

    def on_button_pressed(self, event: Button.Pressed) -> None:
        match event.button.id:
            case "inc":
                self.counter.dispatch(Increment())
            case "dec":
                self.counter.dispatch(Decrement())


if __name__ == "__main__":
    CounterApp().run()

API Reference

State Hooks

use_state(widget, initial_value, *, name=None)

Create simple reactive state bound to a widget.

class MyWidget(Widget):
    def on_mount(self):
        self.count = use_state(self, 0, name="count")

    def increment(self):
        self.count.set(lambda x: x + 1)
        # or: self.count.set(5)

use_reducer(widget, reducer, initial_value, *, name=None)

Create reducer-based state for complex state logic.

def reducer(state, action):
    match action:
        case Increment():
            return state + 1
    return state

class MyWidget(Widget):
    def on_mount(self):
        self.counter = use_reducer(self, reducer, 0, name="counter")

    def increment(self):
        self.counter.dispatch(Increment())

use_derived(widget, source, selector, *, name=None)

Create computed values that update when the source changes.

class TodoList(Widget):
    def on_mount(self):
        self.todos = use_reducer_context(self, TodoContext)

        # Derived values
        self.total = use_derived(self, self.todos, lambda t: len(t.items), name="total")
        self.completed = use_derived(
            self,
            self.todos,
            lambda t: len([i for i in t.items if i.done]),
            name="completed"
        )

    @effect("total")
    def on_total_change(self, old: int, new: int):
        self.query_one("#total").update(f"Total: {new}")

Context System

create_reducer_context(name=None)

Create a context for sharing a reducer across the widget tree.

# In a shared module (e.g., contexts.py)
TodoContext = create_reducer_context("todos")

ReducerProvider

Provide a reducer to descendant widgets.

class App(App):
    def compose(self):
        self.todos = use_reducer(self, todo_reducer, TodoState(), name="todos")

        yield ReducerProvider(TodoContext, self.todos,
            Header(),
            TodoList(),
            Footer(),
        )

use_reducer_context(widget, context, *, subscribe=True)

Consume a reducer from context in a child widget.

class TodoList(Widget):
    def on_mount(self):
        self.todos = use_reducer_context(self, TodoContext)
        # self.todos.value -> current state
        # self.todos.dispatch(action) -> dispatch action

Effects

@effect(*targets)

Decorator to run a method when specific state changes.

class MyWidget(Widget):
    def on_mount(self):
        self.count = use_state(self, 0, name="count")
        self.name = use_state(self, "", name="name")

    @effect("count")
    def on_count_change(self, old: int, new: int):
        self.refresh()

    @effect("count", "name")  # multiple targets
    def on_any_change(self, old, new):
        self.save()

Store Pattern (Alternative)

For simpler cases, use create_store which combines reducer and context:

from textual_reactive import create_store, effect

# Create store
TodoStore = create_store(todo_reducer, TodoState(), name="todos")

class App(App):
    def compose(self):
        yield TodoStore.provider(
            TodoList(),
        )

class TodoList(Widget):
    def on_mount(self):
        self.todos = TodoStore.use(self)

    @effect(TodoStore)  # Can use store as effect target
    def on_change(self, old, new):
        self.refresh()

Pydantic Models

All hooks work seamlessly with Pydantic models:

class UserState(BaseModel):
    name: str = ""
    email: str = ""
    preferences: dict = {}

class MyWidget(Widget):
    def on_mount(self):
        self.user = use_model_state(self, UserState(), name="user")

    def update_name(self, name: str):
        # Update single field
        self.user.update(name=name)

    def update_all(self, user: UserState):
        # Replace entire model
        self.user.set(user)

Comparison with React

React Textual Reactive
useState use_state
useReducer use_reducer
useContext use_reducer_context / use_context
useEffect with deps @effect("dep1", "dep2")
useMemo use_derived
createContext create_reducer_context / create_context
<Context.Provider> ReducerProvider / ContextProvider

Examples

See the examples/ directory for complete working examples:

  • examples/new_counter.py - Simple counter with reducer
  • examples/new_todo_app.py - Todo app with derived state
  • examples/context.py - Context sharing example

License

MIT

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

textual_reactive-0.1.0a1.tar.gz (74.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

textual_reactive-0.1.0a1-py3-none-any.whl (19.3 kB view details)

Uploaded Python 3

File details

Details for the file textual_reactive-0.1.0a1.tar.gz.

File metadata

  • Download URL: textual_reactive-0.1.0a1.tar.gz
  • Upload date:
  • Size: 74.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.9.26 {"installer":{"name":"uv","version":"0.9.26","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"NixOS","version":"26.05","id":"yarara","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for textual_reactive-0.1.0a1.tar.gz
Algorithm Hash digest
SHA256 11a726fd7ecf5e51aaf3b2ac2c339dc02790a098c412c24b73352d116ef105d6
MD5 796550d2ca4904f3268fe144e6d1940b
BLAKE2b-256 1bfd8f6f362dd4f5802d73f9c871176a67e3d28f2d4a98fbc6befeae27d1c21d

See more details on using hashes here.

File details

Details for the file textual_reactive-0.1.0a1-py3-none-any.whl.

File metadata

  • Download URL: textual_reactive-0.1.0a1-py3-none-any.whl
  • Upload date:
  • Size: 19.3 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.9.26 {"installer":{"name":"uv","version":"0.9.26","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"NixOS","version":"26.05","id":"yarara","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for textual_reactive-0.1.0a1-py3-none-any.whl
Algorithm Hash digest
SHA256 06597c2c4b52d81615e3847d297a527f0c24a7c1f4f8c92c45459860a7e91ada
MD5 37958909ee6a464cc54b516b1dce8aa6
BLAKE2b-256 555d20c68f6760be1e83ccb909ff7802ed12a3897739bb94581ff37db27299bb

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page