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_typeFor 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 submittedprops()- 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