How to write forms with Jazz
This guide shows you a simple and powerful way to implement forms for creating and updating CoValues.
Updating a CoValue
To update a CoValue, we simply assign the new value directly as changes happen. These changes are synced to the server.
<input type="text" value={order.name} onChange={(e) => order.$jazz.set("name", e.target.value)} />
It's that simple!
Creating a CoValue
When creating a CoValue, we can use a partial version that allows us to build up the data before submitting.
Using a Partial CoValue
Let's say we have a CoValue called BubbleTeaOrder. We can create a partial version,
PartialBubbleTeaOrder, which has some fields made optional so we can build up the data incrementally.
// schema.ts export constBubbleTeaOrder =const BubbleTeaOrder: co.Map<{ name: z.z.ZodString; }, unknown, Account | Group>import coco.map({map<{ name: z.z.ZodString; }>(shape: { name: z.z.ZodString; }): co.Map<{ name: z.z.ZodString; }, unknown, Account | Group> export mapname: z.z.ZodStringname:import zz.string(), }); export typefunction string(params?: string | z.z.core.$ZodStringParams): z.z.ZodString (+1 overload) export stringBubbleTeaOrder =type BubbleTeaOrder = { readonly name: string; } & CoMapimport coco.loaded<typeoftype loaded<T extends CoValueClassOrSchema, R extends ResolveQuery<T> = true> = R extends boolean | undefined ? NonNullable<InstanceOfSchemaCoValuesNullable<T>> : [NonNullable<InstanceOfSchemaCoValuesNullable<T>>] extends [...] ? Exclude<...> extends CoValue ? R extends { ...; } ? readonly ((CoValue & ... 1 more ... & (ItemDepth extends boolean | undefined ? CoValue & Exclude<...> : [...] extends [...] ? Exclude<...> extends CoValue ? ItemDepth extends { ...; } ? readonly ((CoValue & ... 1 more ... & (ItemDepth extends boolean | undefined ? CoValue & Exclude<...> : [...] extends [...] ? Exclude<...> extends CoValue ? ItemDepth extends { ...; } ? readonly ((CoValue & ... 1 more ... & (ItemDepth extends boolean | undefined ? CoValue & Exclude<...> : [...] extends [...] ? Exclude<...> extends CoValue ? ItemDepth extends { ...; } ? readonly ((CoValue & ... 1 more ... & (ItemDepth extends boolean | undefined ? CoValue & Exclude<...> : [...] extends [...] ? Exclude<...> extends CoValue ? ItemDepth extends { ...; } ? readonly ((CoValue & ... 1 more ... & (ItemDepth extends boolean | undefined ? CoValue & Exclude<...> : [...] extends [...] ? Exclude<...> extends CoValue ? ItemDepth extends { ...; } ? readonly ((CoValue & ... 1 more ... & (ItemDepth extends boolean | undefined ? CoValue & Exclude<...> : [...] extends [...] ? Exclude<...> extends CoValue ? ItemDepth extends { ...; } ? readonly ((CoValue & ... 1 more ... & (ItemDepth extends boolean | undefined ? CoValue & Exclude<...> : [...] extends [...] ? Exclude<...> extends CoValue ? ItemDepth extends { ...; } ? readonly ((CoValue & ... 1 more ... & (ItemDepth extends boolean ... export loadedBubbleTeaOrder>; export constconst BubbleTeaOrder: co.Map<{ name: z.z.ZodString; }, unknown, Account | Group>PartialBubbleTeaOrder =const PartialBubbleTeaOrder: co.Map<{ name: z.ZodOptional<z.z.ZodString>; }, unknown, Account | Group>BubbleTeaOrder.const BubbleTeaOrder: co.Map<{ name: z.z.ZodString; }, unknown, Account | Group>CoMapSchema<{ name: ZodString; }, unknown, Account | Group>.partial<"name">(keys?: { name: true; } | undefined): co.Map<{ name: z.ZodOptional<z.z.ZodString>; }, unknown, Account | Group>Creates a new CoMap schema by making all fields optional.partial(); export typePartialBubbleTeaOrder =type PartialBubbleTeaOrder = { readonly name: string | undefined; } & CoMapimport coco.loaded<typeoftype loaded<T extends CoValueClassOrSchema, R extends ResolveQuery<T> = true> = R extends boolean | undefined ? NonNullable<InstanceOfSchemaCoValuesNullable<T>> : [NonNullable<InstanceOfSchemaCoValuesNullable<T>>] extends [...] ? Exclude<...> extends CoValue ? R extends { ...; } ? readonly ((CoValue & ... 1 more ... & (ItemDepth extends boolean | undefined ? CoValue & Exclude<...> : [...] extends [...] ? Exclude<...> extends CoValue ? ItemDepth extends { ...; } ? readonly ((CoValue & ... 1 more ... & (ItemDepth extends boolean | undefined ? CoValue & Exclude<...> : [...] extends [...] ? Exclude<...> extends CoValue ? ItemDepth extends { ...; } ? readonly ((CoValue & ... 1 more ... & (ItemDepth extends boolean | undefined ? CoValue & Exclude<...> : [...] extends [...] ? Exclude<...> extends CoValue ? ItemDepth extends { ...; } ? readonly ((CoValue & ... 1 more ... & (ItemDepth extends boolean | undefined ? CoValue & Exclude<...> : [...] extends [...] ? Exclude<...> extends CoValue ? ItemDepth extends { ...; } ? readonly ((CoValue & ... 1 more ... & (ItemDepth extends boolean | undefined ? CoValue & Exclude<...> : [...] extends [...] ? Exclude<...> extends CoValue ? ItemDepth extends { ...; } ? readonly ((CoValue & ... 1 more ... & (ItemDepth extends boolean | undefined ? CoValue & Exclude<...> : [...] extends [...] ? Exclude<...> extends CoValue ? ItemDepth extends { ...; } ? readonly ((CoValue & ... 1 more ... & (ItemDepth extends boolean | undefined ? CoValue & Exclude<...> : [...] extends [...] ? Exclude<...> extends CoValue ? ItemDepth extends { ...; } ? readonly ((CoValue & ... 1 more ... & (ItemDepth extends boolean ... export loadedPartialBubbleTeaOrder>;const PartialBubbleTeaOrder: co.Map<{ name: z.ZodOptional<z.z.ZodString>; }, unknown, Account | Group>
Writing the components in React
Let's write the form component that will be used for both create and update.
// OrderForm.tsx export functionOrderForm({function OrderForm({ order, onSave }: { order: BubbleTeaOrder | PartialBubbleTeaOrder; onSave?: (e: React.FormEvent<HTMLFormElement>) => void; }): React.JSX.Elementorder,order: ({ readonly name: string; } & CoMap) | ({ readonly name: string | undefined; } & CoMap)onSave: ((e: React.FormEvent<HTMLFormElement>) => void) | undefinedonSave }: {order:order: ({ readonly name: string; } & CoMap) | ({ readonly name: string | undefined; } & CoMap)BubbleTeaOrder |type BubbleTeaOrder = { readonly name: string; } & CoMapPartialBubbleTeaOrder;type PartialBubbleTeaOrder = { readonly name: string | undefined; } & CoMaponSave?: ((e: React.FormEvent<HTMLFormElement>) => void) | undefinedonSave?: (e: React.FormEvent<HTMLFormElement>e: React.interface React.FormEvent<T = Element>FormEvent<HTMLFormElement>) => void; }) { return ( <React.JSX.IntrinsicElements.form: React.DetailedHTMLProps<React.FormHTMLAttributes<HTMLFormElement>, HTMLFormElement>formReact.DOMAttributes<HTMLFormElement>.onSubmit?: React.FormEventHandler<HTMLFormElement> | undefinedonSubmit={onSave: ((e: React.FormEvent<HTMLFormElement>) => void) | undefinedonSave || (e: React.FormEvent<HTMLFormElement>e =>e: React.FormEvent<HTMLFormElement>e.React.BaseSyntheticEvent<Event, EventTarget & HTMLFormElement, EventTarget>.preventDefault(): voidpreventDefault())}> <React.JSX.IntrinsicElements.label: React.DetailedHTMLProps<React.LabelHTMLAttributes<HTMLLabelElement>, HTMLLabelElement>label> Name <React.JSX.IntrinsicElements.input: React.DetailedHTMLProps<React.InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>inputReact.InputHTMLAttributes<HTMLInputElement>.type?: React.HTMLInputTypeAttribute | undefinedtype="text"React.InputHTMLAttributes<HTMLInputElement>.value?: string | number | readonly string[] | undefinedvalue={order.order: ({ readonly name: string; } & CoMap) | ({ readonly name: string | undefined; } & CoMap)name: string | undefinedname || ""}React.InputHTMLAttributes<HTMLInputElement>.onChange?: React.ChangeEventHandler<HTMLInputElement> | undefinedonChange={(e: React.ChangeEvent<HTMLInputElement>e) =>order.order: ({ readonly name: string; } & CoMap) | ({ readonly name: string | undefined; } & CoMap)CoMap.$jazz: CoMapJazzApi<{ readonly name: string; } & CoMap> | CoMapJazzApi<{ readonly name: string | undefined; } & CoMap>Jazz methods for CoMaps are inside this property. This allows CoMaps to be used as plain objects while still having access to Jazz methods, and also doesn't limit which key names can be used inside CoMaps.$jazz.CoMapJazzApi<M extends CoMap>.set<"name">(key: "name", value: string): voidSet a value on the CoMapset("name",e: React.ChangeEvent<HTMLInputElement>e.React.ChangeEvent<HTMLInputElement>.target: EventTarget & HTMLInputElementtarget.HTMLInputElement.value: stringReturns the value of the data at the cursor's current position.value)}React.InputHTMLAttributes<HTMLInputElement>.required?: boolean | undefinedrequired /> </React.JSX.IntrinsicElements.label: React.DetailedHTMLProps<React.LabelHTMLAttributes<HTMLLabelElement>, HTMLLabelElement>label> {onSave: ((e: React.FormEvent<HTMLFormElement>) => void) | undefinedonSave && <React.JSX.IntrinsicElements.button: React.DetailedHTMLProps<React.ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>buttonReact.ButtonHTMLAttributes<HTMLButtonElement>.type?: "button" | "reset" | "submit" | undefinedtype="submit">Submit</React.JSX.IntrinsicElements.button: React.DetailedHTMLProps<React.ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>button>} </React.JSX.IntrinsicElements.form: React.DetailedHTMLProps<React.FormHTMLAttributes<HTMLFormElement>, HTMLFormElement>form> ); }
Writing the edit form
To make the edit form, simply pass the BubbleTeaOrder. Changes are automatically saved as you type.
// EditOrder.tsx export functionEditOrder(function EditOrder(props: { id: string; }): React.JSX.Element | undefinedprops: {props: { id: string; }id: stringid: string }) { constorder =const order: ({ readonly name: string; } & CoMap) | null | undefineduseCoState<co.Map<{ name: z.z.ZodString; }, unknown, Account | Group>, true>(Schema: co.Map<{ name: z.z.ZodString; }, unknown, Account | Group>, id: string | undefined, options?: { ...; } | undefined): ({ ...; } & CoMap) | ... 1 more ... | undefinedReact hook for subscribing to CoValues and handling loading states. This hook provides a convenient way to subscribe to CoValues and automatically handles the subscription lifecycle (subscribe on mount, unsubscribe on unmount). It also supports deep loading of nested CoValues through resolve queries.useCoState(BubbleTeaOrder,const BubbleTeaOrder: co.Map<{ name: z.z.ZodString; }, unknown, Account | Group>props.props: { id: string; }id: stringid); if (!order) return; return <const order: ({ readonly name: string; } & CoMap) | null | undefinedOrderFormfunction OrderForm({ order, }: { order: PartialBubbleTeaOrder; }): React.JSX.Elementorder={order: { readonly name: string | undefined; } & CoMaporder} />; }const order: { readonly name: string; } & CoMap
Writing the create form
For the create form, we need to:
- Create a partial order.
- Edit the partial order.
- Convert the partial order to a "real" order on submit.
Here's how that looks like:
// CreateOrder.tsx export functionCreateOrder(function CreateOrder(props: { id: string; }): React.JSX.Element | undefinedprops: {props: { id: string; }id: stringid: string }) { constorders =const orders: CoList<({ readonly name: string; } & CoMap) | null> | undefineduseAccountWithSelector<co.Account<{ root: co.Map<{ orders: co.List<co.Map<{ name: z.z.ZodString; }, unknown, Account | Group>>; }, unknown, Account | Group>; profile: co.Profile<BaseProfileShape>; }>, CoList<...> | undefined, { ...; }>(AccountSchema: co.Account<...> | undefined, options: { ...; }): CoList<...> | undefinedReact hook for accessing the current user's account with selective data extraction and custom equality checking. This hook extends `useAccount` by allowing you to select only specific parts of the account data through a selector function, which helps reduce unnecessary re-renders by narrowing down the returned data. Additionally, you can provide a custom equality function to further optimize performance by controlling when the component should re-render based on the selected data. The hook automatically handles the subscription lifecycle and supports deep loading of nested CoValues through resolve queries, just like `useAccount`.useAccountWithSelector(JazzAccount, {const JazzAccount: co.Account<{ root: co.Map<{ orders: co.List<co.Map<{ name: z.z.ZodString; }, unknown, Account | Group>>; }, unknown, Account | Group>; profile: co.Profile<BaseProfileShape>; }>resolve?: RefsToResolve<{ readonly root: ({ readonly orders: CoList<({ readonly name: string; } & CoMap) | null> | null; } & CoMap) | null; readonly profile: ({ readonly name: string; readonly inbox: string | undefined; readonly inboxInvite: string | undefined; } & CoMap) | null; } & Account, 10, []> | undefinedResolve query to specify which nested CoValues to load from the accountresolve: {root: {root?: RefsToResolve<{ readonly orders: CoList<({ readonly name: string; } & CoMap) | null> | null; } & CoMap, 10, [0]> | undefinedorders: true } },orders?: RefsToResolve<CoList<({ readonly name: string; } & CoMap) | null>, 10, [0, 0]> | undefinedselect: (account: CoMapLikeLoaded<{ readonly root: ({ readonly orders: CoList<({ readonly name: string; } & CoMap) | null> | null; } & CoMap) | null; readonly profile: ({ readonly name: string; readonly inbox: string | undefined; readonly inboxInvite: string | undefined; } & CoMap) | null; } & Account, { ...; }, 10, []> | null | undefined) => CoList<...> | undefinedSelect which value to return from the account dataselect: (account) =>account: CoMapLikeLoaded<{ readonly root: ({ readonly orders: CoList<({ readonly name: string; } & CoMap) | null> | null; } & CoMap) | null; readonly profile: ({ readonly name: string; readonly inbox: string | undefined; readonly inboxInvite: string | undefined; } & CoMap) | null; } & Account, { ...; }, 10, []> | null | undefinedaccount?.account: CoMapLikeLoaded<{ readonly root: ({ readonly orders: CoList<({ readonly name: string; } & CoMap) | null> | null; } & CoMap) | null; readonly profile: ({ readonly name: string; readonly inbox: string | undefined; readonly inboxInvite: string | undefined; } & CoMap) | null; } & Account, { ...; }, 10, []> | null | undefinedroot.Account.root: { readonly orders: CoList<({ readonly name: string; } & CoMap) | null>; } & { readonly orders: CoList<({ readonly name: string; } & CoMap) | null> | null; } & CoMaporders, }); constorders: CoList<({ readonly name: string; } & CoMap) | null> | undefinednewOrder =const newOrder: ({ readonly name: string | undefined; } & CoMap) | null | undefineduseCoState<co.Map<{ name: z.ZodOptional<z.z.ZodString>; }, unknown, Account | Group>, true>(Schema: co.Map<{ name: z.ZodOptional<z.z.ZodString>; }, unknown, Account | Group>, id: string | undefined, options?: { ...; } | undefined): ({ ...; } & CoMap) | ... 1 more ... | undefinedReact hook for subscribing to CoValues and handling loading states. This hook provides a convenient way to subscribe to CoValues and automatically handles the subscription lifecycle (subscribe on mount, unsubscribe on unmount). It also supports deep loading of nested CoValues through resolve queries.useCoState(PartialBubbleTeaOrder,const PartialBubbleTeaOrder: co.Map<{ name: z.ZodOptional<z.z.ZodString>; }, unknown, Account | Group>props.props: { id: string; }id: stringid); if (!newOrder || !const newOrder: ({ readonly name: string | undefined; } & CoMap) | null | undefinedorders) return; constconst orders: CoList<({ readonly name: string; } & CoMap) | null> | undefinedconst handleSave: (e: React.FormEvent<HTMLFormElement>) => voidhandleSave = (e: React.FormEvent<HTMLFormElement>e: React.interface React.FormEvent<T = Element>FormEvent<HTMLFormElement>) => {e: React.FormEvent<HTMLFormElement>e.React.BaseSyntheticEvent<Event, EventTarget & HTMLFormElement, EventTarget>.preventDefault(): voidpreventDefault(); // Convert to real order and add to the list // Note: the name field is marked as required in the form, so we can assume that has been set in this case // In a more complex form, you would need to validate the partial value before storing itorders.const orders: CoList<({ readonly name: string; } & CoMap) | null>$jazz.CoList<({ readonly name: string; } & CoMap) | null>.$jazz: CoListJazzApi<CoList<({ readonly name: string; } & CoMap) | null>>CoListJazzApi<CoList<({ readonly name: string; } & CoMap) | null>>.push(...items: (({ readonly name: string; } & CoMap) | CoMapInit<{ readonly name: string; } & CoMap> | null)[]): numberAppends new elements to the end of an array, and returns the new length of the array.push(newOrder asconst newOrder: { readonly name: string | undefined; } & CoMapBubbleTeaOrder); }; return ( <type BubbleTeaOrder = { readonly name: string; } & CoMapOrderFormfunction OrderForm({ order, onSave, }: { order: BubbleTeaOrder | PartialBubbleTeaOrder; onSave?: (e: React.FormEvent<HTMLFormElement>) => void; }): React.JSX.Elementorder={order: ({ readonly name: string; } & CoMap) | ({ readonly name: string | undefined; } & CoMap)newOrder}const newOrder: { readonly name: string | undefined; } & CoMaponSave?: ((e: React.FormEvent<HTMLFormElement>) => void) | undefinedonSave={const handleSave: (e: React.FormEvent<HTMLFormElement>) => voidhandleSave} /> ); }
Editing with a save button
If you need a save button for editing (rather than automatic saving), you can use Jazz's branching feature. The example app shows how to create a private branch for editing that can be merged back when the user saves:
import { useCoState } from "jazz-tools/react"; import { Group } from "jazz-tools"; import { useState, useMemo } from "react"; export function EditOrderWithSave(props: { id: string }) { // Create a new group for the branch, so that every time we open the edit page, // we create a new private branch const owner = useMemo(() => Group.create(), []); const order = useCoState(BubbleTeaOrder, props.id, { resolve: { addOns: { $each: true }, instructions: true, }, unstable_branch: { name: "edit-order", owner, }, }); function handleSave(e: React.FormEvent<HTMLFormElement>) { e.preventDefault(); if (!order) return; // Merge the branch back to the original order.$jazz.unstable_merge(); // Navigate away or show success message } function handleCancel() { // Navigate away without saving - the branch will be discarded } if (!order) return; return <OrderForm order={order} onSave={handleSave} onCancel={handleCancel} />; }
This approach creates a private branch using unstable_branch with a unique owner group. The user can edit the branch without affecting the original data, and changes are only persisted when they click save via unstable_merge().
Important: Version control is currently unstable and we may ship breaking changes in patch releases.
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, boolean inputs, and rich text.