Creating and updating CoValues in a form
Normally, we implement forms using the onSubmit handler, or by making a controlled form with useState, or by using special libraries like react-hook-form.
In Jazz, we can do something simpler and more powerful, because CoValues give us reactive, persisted state which we can use to directly edit live objects, and represent auto-saved drafts.
Updating a CoValue
To update a CoValue, we simply assign the new value directly as changes happen. These changes are synced to the server, so we don't need to handle form submissions either.
<input type="text" value={order.name} onChange={(e) => order.name = e.target.value} />
This means we can write update forms in fewer lines of code.
Creating a CoValue
However, when creating a CoValue, the CoValue does not exist yet, so we don't have the advantages previously mentioned.
There's a way around this, and it provides unexpected benefits too.
Using a Draft CoValue
Let's say we have a CoValue called BubbleTeaOrder
. We can create a "draft" CoValue,
which is an empty version of a BubbleTeaOrder
, that we can then modify when we are "creating"
a new CoValue.
A DraftBubbleTeaOrder
is essentially a copy of BubbleTeaOrder
, but with all the fields made optional.
// schema.ts export class BubbleTeaOrder extends CoMap { name = co.string; } export class DraftBubbleTeaOrder extends CoMap { name = co.optional.string; }
Writing the components in React
Let's write the form component that will be used for both create and update.
// OrderForm.tsx export function OrderForm({ order, onSave, }: { order: BubbleTeaOrder | DraftBubbleTeaOrder; onSave?: (e: React.FormEvent<HTMLFormElement>) => void; }) { return ( <form onSubmit={onSave}> <label> Name <input type="text" value={order.name} onChange={(e) => (order.name = e.target.value)} required /> </label> {onSave && <button type="submit">Submit</button>} </form> ); }
Writing the edit form
To make the edit form, simply pass the BubbleTeaOrder
.
// EditOrder.tsx export function EditOrder(props: { id: ID<BubbleTeaOrder> }) { const order = useCoState(BubbleTeaOrder, props.id, []); if (!order) return; return <OrderForm order={order} />; }
Writing the create form
For the create form, we need to:
- Create a draft order.
- Edit the draft order.
- Convert the draft order to a "real" order on submit.
Here's how that looks like:
// CreateOrder.tsx export function CreateOrder() { const { me } = useAccount(); const [draft, setDraft] = useState<DraftBubbleTeaOrder>(); useEffect(() => { setDraft(DraftBubbleTeaOrder.create({}, { owner: me })); }, [me?.id]); const onSave = (e: React.FormEvent<HTMLFormElement>) => { e.preventDefault(); if (!draft) return; const order = draft as BubbleTeaOrder; console.log("Order created:", order); }; if (!draft) return; return <OrderForm order={draft} onSave={onSave} />; }
Validation
In a BubbleTeaOrder
, the name
field is required, so it would be a good idea to validate this before turning the draft into a real order.
Update the schema to include a validate
method.
// schema.ts export class DraftBubbleTeaOrder extends CoMap { name = co.optional.string; validate() { const errors: string[] = []; if (!this.name) { errors.push("Please enter a name."); } return { errors }; } }
Then perform the validation on submit.
// CreateOrder.tsx export function CreateOrder() { const { me } = useAccount(); const [draft, setDraft] = useState<DraftBubbleTeaOrder>(); useEffect(() => { setDraft(DraftBubbleTeaOrder.create({}, { owner: me })); }, [me?.id]); const onSave = (e: React.FormEvent<HTMLFormElement>) => { e.preventDefault(); if (!draft) return; const validation = draft.validate(); if (validation.errors.length > 0) { console.log(validation.errors); return; } const order = draft as BubbleTeaOrder; console.log("Order created:", order); }; if (!draft) return; return <OrderForm order={draft} onSave={onSave} />; }
Saving the user's work-in-progress
It turns out that using this pattern also provides a UX improvement.
By storing the draft in the user's account, they can come back to it anytime without losing their work. 🙌
// schema.ts export class BubbleTeaOrder extends CoMap { name = co.string; } export class DraftBubbleTeaOrder extends CoMap { name = co.optional.string; } export class AccountRoot extends CoMap { draft = co.ref(DraftBubbleTeaOrder); } export class JazzAccount extends Account { root = co.ref(AccountRoot); migrate(this: JazzAccount, creationProps?: { name: string }) { super.migrate(creationProps); if (!this._refs.root) { const ownership = { owner: this }; const draft = DraftBubbleTeaOrder.create({}, ownership); this.root = AccountRoot.create({ draft }, ownership); } } }
Let's not forget to update the AccountSchema
.
const Jazz = createJazzReactApp({ AccountSchema: JazzAccount, });
Instead of creating a new draft every time we use the create form, let's use the draft from the account root.
// CreateOrder.tsx export function CreateOrder() { const { me } = useAccount({ root: { draft: {} } }); if (!me?.root) return; const onSave = (e: React.FormEvent<HTMLFormElement>) => { e.preventDefault(); const draft = me.root.draft; if (!draft) return; const validation = draft.validate(); if (validation.errors.length > 0) { console.log(validation.errors); return; } const order = draft as BubbleTeaOrder; console.log("Order created:", order); // create a new empty draft me.root.draft = DraftBubbleTeaOrder.create( {}, { owner: me }, ); }; return <CreateOrderForm id={me?.root?.draft.id} onSave={onSave} /> } function CreateOrderForm({ id, onSave, }: { id: ID<DraftBubbleTeaOrder>; onSave: (e: React.FormEvent<HTMLFormElement>) => void; }) { const draft = useCoState(DraftBubbleTeaOrder, id); if (!draft) return; return <OrderForm order={draft} onSave={onSave} />; }
When the new draft is created, we need to call useCoState
again, so that we are passing the new draft to <OrderForm/>
.
There you have it! Notice that when you refresh the page, you will see your unsaved changes.
Draft indicator
To improve the UX even further, in just a few more steps, we can tell the user that they currently have unsaved changes.
Simply add a hasChanges
checker to your schema.
// schema.ts export class DraftBubbleTeaOrder extends CoMap { name = co.optional.string; validate() { const errors: string[] = []; if (!this.name) { errors.push("Plese enter a name."); } return { errors }; } get hasChanges() { return Object.keys(this._edits).length; } }
In the UI, you can choose how you want to show the draft indicator.
// DraftIndicator.tsx export function DraftIndicator() { const { me } = useAccount({ root: { draft: {} }, }); if (me?.root.draft?.hasChanges) { return ( <p>You have a draft</p> ); } }
A more subtle way is to show a small dot next to the Create button.
Handling different types of data
Forms can be more complex than just a single string field, so we've put together an example app that shows you how to handle single-select, multi-select, date, and boolean inputs.
export class BubbleTeaOrder extends CoMap { baseTea = co.literal(...BubbleTeaBaseTeaTypes); addOns = co.ref(ListOfBubbleTeaAddOns); deliveryDate = co.Date; withMilk = co.boolean; instructions = co.optional.string; }