FastHTML + Datastar

Interactive examples of Datastar's reactive superpowers

Interactive Todo App

See Datastar in action with this reactive todo app. Add tasks, toggle completion, and filter - all with real-time UI updates.

TASKS

FILTER:
  • Learn Python
  • Learn Datastar
  • ???
  • Profit

What is Datastar?

A lightweight (14.3 KiB) JavaScript library that adds reactivity to HTML using data-* attributes. Use with any backend language and Server-Sent Events for real-time UI updates.

Declarative

Add reactivity directly to HTML with data-* attributes.

Signals-Based

State is managed through reactive signals that automatically track and propagate changes throughout your UI.

Hypermedia-First

Embrace the web's natural architecture. Keep logic on the server and your frontend lightweight and focused.

Function: ds_bind(signal_name)

Creates a two-way connection between form elements and signals. When users interact with the element, the signal updates automatically.

Works with:
  • text inputs
  • textarea elements
  • select dropdowns
  • checkboxes
  • radio buttons
  • custom web components
Usage: Input(ds_bind('input1'), ...)

Data Binding Example

Current value:
VIEW CODE
Input(
    ds_bind("input1"),
    cls="input w-full max-w-xs",
    placeholder="Type something..."
)
Div(
    "Current value: ",
    Span(ds_text("$input1")),
    cls="mt-2 p-2 bg-base-200 rounded"
)
Function: ds_text(expr)

Sets an element's text content based on signal values using JavaScript expressions. Great for displaying dynamic output that updates automatically when signals change.

Examples:
  • Basic: $input
  • Transformed: $input.toUpperCase()
  • Conditional: $input ? $input : 'Nothing entered'
  • Calculations: 'Length: ' + $input.length
Usage: Span(ds_text('$input.toUpperCase()'))

Reactive Text Example

Uppercase:
VIEW CODE
Input(
    ds_bind("input2"),
    cls="input w-full max-w-xs",
    placeholder="Type something..."
),
Div(
    "Uppercase: ",
    Span(ds_text("$input2.toUpperCase()")),
    cls="mt-2 p-2 bg-base-200 rounded"
)
Function: ds_computed(name=expr, ...)

Creates new read-only signals derived from reactive expressions. These computed values update automatically when their dependencies change.

Examples:
  • Basic calculations: $input.length
  • Text transformations: $input.repeat(2)
  • Complex logic: $input ? $input.toUpperCase() : 'Nothing entered'
Usage: Div(ds_computed(doubled='$input.repeat(2)'))

Computed Example

Doubled:
Length of input:
VIEW CODE
Input(
    ds_bind("input3"),
    cls="input w-full max-w-xs",
    placeholder="Type something..."
),
Div(
    ds_computed(
        doubled="$input3.repeat(2)",
        length="$input3.length"
    )
),
Div(
    "Doubled: ",
    Span(ds_text("$doubled")),
    cls="mb-2"
),
Div(
    "Length of input: ",
    Span(ds_text("$length"))
)
Function: ds_show(when=expr)

Conditionally shows or hides elements based on reactive expressions. Elements are only visible when the expression evaluates to true.

Examples:
  • Basic visibility: $input != ''
  • Inverse condition: !$input
  • Complex logic: $count > 10 && $isAdmin
Syntax:
  • Explicit: ds_show(when="$input != ''")
  • Concise: ds_show("$input != ''")
Usage: Button(ds_show(when='$input != ""'), 'Save')

Show/Hide Example

Only shown when input not empty
VIEW CODE
Input(
    ds_bind("input4"),
    cls="input w-full max-w-xs",
    placeholder="Type something..."
),
Div(
    "Only shown when input not empty",
    ds_show(when="$input4 != ''"),
    cls="p-2 bg-success text-success-content rounded"
)
Function: ds_classes(**expressions)

Conditionally applies CSS classes based on reactive expressions. Classes are added when the expression evaluates to true and removed when false.

Examples:
  • Single class: ds_classes(hidden="$input == ''")
  • Multiple classes (object syntax): ds_classes(text_primary="$input.length > 0", font_bold="$input.length > 3")
  • Use with TailwindCSS: ds_classes(bg_error="$isInvalid", text_success="$isValid")
Usage: Div(ds_classes(text_primary="$input.length > 0"))

Dynamic Classes Example

This text changes style as you type more
VIEW CODE
Input(
    ds_bind("input5"),
    cls="input w-full max-w-xs",
    placeholder="Type something..."
),
Div(
    "This text changes style as you type more",
    ds_classes(
        text_primary="$input5.length > 0",
        font_bold="$input5.length > 3",
        text_2xl="$input5.length > 5"
    ),
    cls="p-2"
)
Function: ds_attrs(**expressions)

Reactively sets HTML attributes based on signal values. Attributes are updated automatically when the expressions evaluate to new values.

Examples:
  • Single attribute: ds_attrs(disabled="$input == ''")
  • Multiple attributes: ds_attrs(disabled="!['red', 'blue'].includes($input)", title="$input ? 'Submit ' + $input : 'Enter a valid color'")
  • Style attribute: ds_attrs(style="'color: ' + ($isError ? 'red' : 'green')")
  • ARIA attributes: ds_attrs(aria_expanded="$isOpen", aria_label="$buttonLabel")
Usage: Button(ds_attrs(disabled="$isEmpty"), "Save")

Dynamic Attributes Example

Hover over the non-disabled button to see the title in a tooltip
VIEW CODE
Input(
    ds_bind("input6"),
    cls="input w-full max-w-xs",
    placeholder="Type 'red' or 'blue'",
),
Span(
    "Hover over the non-disabled button to see the title in a tooltip",            
    cls="text-sm"
),
Button(
    "Submit",
    ds_attrs(
        disabled="!['red', 'blue'].includes($input6)",                
        title="$input6 ? 'Submit ' + $input6 : 'Enter a valid color'"
    ),
    ds_classes(                
        btn_error="$input6 === 'red'",  # btn-error only applies when red                
        btn_info="$input6 === 'blue'",  # btn-info only applies when blue
        ),            
    cls="btn"
)
Function: ds_signals(**signal_values)

Initializes reactive signals that can be accessed throughout your application. Signals are the foundation of Datastar's reactivity system.

Examples:
  • Basic signal: ds_signals(count="0") - Numbers don't need quotes in JavaScript
  • String signal: ds_signals(name="'Anonymous'") - Strings require quotes in JavaScript (inner quotes)
  • Boolean signal: ds_signals(isAdmin="false") - JavaScript booleans are lowercase
  • Multiple signals: ds_signals(count="0", name="'User'", isAdmin="false") - Define several at once
  • Namespaced signals: ds_signals(user__name="'Anonymous'") - Double underscore becomes dot notation
  • JSON objects: ds_signals(user=json_dumps({"name": "", "email": ""})) - For complex nested data
Usage: Div(ds_signals(count="0", name="'User'"))

Signals Example

Basic Signal

Current value: / 100

Namespaced Signals

Name:
Email includes @:
VIEW CODE
Div(
    ds_signals(
        user__name="''",
        user__email="''"
    )
),
Div(
    Input(
        ds_bind("user.name"),
        placeholder="Enter your name",
        cls="input w-full mb-2"
    ),
    Input(
        ds_bind("user.email"),
        placeholder="Enter your email",
        cls="input w-full",
        type="email"
    ),
    cls="space-y-2 mb-4"
),
Div(
    "Name: ",
    Span(ds_text("$user.name || 'Anonymous'"), cls="font-mono"),
    cls="mb-2"
),
Div(
    "Email includes @: ",
    Span(
        ds_text("$user.email.includes('@') ? '✅' : '❌'"),                    
    ),
    cls="flex items-center gap-2"
),
cls="p-4 bg-base-200 rounded-lg"
)
Function: ds_on(**event_handlers)

Attaches event listeners to elements that execute JavaScript expressions when triggered. This enables interactive UI without writing custom JavaScript functions.

Examples:
  • Simple actions: ds_on(click="$count++")
  • Multiple statements: ds_on(click="$count = 0; $message = 'Reset'")
  • DOM access: ds_on(input="$value = evt.target.value")
  • Conditional logic: ds_on(keydown="if(evt.key === 'Enter') $submit = true")
  • Server actions: ds_on(click="@post('/api/data')")

The special evt variable gives access to the browser's native event object, allowing you to access properties like evt.target, evt.key, etc.

Usage: Button(ds_on(click="$count++"), "Increment")

Events Example

Using signals we default the starting count to 5
Count:
VIEW CODE
Button(
    "Increment",
    ds_on(click="$count2++"),
    cls="btn btn-primary mr-2"
),
Button(
    "Reset",
    ds_on(click="$count2 = 0"),
    cls="btn btn-warning"
),
Div(
    "Count: ",
    Span(ds_text("$count2"), cls="font-mono"),
    cls="mt-2"
)
Function: ds_signals, ds_computed, ds_on, ds_show, ds_text

This example demonstrates a simple interactive quiz using Datastar's client-side reactivity features.

Key concepts:
  • Signal initialization with ds_signals for state management
  • Computed values with ds_computed for answer validation
  • Event handling with ds_on for user interaction
  • Conditional rendering with ds_show for dynamic feedback
  • Text interpolation with ds_text for displaying values

While the initial HTML is served from a route, all quiz interactions happen entirely in the browser without additional server requests. This makes it simple to implement but limits it to using hardcoded questions.

Usage: Button(ds_on(click="$response = prompt('Answer:')"), "Answer")
What do you put in a toaster?
You answered . That is correct. ✅The correct answer is . 🤷
VIEW CODE
# Define initial signals
Div(
    ds_signals(
        response="''",
        answer="bread"
        ),        
    ds_computed(
        correct="$response.toLowerCase() == $answer"
        ),
),

# Quiz interface
Div(
    "What do you put in a toaster?",
    id="question",
    cls="text-lg font-bold mb-4"
),

# Answer button
Button(
    "BUZZ",
    ds_on(click="$response = prompt('Answer:') ?? ''"),
    cls="btn btn-primary mb-4"
),

# Response display with conditional feedback
Div(
    ds_show(when="$response != ''"),
    "You answered ",
    Span(ds_text("$response"), cls="font-mono"),
    " . ",
    
    # Correct answer feedback
    Span(
        ds_show(when="$correct"),
        "That is correct. ✅",                
    ),
    
    # Wrong answer feedback
    Span(
        ds_show(when="!$correct"),
        "The correct answer is ",
        Span(ds_text("$answer"), cls="font-mono"),
        ". 🤷",                
    ),
    cls="space-y-2"
)
Function: @get, signal_update, fragment_update

Building on the client-side quiz, this example demonstrates how to enhance the experience with server-side functionality using Server-Sent Events (SSE).

Key enhancements:
  • Server communication with the @get directive for dynamic content
  • Questions fetched from the server during user interaction
  • Real-time UI updates with SSE without page reloads
  • Server-side signal updates with signal_update
  • HTML fragment updates with fragment_update

Unlike the previous example where all interaction happens in the browser, this quiz makes additional server requests during use to fetch new questions and answers, demonstrating how Datastar bridges client and server functionality.

Usage: Button(ds_on(click="@get('/actions/quiz')"), "Fetch Question")

SSE Merge Signals Example

You answered . That is correct. ✅The correct answer is . 🤷
VIEW CODE
# Client-side component
Div(
    # Define initial signals
    Div(
        ds_signals(
            question2=json_dumps(""),
            response2=json_dumps(""),
            answer2=json_dumps("")
            ),        
        ds_computed(
            correct2="$response2.toLowerCase() == $answer2"
            ),
    ),        
            
    Button(
        "Fetch New question",
        ds_on(click="@get('/actions/quiz'); $response2 = ''"),
        cls="btn btn-accent gap-2 mb-4"
    ),
    Div(
        ds_text("$question2 ?? 'Click above to get a question'"),
        id="question2",
        cls="text-lg font-bold mb-4"
    ),
    Button(
        ds_show(when="$answer2 != ''"),
        "BUZZ",
        ds_on(click="$response2 = prompt('Answer:') ?? ''"),
        cls="btn btn-primary mb-4"
    ),
    
    # Response display with conditional feedback
    Div(
        ds_show(when="$response2 != ''"),
        "You answered ",
        Span(ds_text("$response2"), cls="font-mono"),
        Span(ds_show(when="$correct2"), "That is correct. ✅"),
        Span(
            ds_show(when="!$correct2"),
            "The correct answer is ",
            Span(ds_text("$answer2"), cls="font-mono"),
            ". 🤷",                
        ),
        cls="space-y-2"
    )
)

# Server-side handler
@rt("/actions/quiz")
@sse
async def quiz_action():
    # Select random question from database
    qna = random.choice(QUESTIONS)
    
    # Create a fragment to update the question display
    question_fragment = Div(
        qna["question"],
        id="question2",
        cls="text-lg font-bold mb-4"
    )
    
    # Update the question fragment with proper selector and merge mode
    yield fragment_update(question_fragment, selector="#question2", merge_mode="morph")
    
    # Update the signals
    yield signal_update(
        question2=qna["question"],
        answer2=qna["answer"],
        response2=""
    )
Function: @sse, signal_update, fragment_update

Datastar provides a powerful way to create server routes that can send real-time updates to the client using Server-Sent Events (SSE).

Key concepts:
  • The @sse decorator marks a route as an SSE endpoint
  • SSE routes can yield multiple updates to the client
  • fragment_update replaces or modifies HTML elements
  • signal_update changes signal values without reloading the page
Route setup:
  • Create a route with @rt("/your/path")
  • Add the @sse decorator to enable streaming
  • Use yield to send updates to the client
  • Client connects using @get, @post, etc. directives
Usage: @rt("/api/data") @sse async def handler(): yield signal_update(...)
Original value:
Processed result:
VIEW CODE
# Client-side component
@rt("/minimal-test")
def minimal_test_ui():
    return Div(
        # Initialize signals
        ds_signals({
            "minimalSignal": 0,
            "testParam": json_dumps(""),  # camelCase JS signal
            "processedResult": "''"
        }),
        
        # Input with binding
        Input(
            ds_bind("testParam"),  # HTML attribute
            placeholder="Enter test value",
            cls="input input-bordered w-full mb-4"
        ),
        
        # Display original signal
        Div(
            "Original value: ",
            Span(ds_text("$testParam"), cls="font-mono"),
            cls="mb-2"
        ),
        
        # Display processed result
        Div(
            "Processed result: ",
            Span(ds_text("$processedResult"), cls="font-mono"),
            cls="mb-4"
        ),
        
        # Button with parameter passing
        Button(
            "Process Value",
            ds_on(click="@post('/api/minimal-post')"),
            ds_indicator("minimal.loading"),
            cls="btn btn-primary gap-2"
        ),
        
        cls="p-6 max-w-md mx-auto"
    )

# Server-side SSE route
@rt("/api/minimal-post", methods=["POST"])
@sse
async def minimal_post_example(testParam: str):        
    # Process the input and update signals
    processed = f"Processed: {testParam.upper()}"
    
    yield signal_update(
        processedResult=json_dumps(processed),
        minimalSignal="prev => prev + 1"  # Increment using a function
    )
Function: ds_indicator(signal_name)
The `ds_indicator` function sets the value of a signal to `true` while a request is in flight, and `false` otherwise.
  • Use ds_indicator("signal_name") to create a signal that tracks the loading state
  • The signal will be true during the request and false when complete
  • Combine with ds_show or ds_classes to create responsive loading states
  • Works with all HTTP methods: GET, POST, PUT, PATCH, and DELETE
Usage: Button("Load Data", ds_on(click="@get('/api/data')"), ds_indicator("loading"))
VIEW CODE
@rt("/indicator-demo")
def indicator_demo():
    return Div(                       
        # Button with loading states
        Button(
            Span("Load Data", ds_show(when="!$fetching")),
            Span("Loading...", ds_show(when="$fetching")),
            ds_on(click="@get('/actions/load-data')"),
            ds_indicator("fetching"),
            cls="btn btn-primary"
        ),
        
        # Spinner indicator (using DaisyUI classes)
        Div(
            ds_classes(
                loading="$fetching",
                hidden="!$fetching"
            ),
            cls="loading loading-spinner ml-2"
        ),        
        cls="p-4 flex items-center gap-2"
    )

@rt("/actions/load-data")
@sse
async def load_data_action():    
    # Simulate a slow operation
    await asyncio.sleep(2)
    
    # Send data back to the client
    # The ds_indicator signal will automatically be set to false when this completes
    yield signal_update(data=json_dumps("Loaded!"))
Function: @setAll(prefix, value) / @toggleAll(prefix)
Datastar provides two powerful actions for manipulating multiple signals at once: `@setAll()` and `@toggleAll()`.
  • @setAll(prefix, value) sets all signals that match the prefix to the specified value
  • @toggleAll(prefix) toggles the boolean value of all signals that match the prefix
  • Both actions are perfect for working with form fields, checkboxes, and other grouped elements
  • The prefix is used to match against signal names (e.g., 'checkboxes.' will match 'checkboxes.option1', 'checkboxes.option2', etc.)
Usage: Button("Check All", ds_on(click="@setAll('checkboxes.', true)"))
Selected:
VIEW CODE
@rt("/examples/setall")
def setall_example():
    OPTIONS = [
    ("option1", "Option 1"),
    ("option2", "Option 2"), 
    ("option3", "Option 3")
    ]
    return Div(
        # Initialize checkbox signals
        Div(
             ds_signals({
            "checkboxes": json_dumps({
                "option1": False,
                "option2": False,
                "option3": False
            })
        }),
        ),
        
        # Checkbox group
        Div(
            # Generate checkboxes dynamically
            *[
                Label(
                    Input(                        
                        ds_bind(f"checkboxes.{opt}"),  # Dot notation for object access
                        type="checkbox",                        
                        id=opt,                        
                        cls="checkbox checkbox-primary"
                    ),
                    text,
                    cls="flex items-center gap-2"
                )
                for opt, text in OPTIONS
            ],
            cls="space-y-2 mb-4"
        ),
        
        # Control buttons
        Div(
            Button(
                "Check All",
                ds_on(click="@setAll('checkboxes.', true)"),
                cls="btn btn-sm btn-success mr-2"
            ),
            Button(
                "Uncheck All", 
                ds_on(click="@setAll('checkboxes.', false)"),
                cls="btn btn-sm btn-error"
            ),
            Button(
                "Toggle All",
                ds_on(click="@toggleAll('checkboxes.')"),
                cls="btn btn-sm btn-warning"
            ),
            cls="flex gap-2"
        ),
        Div(
            "Selected: ",
            Span(
                ds_text(
                    "[" + 
                    ",".join([f"{{name:'{text}',value:$checkboxes.{opt}}}" for opt, text in OPTIONS]) + 
                    "].filter(i=>i.value).map(i=>i.name).join(', ')"
                ),
                cls="font-mono"
            ),            
            cls="mt-4"
        ),
        cls="p-4 bg-base-200 rounded-lg"
    )
Function: signal_update(), fragment_update()
This advanced example demonstrates how to create a more complex interaction between client and server using DataStar's features:
  • Multi-step server processing with real-time UI updates
  • Combining signal updates and fragment updates in a single request
  • Error handling with graceful UI feedback
  • Progress indicators with dynamic content insertion
  • Stateful counters that persist between requests
Usage: yield fragment_update(status_component, "#container", "inner")
Requests made:
Final result:
VIEW CODE
@rt("/complex-example-ui")
def complex_example_ui():
    return Div(
        # Initialize signals
        ds_signals({
            "complex_loading": "false",
            "complex_request_count": 0,
            "complex_result": "''"
        }),
        
        # Control section
        Div(
            Button(
                "Start Complex Process",
                ds_on(click="@post('/api/complex-example')"),
                ds_indicator("complex_loading"),
                cls="btn btn-primary gap-2",                
            ),
            Div(
                "Requests made: ",
                Span(
                    ds_text("$complex_request_count"),
                    cls="font-mono"
                ),
                cls="text-sm mt-2"
            ),
            cls="p-4 bg-base-200 rounded-lg"
        ),
        
        # Status container
        Div(
            id="complex-status-container",
            cls="space-y-4 mt-4",
            # Dynamic fragments will be inserted here
        ),
        
        # Result display
        Div(
            ds_show("$complex_result !== ''"),
            "Final result: ",
            Span(
                ds_text("$complex_result"),
                cls="font-mono text-success"
            ),
            cls="mt-4 p-2 bg-base-300 rounded"
        ),
        
        cls="mx-auto p-4 max-w-2xl"
    )

@rt("/api/complex-example", methods=["POST"])
@sse
async def complex_handler(testParam: str = ""):
    try:
        # Initial signal update
        yield signal_update(
            complex_loading="true",
            complex_request_count="prev => prev + 1"
        )
        
        await asyncio.sleep(1)

        # First fragment update
        status_update = Div(
            Div(
                IconifyIcon(icon="fa6-solid:spinner", cls="animate-spin mr-2"),
                Span("Processing..."),
                cls="flex items-center"
            ),
            cls="alert alert-info"
        )
        
        yield fragment_update(status_update, "#complex-status-container", "inner")
        
        await asyncio.sleep(2)
        
        # Final fragment update
        final_status = Div(
            Span("Process completed successfully!"),
            cls="alert alert-success"
        )
        
        yield fragment_update(final_status, "#complex-status-container", "inner")
        
        # Final signal update
        yield signal_update(
            complex_loading="false",
            complex_result=json_dumps(testParam.upper() or "COMPLETED")
        )
    
    except Exception as e:
        # Handle errors gracefully
        error_message = f"Error: {str(e)}"
        yield fragment_update(
            Div(error_message, cls="alert alert-error"),
            "#complex-status-container", 
            "append"
        )
        yield signal_update(
            complex_loading="false",
            complex_result=json_dumps(error_message)
        )

You've reached the end!

Get building! Or explore the standalone Todo app some more.