Forms & Changesets Example
Now that we've revisited Form Events and learned about Changesets, let's take a look at how we can use them together to build, validate, and process a form.
Example LiveView
We're going to build a LiveView for managing our book library. We'll be able to add new books and mark those books as "available" or "checked out".
Here's the LiveView example code:
import {
createLiveView,
error_tag,
form_for,
html,
LiveViewChangeset,
newChangesetFactory,
submit,
text_input,
} from "liveviewjs";
import { nanoid } from "nanoid";
import { z } from "zod";
// Create the zod BookSchema
const BookSchema = z.object({
id: z.string().default(nanoid),
name: z.string().min(2).max(100),
author: z.string().min(2).max(100),
checked_out: z.boolean().default(false),
});
// Infer the Book type from the BookSchema
type Book = z.infer<typeof BookSchema>;
// in memory data store for Books
const booksDB: Record<string, Book> = {};
// Book LiveViewChangesetFactory
const bookCSF = newChangesetFactory<Book>(BookSchema);
export const booksLiveView = createLiveView<
// Define the Context of the LiveView
{
books: Book[];
changeset: LiveViewChangeset<Book>;
},
// Define events that are initiated by the end-user
| { type: "save"; name: string; author: string }
| { type: "validate"; name: string; author: string }
| { type: "toggle-checkout"; id: string }
>({
mount: async (socket) => {
socket.assign({
books: Object.values(booksDB),
changeset: bookCSF({}, {}), // empty changeset
});
},
handleEvent: (event, socket) => {
switch (event.type) {
case "validate":
// validate the form data
socket.assign({
changeset: bookCSF({}, event, "validate"),
});
break;
case "save":
// attempt to create the volunteer from the form data
const saveChangeset = bookCSF({}, event, "save");
let changeset = saveChangeset;
if (saveChangeset.valid) {
// save the book to the in memory data store
const newBook = saveChangeset.data as Book;
booksDB[newBook.id] = newBook;
// since book was saved, reset the changeset to empty
changeset = bookCSF({}, {});
}
// update context
socket.assign({
books: Object.values(booksDB),
changeset,
});
break;
case "toggle-checkout":
// lookup book by id
const book = booksDB[event.id];
if (book) {
// update book
book.checked_out = !book.checked_out;
booksDB[book.id] = book;
// update context
socket.assign({
books: Object.values(booksDB),
});
}
break;
}
},
render: (context, meta) => {
const { changeset, books } = context;
const { csrfToken } = meta;
return html`
<h1>My Library</h1>
<div id="bookForm">
${form_for<Book>("#", csrfToken, {
phx_submit: "save",
phx_change: "validate",
})}
<div class="field">
${text_input<Book>(changeset, "name", { placeholder: "Name", autocomplete: "off", phx_debounce: 1000 })}
${error_tag(changeset, "name")}
</div>
<div class="field">
${text_input<Book>(changeset, "author", { placeholder: "Author", autocomplete: "off", phx_debounce: 1000 })}
${error_tag(changeset, "author")}
</div>
${submit("Add Book", { phx_disable_with: "Saving..." })}
</form>
</div>
<div id="books">
${books.map(renderBook)}
</div>
`;
},
});
function renderBook(b: Book) {
const color = b.checked_out ? `color: #ccc;` : ``;
const emoji = b.checked_out ? `📖[checked out]` : `📚[available]`;
return html`
<div id="${b.id}" style="margin-top: 1rem; ${color}">
${emoji}<span>${b.name}</span> by <span>${b.author}</span>
<div>
<button phx-click="toggle-checkout" phx-value-id="${b.id}" phx-disable-with="Updating...">
${b.checked_out ? "Return" : "Check Out"}
</button>
</div>
</div>
`;
}
Code Walk Through
Let's walk through the code to see how it all works together.
First defining the Zod Schema, Types, and Changeset Factory
// Create the zod BookSchema
const BookSchema = z.object({
id: z.string().default(nanoid),
name: z.string().min(2).max(100),
author: z.string().min(2).max(100),
checked_out: z.boolean().default(false),
});
// Infer the Book type from the BookSchema
type Book = z.infer<typeof BookSchema>;
// Book LiveViewChangesetFactory
const bookCSF = newChangesetFactory<Book>(BookSchema);
This code should look familiar. We're defining the Zod Schema, inferring the type, and creating the changeset factory
for the BookSchema and Book type.
Define the LiveView TContext and TEvent types
...
export const booksLiveView = createLiveView<
// Define the Context of the LiveView
{
books: Book[];
changeset: LiveViewChangeset<Book>;
},
// Define events that are initiated by the end-user
| { type: "save"; name: string; author: string }
| { type: "validate"; name: string; author: string }
| { type: "toggle-checkout"; id: string }
>({
...
Here we are saying the context of our LiveView will have a books array of type Book and a changeset of type
LiveViewChangeset<Book>. We are also defining the events that can be initiated by the end-user. In this case, we
have save, validate, and toggle-checkout events along with the data that is required for each event.
Use Helpers in render
...
render: (context, meta) => {
// get the changeset and books from the context
const { changeset, books } = context;
// pull out the csrf token from the meta data
const { csrfToken } = meta;
return html`
<h1>My Library</h1>
<div id="bookForm">
<!-- use the form_for helper to render the form -->
${form_for<Book>("#", csrfToken, {
phx_submit: "save",
phx_change: "validate",
})}
<div class="field">
<!-- use the text_input helper to render the input for the name property -->
${text_input<Book>(changeset, "name", { placeholder: "Name", autocomplete: "off", phx_debounce: 1000 })}
<!-- use the error_tag helper to optionally render the error message for the name property -->
${error_tag(changeset, "name")}
</div>
<div class="field">
<!-- use the text_input helper to render the input for the author property -->
${text_input<Book>(changeset, "author", { placeholder: "Author", autocomplete: "off", phx_debounce: 1000 })}
<!-- use the error_tag helper to optionally render the error message for the author property -->
${error_tag(changeset, "author")}
</div>
<!-- use the submit helper to render the submit button -->
${submit("Add Book", { phx_disable_with: "Saving..." })}
</form>
</div>
<div id="books">
${books.map(renderBook)}
</div>
`;
},
...
There is a bit going on here so let's break it down. First we are pulling out the changeset and books from the
context. We are also pulling out the csrfToken from the meta data. Next we are using the form_for helper to
render the form. We are passing in the changeset and the csrfToken as well as setting the phx_submit and
phx_change attributes to the events that will be called in handleEvent.
Using the form_for helper is optional. You can use the form helper and set the phx_submit and phx_change
attributes to the events that will be called in handleEvent. The form_for helper just makes it a little easier to
render the form and automatically passes the csrfToken to the form.
The LiveViewJS framework automatically validates the csrfToken (a.k.a. authenticity token) for you and will throw an error if the token is invalid.
Next we are using the text_input helper to render the input for the name property. We are passing in the changeset
and the name property as well as the placeholder, autocomplete, and phx_debounce attributes.
The text_input helper is optional but it provides type safefy and automatically works with the changeset. Of
note, we use the phx-debounce attribute to only send the change event to the server after the user has stopped typing
for 1000ms. This is a great way to reduce the number of events sent to the server and improve performance.
Next we are using the error_tag helper to optionally render the error message for the name property. We are passing
in the changeset and the name property there as well.
The error_tag helper is optional but it provides type safefy and automatically works with the changeset to
pull out the error message for the property if there is one.
We follow the same pattern for the author property.
Next, we are using the submit helper to render the submit button. We are passing in the Add Book text and the
phx_disable_with attribute.
Finally we map over the books to render each book using the renderBook function.
Handle validate event
handleEvent: (event, socket) => {
...
case "validate":
// validate the form data
socket.assign({
changeset: bookCSF({}, event, "validate"),
});
break;
...
When handleEvent occurs with the validate event, we use the bookCSF changeset factory to generate a new
LiveViewChangeset for the the form data (also present in the event). Since, this isn't updating the book, we pass an
empty object {} first, along with the event data. Importantly, we set the action to validate and assign the
result to the changeset in the context.
Remember if you don't set the action text in the LiveViewChangesetFactory call then returned
LiveViewChangeset will always be valid.
The LiveViewChangeset works with the helpers in render to automatically render the error messages for the properties
that have errors and set the value of the input fields to the values that were submitted.
Handle save event
handleEvent: (event, socket) => {
...
case "save":
// attempt to create the volunteer from the form data
const saveChangeset = bookCSF({}, event, "save");
let changeset = saveChangeset;
if (saveChangeset.valid) {
// save the book to the in memory data store
const newBook = saveChangeset.data as Book;
booksDB[newBook.id] = newBook;
// since book was saved, reset the changeset to empty
changeset = bookCSF({}, {});
}
// update context
socket.assign({
books: Object.values(booksDB),
changeset,
});
break;
...
When handleEvent occurs with the save event, we use the bookCSF changeset factory to generate a new
LiveViewChangeset for the the form data (also present in the event). Since, this is also not updating an existing
book, we pass an empty object {} first, along with the event data. Importantly, we set the action to save and
assign the result to the saveChangeset variable.
If the saveChangeset is valid, we save the book to the in memory data store. We then reset the changeset to be an
empty changeset (i.e., bookCSF({}, {})). If the saveChangeset is not valid, we keep the changeset as the
saveChangeset so that the error messages will be rendered in the form using the form helpers (i.e., error_tag and
text_input).
Finally, we update the books and changeset in the context.
Bonus toggle_checkout event
handleEvent: (event, socket) => {
...
case "toggle_checkout":
// lookup book by id
const book = booksDB[event.id];
if (book) {
// update book
book.checked_out = !book.checked_out;
booksDB[book.id] = book;
// update context
socket.assign({
books: Object.values(booksDB),
});
}
break;
...
When handleEvent occurs with the toggle_checkout event, we toggle the checked_out property of the book in the in
memory data store and update the books in the context.
Conclusion
As you can see, Forms and Changesets are a powerful way to build forms in LiveView. They provide type safety and automatically render the error messages and input values. They validate the form data incrementally and upon submission. In short, they make forms easy and fun again!