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'suseState)use_reducer- Complex state with actions and reducers (like React'suseReducer)use_derived- Computed values that update automatically (like Svelte's derived stores)@effect- Decorator to watch specific state changes (like React'suseEffectwith 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 reducerexamples/new_todo_app.py- Todo app with derived stateexamples/context.py- Context sharing example
License
MIT
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file textual_reactive-0.1.0a2.tar.gz.
File metadata
- Download URL: textual_reactive-0.1.0a2.tar.gz
- Upload date:
- Size: 74.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
42e46b5273ba52465a93581199c522a1c106c82134f867d10266802950c13ec0
|
|
| MD5 |
76786c4a2ca6994f543273e6dc804fa7
|
|
| BLAKE2b-256 |
baa28f6a9794d8391bb36fa328700825614706fb0e788d3f2055b111c38a1fb7
|
Provenance
The following attestation bundles were made for textual_reactive-0.1.0a2.tar.gz:
Publisher:
publish.yml on ierence/textual-reactive
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
textual_reactive-0.1.0a2.tar.gz -
Subject digest:
42e46b5273ba52465a93581199c522a1c106c82134f867d10266802950c13ec0 - Sigstore transparency entry: 925364758
- Sigstore integration time:
-
Permalink:
ierence/textual-reactive@0e5d9342a0fabb829f9ef6a0235cb3ebbc6b7b3c -
Branch / Tag:
refs/tags/v0.1.0a2 - Owner: https://github.com/ierence
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@0e5d9342a0fabb829f9ef6a0235cb3ebbc6b7b3c -
Trigger Event:
push
-
Statement type:
File details
Details for the file textual_reactive-0.1.0a2-py3-none-any.whl.
File metadata
- Download URL: textual_reactive-0.1.0a2-py3-none-any.whl
- Upload date:
- Size: 19.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
121d1149c992b4aa5f09b8d277bf4cb28709d00f74efa6476af4e3d6f9a96260
|
|
| MD5 |
99cd534753a867acc774aa900b8d6083
|
|
| BLAKE2b-256 |
d620b40e5d74e4de9b259f5b386cb3d6a7191e8173db5772d4eaf6ad110b490b
|
Provenance
The following attestation bundles were made for textual_reactive-0.1.0a2-py3-none-any.whl:
Publisher:
publish.yml on ierence/textual-reactive
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
textual_reactive-0.1.0a2-py3-none-any.whl -
Subject digest:
121d1149c992b4aa5f09b8d277bf4cb28709d00f74efa6476af4e3d6f9a96260 - Sigstore transparency entry: 925364813
- Sigstore integration time:
-
Permalink:
ierence/textual-reactive@0e5d9342a0fabb829f9ef6a0235cb3ebbc6b7b3c -
Branch / Tag:
refs/tags/v0.1.0a2 - Owner: https://github.com/ierence
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@0e5d9342a0fabb829f9ef6a0235cb3ebbc6b7b3c -
Trigger Event:
push
-
Statement type: