SolidStart data mutation: form submissions with actions, validation, error handling, pending states, optimistic UI, redirects, database operations, programmatic triggers.
Complete guide to handling data mutations in SolidStart using actions, forms, validation, and error handling.
Actions handle form submissions. Forms must use method="post":
import { action } from "@solidjs/router";
const addPost = action(async (formData: FormData) => {
const title = formData.get("title") as string;
await fetch("https://my-api.com/posts", {
method: "POST",
body: JSON.stringify({ title }),
});
}, "addPost");
export default function Page() {
return (
<form action={addPost} method="post">
<input name="title" />
<button>Add Post</button>
</form>
);
}
Requirements:
method="post"FormData as first parameterFormData.get() to extract field valuesUse .with() to pass additional arguments to actions:
const addPost = action(async (userId: number, formData: FormData) => {
const title = formData.get("title") as string;
await fetch("https://my-api.com/posts", {
method: "POST",
body: JSON.stringify({ userId, title }),
});
}, "addPost");
export default function Page() {
const userId = 1;
return (
<form action={addPost.with(userId)} method="post">
<input name="title" />
<button>Add Post</button>
</form>
);
}
Use useSubmission to track submission state and show pending UI:
import { action, useSubmission } from "@solidjs/router";
const addPost = action(async (formData: FormData) => {
const title = formData.get("title") as string;
await fetch("https://my-api.com/posts", {
method: "POST",
body: JSON.stringify({ title }),
});
}, "addPost");
export default function Page() {
const submission = useSubmission(addPost);
return (
<form action={addPost} method="post">
<input name="title" />
<button disabled={submission.pending}>
{submission.pending ? "Adding..." : "Add Post"}
</button>
</form>
);
}
Submission properties:
pending - Boolean indicating if action is runningresult - Successful return valueerror - Error throwninput - Reactive input dataclear() - Clear submission stateretry() - Re-execute with same inputDisplay errors from failed actions:
import { Show } from "solid-js";
import { action, useSubmission } from "@solidjs/router";
const addPost = action(async (formData: FormData) => {
const title = formData.get("title") as string;
const response = await fetch("https://my-api.com/posts", {
method: "POST",
body: JSON.stringify({ title }),
});
if (!response.ok) {
throw new Error("Failed to add post");
}
}, "addPost");
export default function Page() {
const submission = useSubmission(addPost);
return (
<form action={addPost} method="post">
<Show when={submission.error}>
<p class="error">{submission.error.message}</p>
<button onClick={() => submission.retry()}>Retry</button>
</Show>
<input name="title" />
<button>Add Post</button>
</form>
);
}
Return validation errors from actions and display them:
import { Show } from "solid-js";
import { action, useSubmission } from "@solidjs/router";
const addPost = action(async (formData: FormData) => {
const title = formData.get("title") as string;
// Validate
if (!title || title.length < 2) {
return {
error: "Title must be at least 2 characters",
};
}
await fetch("https://my-api.com/posts", {
method: "POST",
body: JSON.stringify({ title }),
});
return { success: true };
}, "addPost");
export default function Page() {
const submission = useSubmission(addPost);
return (
<form action={addPost} method="post">
<input name="title" />
<Show when={submission.result?.error}>
<p class="error">{submission.result.error}</p>
</Show>
<button>Add Post</button>
</form>
);
}
Validation pattern:
submission.result?.error in UIShow expected result immediately before server responds. See solidstart-optimistic-ui rule for detailed patterns.
Basic pattern with useSubmission:
import { For, Show } from "solid-js";
import { action, useSubmission, query, createAsync } from "@solidjs/router";
const getPosts = query(async () => {
const posts = await fetch("https://my-api.com/blog");
return await posts.json();
}, "posts");
const addPost = action(async (formData: FormData) => {
const title = formData.get("title") as string;
await fetch("https://my-api.com/posts", {
method: "POST",
body: JSON.stringify({ title }),
});
}, "addPost");
export default function Page() {
const posts = createAsync(() => getPosts());
const submission = useSubmission(addPost);
return (
<main>
<form action={addPost} method="post">
<input name="title" />
<button>Add Post</button>
</form>
<ul>
<For each={posts()}>{(post) => <li>{post.title}</li>}</For>
<Show when={submission.pending}>
<li>{submission.input?.[0]?.get("title")?.toString()} (pending)</li>
</Show>
</ul>
</main>
);
}
For multiple concurrent submissions, use useSubmissions (see solidstart-optimistic-ui rule).
Redirect users after successful mutation:
import { action, redirect } from "@solidjs/router";
const addPost = action(async (formData: FormData) => {
const title = formData.get("title") as string;
const response = await fetch("https://my-api.com/posts", {
method: "POST",
body: JSON.stringify({ title }),
});
const post = await response.json();
// Throw redirect to navigate
throw redirect(`/posts/${post.id}`);
}, "addPost");
Important: Must throw redirect(), not return it.
Mark actions with "use server" to safely access database:
import { action } from "@solidjs/router";
import { db } from "~/lib/db";
const addPost = action(async (formData: FormData) => {
"use server";
const title = formData.get("title") as string;
await db.insert("posts").values({ title });
}, "addPost");
Best practices:
"use server" for database operationsUse useAction to trigger actions programmatically (not just from forms):
import { createSignal } from "solid-js";
import { action, useAction } from "@solidjs/router";
const addPost = action(async (title: string) => {
await fetch("https://my-api.com/posts", {
method: "POST",
body: JSON.stringify({ title }),
});
}, "addPost");
export default function Page() {
const [title, setTitle] = createSignal("");
const addPostAction = useAction(addPost);
const handleSubmit = async () => {
await addPostAction(title());
setTitle(""); // Clear input
};
return (
<div>
<input
value={title()}
onInput={(e) => setTitle(e.target.value)}
/>
<button onClick={handleSubmit}>Add Post</button>
</div>
);
}
Use cases:
<form>)import { Show } from "solid-js";
import { action, useSubmission, redirect } from "@solidjs/router";
const createUser = action(async (formData: FormData) => {
"use server";
const email = formData.get("email") as string;
const name = formData.get("name") as string;
// Validation
if (!email || !email.includes("@")) {
return { error: "Invalid email address" };
}
if (!name || name.length < 2) {
return { error: "Name must be at least 2 characters" };
}
// Database operation
const user = await db.users.create({ email, name });
// Redirect on success
throw redirect(`/users/${user.id}`);
}, "createUser");
export default function CreateUserPage() {
const submission = useSubmission(createUser);
return (
<form action={createUser} method="post">
<input name="email" type="email" />
<input name="name" />
<Show when={submission.result?.error}>
<p class="error">{submission.result.error}</p>
</Show>
<Show when={submission.error}>
<p class="error">Error: {submission.error.message}</p>
<button onClick={() => submission.retry()}>Retry</button>
</Show>
<button disabled={submission.pending}>
{submission.pending ? "Creating..." : "Create User"}
</button>
</form>
);
}
action() must be unique"use server" for database: Keeps credentials secureuseSubmission for better UX (pending, errors).with() for additional args: When forms need extra contextuseSubmissions for multiple concurrent mutationsuseAction when not using native formsconst uploadFile = action(async (formData: FormData) => {
"use server";
const file = formData.get("file") as File;
// Handle file upload
}, "uploadFile");
<form action={uploadFile} method="post" enctype="multipart/form-data">
<input name="file" type="file" />
<button>Upload</button>
</form>
const saveDraft = useAction(saveDraftAction);
const publish = useAction(publishAction);
const handlePublish = async () => {
await saveDraft(data);
await publish(data.id);
};
const updatePost = action(async (formData: FormData) => {
"use server";
const post = await db.posts.update(formData);
if (post.published) {
throw redirect(`/posts/${post.id}`);
} else {
throw redirect(`/posts/${post.id}/edit`);
}
}, "updatePost");