Pulse

Forms

Forms are a fundamental part of web applications. Pulse provides helpers that make it easy to handle form submissions server-side, including file uploads, while keeping your code simple and secure.

Basic forms with ps.Form

The ps.Form component handles form submissions automatically. It sends form data to your Python handler and manages submission state for you:

import pulse as ps

class ContactState(ps.State):
    async def handle_submit(self, data: ps.FormData):
        # data is a dict with form field values
        name = data.get("name")
        email = data.get("email")
        message = data.get("message")
        await send_contact_email(name, email, message)

@ps.component
def ContactForm():
    with ps.init():
        state = ContactState()

    return ps.Form(key="contact", onSubmit=state.handle_submit)[
        ps.label("Name", htmlFor="name"),
        ps.input(id="name", name="name", type="text", required=True),

        ps.label("Email", htmlFor="email"),
        ps.input(id="email", name="email", type="email", required=True),

        ps.label("Message", htmlFor="message"),
        ps.textarea(id="message", name="message", rows=4),

        ps.button("Send", type="submit"),
    ]

Every ps.Form requires a unique key prop. This identifies the form and ensures submissions go to the correct handler, even if you have multiple forms on the same page.

FormData

The onSubmit handler receives a ps.FormData dictionary. Values are strings by default, but file inputs produce UploadFile objects:

async def handle_submit(self, data: ps.FormData):
    # Text fields are strings
    name = data["name"]  # str

    # File inputs are UploadFile objects
    avatar = data.get("avatar")  # UploadFile | None
    if avatar:
        content = await avatar.read()
        filename = avatar.filename
        content_type = avatar.content_type

For inputs with multiple=True, the value is a list:

async def handle_submit(self, data: ps.FormData):
    # Multiple files
    attachments = data.get("files")  # list[UploadFile] | UploadFile | None
    if isinstance(attachments, list):
        for file in attachments:
            await process_file(file)

File uploads

File uploads work out of the box. Just add a file input to your form:

ps.Form(key="upload", onSubmit=state.handle_upload)[
    ps.label("Profile picture", htmlFor="avatar"),
    ps.input(id="avatar", name="avatar", type="file", accept="image/*"),

    ps.label("Documents", htmlFor="docs"),
    ps.input(id="docs", name="documents", type="file", multiple=True),

    ps.button("Upload", type="submit"),
]

The handler receives UploadFile objects from FastAPI/Starlette:

from fastapi import UploadFile

async def handle_upload(self, data: ps.FormData):
    avatar = data.get("avatar")
    if isinstance(avatar, UploadFile):
        # Read file content
        content = await avatar.read()

        # Access metadata
        print(f"Filename: {avatar.filename}")
        print(f"Content-Type: {avatar.content_type}")
        print(f"Size: {avatar.size}")

        # Save to disk or upload to cloud storage
        await save_file(avatar.filename, content)

Manual form handling with ManualForm

For more control over form behavior, use ps.ManualForm. This gives you access to the form's submission state and lets you customize form props:

@ps.component
def AdvancedForm():
    with ps.init():
        state = FormState()

    # Create the manual form in ps.setup to persist across renders
    manual_form = ps.setup(lambda: ps.ManualForm(state.handle_submit))

    return ps.div(
        # Show loading state during submission
        ps.p("Submitting...") if manual_form.is_submitting else None,

        # Use a regular form element with manual_form.props()
        ps.form(**manual_form.props())[
            ps.input(name="email", type="email", required=True),
            ps.button(
                "Submit",
                type="submit",
                disabled=manual_form.is_submitting,
            ),
        ],
    )

ManualForm provides:

  • is_submitting - Boolean indicating if the form is currently being submitted
  • props() - Returns a dict of props to spread onto a <form> element
  • The form itself can be called as a function to render with children

Form security

Pulse forms include built-in security:

  • Session binding: Forms are tied to the user session that created them. Submissions from other sessions are rejected.
  • Route binding: Forms are tied to their route. If the user navigates away and the form unmounts, submissions are rejected.
  • CSRF protection: Form handlers validate that submissions come from the same session.

This happens automatically - you don't need to add tokens or extra validation.

Tips

Use controlled inputs for validation: If you need to validate input as the user types, combine ps.Form with state-controlled inputs:

class FormState(ps.State):
    email: str = ""

    @ps.computed
    def is_valid(self):
        return "@" in self.email and "." in self.email

    async def handle_submit(self, data: ps.FormData):
        if self.is_valid:
            await submit_form(data)

@ps.component
def ValidatedForm():
    with ps.init():
        state = FormState()

    return ps.Form(key="validated", onSubmit=state.handle_submit)[
        ps.input(
            name="email",
            type="email",
            value=state.email,
            onChange=lambda e: setattr(state, 'email', e["target"]["value"]),
        ),
        ps.button(
            "Submit",
            type="submit",
            disabled=not state.is_valid,
        ),
    ]

Handle errors gracefully: Wrap your submission logic in try/except and update state to show error messages:

class FormState(ps.State):
    error: str | None = None
    success: bool = False

    async def handle_submit(self, data: ps.FormData):
        try:
            await save_data(data)
            self.success = True
            self.error = None
        except Exception as e:
            self.error = str(e)
            self.success = False

See also

On this page