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!